feat: redesign account and SSO management (#5886)
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/oauth2"
|
||||
@@ -21,6 +22,8 @@ type IdentityProvider struct {
|
||||
config *storepb.OAuth2Config
|
||||
}
|
||||
|
||||
const userInfoRequestTimeout = 10 * time.Second
|
||||
|
||||
// NewIdentityProvider initializes a new OAuth2 Identity Provider with the given configuration.
|
||||
func NewIdentityProvider(config *storepb.OAuth2Config) (*IdentityProvider, error) {
|
||||
for v, field := range map[string]string{
|
||||
@@ -78,9 +81,9 @@ func (p *IdentityProvider) ExchangeToken(ctx context.Context, redirectURL, code,
|
||||
}
|
||||
|
||||
// UserInfo returns the parsed user information using the given OAuth2 token.
|
||||
func (p *IdentityProvider) UserInfo(token string) (*idp.IdentityProviderUserInfo, error) {
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest(http.MethodGet, p.config.UserInfoUrl, nil)
|
||||
func (p *IdentityProvider) UserInfo(ctx context.Context, token string) (*idp.IdentityProviderUserInfo, error) {
|
||||
client := &http.Client{Timeout: userInfoRequestTimeout}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.config.UserInfoUrl, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create http request")
|
||||
}
|
||||
@@ -92,6 +95,14 @@ func (p *IdentityProvider) UserInfo(token string) (*idp.IdentityProviderUserInfo
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if readErr != nil {
|
||||
return nil, errors.Wrap(readErr, "failed to read error response body")
|
||||
}
|
||||
return nil, errors.Errorf("userinfo request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to read response body")
|
||||
|
||||
@@ -152,7 +152,7 @@ func TestIdentityProvider(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testAccessToken, oauthToken)
|
||||
|
||||
userInfoResult, err := oauth2.UserInfo(oauthToken)
|
||||
userInfoResult, err := oauth2.UserInfo(ctx, oauthToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
wantUserInfo := &idp.IdentityProviderUserInfo{
|
||||
@@ -162,3 +162,55 @@ func TestIdentityProvider(t *testing.T) {
|
||||
}
|
||||
assert.Equal(t, wantUserInfo, userInfoResult)
|
||||
}
|
||||
|
||||
func TestIdentityProviderUserInfoUsesContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
oauth2, err := NewIdentityProvider(
|
||||
&storepb.OAuth2Config{
|
||||
ClientId: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
TokenUrl: "https://example.com/oauth2/token",
|
||||
UserInfoUrl: s.URL,
|
||||
FieldMapping: &storepb.FieldMapping{
|
||||
Identifier: "sub",
|
||||
},
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = oauth2.UserInfo(ctx, "test-access-token")
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "failed to get user information")
|
||||
}
|
||||
|
||||
func TestIdentityProviderUserInfoRejectsNon2xx(t *testing.T) {
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "upstream failure", http.StatusBadGateway)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
oauth2, err := NewIdentityProvider(
|
||||
&storepb.OAuth2Config{
|
||||
ClientId: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
TokenUrl: "https://example.com/oauth2/token",
|
||||
UserInfoUrl: s.URL,
|
||||
FieldMapping: &storepb.FieldMapping{
|
||||
Identifier: "sub",
|
||||
},
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = oauth2.UserInfo(context.Background(), "test-access-token")
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "userinfo request failed with status 502")
|
||||
assert.ErrorContains(t, err, "upstream failure")
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ func (s *APIV1Service) resolveSSOIdentity(ctx context.Context, idpName, code, re
|
||||
if err != nil {
|
||||
return nil, nil, status.Errorf(codes.Internal, "failed to exchange token, error: %v", err)
|
||||
}
|
||||
userInfo, err = oauth2IdentityProvider.UserInfo(token)
|
||||
userInfo, err = oauth2IdentityProvider.UserInfo(ctx, token)
|
||||
if err != nil {
|
||||
return nil, nil, status.Errorf(codes.Internal, "failed to get user info, error: %v", err)
|
||||
}
|
||||
|
||||
@@ -27,15 +27,15 @@ func NewTestService(t *testing.T) *TestService {
|
||||
// Create a test store with SQLite
|
||||
testStore := teststore.NewTestingStore(ctx, t)
|
||||
|
||||
// Create a test profile with a temp directory for file storage,
|
||||
// so tests that create attachments don't leave artifacts in the source tree.
|
||||
// Align the profile data directory with the test store so attachment files and
|
||||
// derived caches resolve against the same location as DeleteAttachmentStorage.
|
||||
testProfile := &profile.Profile{
|
||||
Demo: true,
|
||||
Version: "test-1.0.0",
|
||||
InstanceURL: "http://localhost:8080",
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
Data: t.TempDir(),
|
||||
Data: testStore.GetDataDir(),
|
||||
}
|
||||
|
||||
// Create APIV1Service with nil grpcServer since we're testing direct calls
|
||||
|
||||
@@ -2,6 +2,8 @@ package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -65,3 +67,423 @@ func TestDeleteUserSelfDeleteCleansAccountDataAndAuthCookies(t *testing.T) {
|
||||
require.NotNil(t, carrier)
|
||||
require.Contains(t, strings.ToLower(carrier.Get("Set-Cookie")), "memos_refresh=")
|
||||
}
|
||||
|
||||
func TestDeleteUserSelfDeleteRemovesOwnedResourcesAndMemoSubtrees(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
user, err := ts.CreateRegularUser(ctx, "resource-owner")
|
||||
require.NoError(t, err)
|
||||
peer, err := ts.CreateRegularUser(ctx, "resource-peer")
|
||||
require.NoError(t, err)
|
||||
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
peerCtx := ts.CreateUserContext(ctx, peer.ID)
|
||||
|
||||
ownMemo, err := ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{
|
||||
Memo: &v1pb.Memo{
|
||||
Content: "owner memo",
|
||||
Visibility: v1pb.Visibility_PUBLIC,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
foreignMemo, err := ts.Service.CreateMemo(peerCtx, &v1pb.CreateMemoRequest{
|
||||
Memo: &v1pb.Memo{
|
||||
Content: "peer memo",
|
||||
Visibility: v1pb.Visibility_PUBLIC,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peerCommentOnOwnMemo, err := ts.Service.CreateMemoComment(peerCtx, &v1pb.CreateMemoCommentRequest{
|
||||
Name: ownMemo.Name,
|
||||
Comment: &v1pb.Memo{
|
||||
Content: "peer comment on owner memo",
|
||||
Visibility: v1pb.Visibility_PUBLIC,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peerNestedCommentOnOwnMemo, err := ts.Service.CreateMemoComment(peerCtx, &v1pb.CreateMemoCommentRequest{
|
||||
Name: peerCommentOnOwnMemo.Name,
|
||||
Comment: &v1pb.Memo{
|
||||
Content: "peer nested comment on owner memo",
|
||||
Visibility: v1pb.Visibility_PUBLIC,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
userCommentOnForeignMemo, err := ts.Service.CreateMemoComment(userCtx, &v1pb.CreateMemoCommentRequest{
|
||||
Name: foreignMemo.Name,
|
||||
Comment: &v1pb.Memo{
|
||||
Content: "owner comment on peer memo",
|
||||
Visibility: v1pb.Visibility_PUBLIC,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peerReplyToUserComment, err := ts.Service.CreateMemoComment(peerCtx, &v1pb.CreateMemoCommentRequest{
|
||||
Name: userCommentOnForeignMemo.Name,
|
||||
Comment: &v1pb.Memo{
|
||||
Content: "peer reply to owner comment",
|
||||
Visibility: v1pb.Visibility_PUBLIC,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ownMemoUID, err := apiv1.ExtractMemoUIDFromName(ownMemo.Name)
|
||||
require.NoError(t, err)
|
||||
ownMemoStore, err := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &ownMemoUID})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ownMemoStore)
|
||||
|
||||
foreignMemoUID, err := apiv1.ExtractMemoUIDFromName(foreignMemo.Name)
|
||||
require.NoError(t, err)
|
||||
foreignMemoStore, err := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &foreignMemoUID})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, foreignMemoStore)
|
||||
|
||||
attachedAttachment, err := ts.Store.CreateAttachment(ctx, &store.Attachment{
|
||||
UID: "attach-owner-memo",
|
||||
CreatorID: user.ID,
|
||||
Filename: "owner.txt",
|
||||
Type: "text/plain",
|
||||
Size: 4,
|
||||
Blob: []byte("memo"),
|
||||
MemoID: &ownMemoStore.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
thumbnailCachePath := filepath.Join(ts.Profile.Data, ".thumbnail_cache", attachedAttachment.UID+".jpeg")
|
||||
motionCachePath := filepath.Join(ts.Profile.Data, ".motion_cache", attachedAttachment.UID+".mp4")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(thumbnailCachePath), 0o755))
|
||||
require.NoError(t, os.WriteFile(thumbnailCachePath, []byte("thumb"), 0o644))
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(motionCachePath), 0o755))
|
||||
require.NoError(t, os.WriteFile(motionCachePath, []byte("motion"), 0o644))
|
||||
|
||||
unattachedAttachment, err := ts.Store.CreateAttachment(ctx, &store.Attachment{
|
||||
UID: "attach-owner-loose",
|
||||
CreatorID: user.ID,
|
||||
Filename: "loose.txt",
|
||||
Type: "text/plain",
|
||||
Size: 5,
|
||||
Blob: []byte("loose"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peerAttachment, err := ts.Store.CreateAttachment(ctx, &store.Attachment{
|
||||
UID: "attach-peer-keep",
|
||||
CreatorID: peer.ID,
|
||||
Filename: "peer.txt",
|
||||
Type: "text/plain",
|
||||
Size: 4,
|
||||
Blob: []byte("peer"),
|
||||
MemoID: &foreignMemoStore.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ts.Store.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: peer.ID,
|
||||
ContentID: ownMemo.Name,
|
||||
ReactionType: "👍",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = ts.Store.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: peer.ID,
|
||||
ContentID: userCommentOnForeignMemo.Name,
|
||||
ReactionType: "🔥",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = ts.Store.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
ContentID: foreignMemo.Name,
|
||||
ReactionType: "👋",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
peerReactionOnForeignMemo, err := ts.Store.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: peer.ID,
|
||||
ContentID: foreignMemo.Name,
|
||||
ReactionType: "✅",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ts.Store.CreateMemoShare(ctx, &store.MemoShare{
|
||||
UID: "share-owner-ownmemo",
|
||||
MemoID: ownMemoStore.ID,
|
||||
CreatorID: user.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = ts.Store.CreateMemoShare(ctx, &store.MemoShare{
|
||||
UID: "share-owner-foreignmemo",
|
||||
MemoID: foreignMemoStore.ID,
|
||||
CreatorID: user.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
peerShare, err := ts.Store.CreateMemoShare(ctx, &store.MemoShare{
|
||||
UID: "share-peer-foreignmemo",
|
||||
MemoID: foreignMemoStore.ID,
|
||||
CreatorID: peer.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ts.Store.CreateUserIdentity(ctx, &store.UserIdentity{
|
||||
UserID: user.ID,
|
||||
Provider: "google",
|
||||
ExternUID: "resource-owner-google-sub",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = ts.Store.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{
|
||||
TokenId: "pat-owner",
|
||||
TokenHash: "pat-owner-hash",
|
||||
Description: "owner pat",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
headerCtx := apiv1.WithHeaderCarrier(ctx)
|
||||
authCtx := ts.CreateUserContext(headerCtx, user.ID)
|
||||
_, err = ts.Service.DeleteUser(authCtx, &v1pb.DeleteUserRequest{
|
||||
Name: apiv1.BuildUserName(user.Username),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
deletedUser, err := ts.Store.GetUser(ctx, &store.FindUser{ID: &user.ID})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, deletedUser)
|
||||
|
||||
for _, memoName := range []string{
|
||||
ownMemo.Name,
|
||||
peerCommentOnOwnMemo.Name,
|
||||
peerNestedCommentOnOwnMemo.Name,
|
||||
userCommentOnForeignMemo.Name,
|
||||
peerReplyToUserComment.Name,
|
||||
} {
|
||||
memoUID, extractErr := apiv1.ExtractMemoUIDFromName(memoName)
|
||||
require.NoError(t, extractErr)
|
||||
memo, getErr := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
|
||||
require.NoError(t, getErr)
|
||||
require.Nil(t, memo, memoName)
|
||||
}
|
||||
|
||||
foreignMemoAfterDelete, err := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &foreignMemoUID})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, foreignMemoAfterDelete)
|
||||
|
||||
for _, attachmentID := range []int32{attachedAttachment.ID, unattachedAttachment.ID} {
|
||||
attachment, getErr := ts.Store.GetAttachment(ctx, &store.FindAttachment{ID: &attachmentID})
|
||||
require.NoError(t, getErr)
|
||||
require.Nil(t, attachment)
|
||||
}
|
||||
peerAttachmentAfterDelete, err := ts.Store.GetAttachment(ctx, &store.FindAttachment{ID: &peerAttachment.ID})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, peerAttachmentAfterDelete)
|
||||
_, err = os.Stat(thumbnailCachePath)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
_, err = os.Stat(motionCachePath)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
|
||||
ownMemoReactions, err := ts.Store.ListReactions(ctx, &store.FindReaction{ContentID: &ownMemo.Name})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, ownMemoReactions)
|
||||
|
||||
userCommentReactions, err := ts.Store.ListReactions(ctx, &store.FindReaction{ContentID: &userCommentOnForeignMemo.Name})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, userCommentReactions)
|
||||
|
||||
foreignMemoReactions, err := ts.Store.ListReactions(ctx, &store.FindReaction{ContentID: &foreignMemo.Name})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, foreignMemoReactions, 1)
|
||||
require.Equal(t, peerReactionOnForeignMemo.ID, foreignMemoReactions[0].ID)
|
||||
|
||||
ownerShares, err := ts.Store.ListMemoShares(ctx, &store.FindMemoShare{CreatorID: &user.ID})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, ownerShares)
|
||||
|
||||
peerShares, err := ts.Store.ListMemoShares(ctx, &store.FindMemoShare{CreatorID: &peer.ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, peerShares, 1)
|
||||
require.Equal(t, peerShare.ID, peerShares[0].ID)
|
||||
|
||||
sentInboxes, err := ts.Store.ListInboxes(ctx, &store.FindInbox{SenderID: &user.ID})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, sentInboxes)
|
||||
receivedInboxes, err := ts.Store.ListInboxes(ctx, &store.FindInbox{ReceiverID: &user.ID})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, receivedInboxes)
|
||||
|
||||
identities, err := ts.Store.ListUserIdentities(ctx, &store.FindUserIdentity{UserID: &user.ID})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, identities)
|
||||
|
||||
patSetting, err := ts.Store.GetUserSetting(ctx, &store.FindUserSetting{
|
||||
UserID: &user.ID,
|
||||
Key: storepb.UserSetting_PERSONAL_ACCESS_TOKENS,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, patSetting)
|
||||
}
|
||||
|
||||
func TestDeleteUserRollbackPreservesAllResources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
user, err := ts.CreateRegularUser(ctx, "rollback-owner")
|
||||
require.NoError(t, err)
|
||||
peer, err := ts.CreateRegularUser(ctx, "rollback-peer")
|
||||
require.NoError(t, err)
|
||||
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
ownMemo, err := ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{
|
||||
Memo: &v1pb.Memo{
|
||||
Content: "rollback owner memo",
|
||||
Visibility: v1pb.Visibility_PUBLIC,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ownMemoUID, err := apiv1.ExtractMemoUIDFromName(ownMemo.Name)
|
||||
require.NoError(t, err)
|
||||
ownMemoStore, err := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &ownMemoUID})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ownMemoStore)
|
||||
|
||||
attachment, err := ts.Store.CreateAttachment(ctx, &store.Attachment{
|
||||
UID: "attach-rollback-owner",
|
||||
CreatorID: user.ID,
|
||||
Filename: "rollback.txt",
|
||||
Type: "text/plain",
|
||||
Size: 8,
|
||||
Blob: []byte("rollback"),
|
||||
MemoID: &ownMemoStore.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
reaction, err := ts.Store.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
ContentID: ownMemo.Name,
|
||||
ReactionType: "💥",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
share, err := ts.Store.CreateMemoShare(ctx, &store.MemoShare{
|
||||
UID: "share-rollback-owner",
|
||||
MemoID: ownMemoStore.ID,
|
||||
CreatorID: user.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
inbox, err := ts.Store.CreateInbox(ctx, &store.Inbox{
|
||||
SenderID: peer.ID,
|
||||
ReceiverID: user.ID,
|
||||
Status: store.UNREAD,
|
||||
Message: &storepb.InboxMessage{
|
||||
Type: storepb.InboxMessage_MEMO_COMMENT,
|
||||
Payload: &storepb.InboxMessage_MemoComment{
|
||||
MemoComment: &storepb.InboxMessage_MemoCommentPayload{
|
||||
MemoId: ownMemoStore.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ts.Store.CreateUserIdentity(ctx, &store.UserIdentity{
|
||||
UserID: user.ID,
|
||||
Provider: "google",
|
||||
ExternUID: "rollback-owner-google-sub",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = ts.Store.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{
|
||||
TokenId: "pat-rollback-owner",
|
||||
TokenHash: "pat-rollback-owner-hash",
|
||||
Description: "rollback pat",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
headerCtx := apiv1.WithHeaderCarrier(ctx)
|
||||
failCtx := store.WithDeleteUserFailpoint(headerCtx, store.DeleteUserFailpointBeforeCommit)
|
||||
authCtx := ts.CreateUserContext(failCtx, user.ID)
|
||||
_, err = ts.Service.DeleteUser(authCtx, &v1pb.DeleteUserRequest{
|
||||
Name: apiv1.BuildUserName(user.Username),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "delete user failpoint before commit")
|
||||
|
||||
userAfterRollback, err := ts.Store.GetUser(ctx, &store.FindUser{ID: &user.ID})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, userAfterRollback)
|
||||
|
||||
memoAfterRollback, err := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &ownMemoUID})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, memoAfterRollback)
|
||||
|
||||
attachmentAfterRollback, err := ts.Store.GetAttachment(ctx, &store.FindAttachment{ID: &attachment.ID})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, attachmentAfterRollback)
|
||||
|
||||
reactionAfterRollback, err := ts.Store.GetReaction(ctx, &store.FindReaction{ID: &reaction.ID})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, reactionAfterRollback)
|
||||
|
||||
shareAfterRollback, err := ts.Store.GetMemoShare(ctx, &store.FindMemoShare{ID: &share.ID})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, shareAfterRollback)
|
||||
|
||||
inboxesAfterRollback, err := ts.Store.ListInboxes(ctx, &store.FindInbox{ID: &inbox.ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, inboxesAfterRollback, 1)
|
||||
|
||||
identitiesAfterRollback, err := ts.Store.ListUserIdentities(ctx, &store.FindUserIdentity{UserID: &user.ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, identitiesAfterRollback, 1)
|
||||
|
||||
patSetting, err := ts.Store.GetUserSetting(ctx, &store.FindUserSetting{
|
||||
UserID: &user.ID,
|
||||
Key: storepb.UserSetting_PERSONAL_ACCESS_TOKENS,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, patSetting)
|
||||
}
|
||||
|
||||
func TestDeleteUserReturnsErrorWhenAttachmentStorageCleanupFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
user, err := ts.CreateRegularUser(ctx, "cleanup-failure-owner")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ts.Store.CreateAttachment(ctx, &store.Attachment{
|
||||
UID: "attach-cleanup-failure",
|
||||
CreatorID: user.ID,
|
||||
Filename: "failure.txt",
|
||||
Type: "text/plain",
|
||||
Size: 7,
|
||||
Blob: []byte("failure"),
|
||||
StorageType: storepb.AttachmentStorageType_LOCAL,
|
||||
Reference: "cleanup-failure.txt",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
headerCtx := apiv1.WithHeaderCarrier(ctx)
|
||||
failCtx := store.WithDeleteAttachmentStorageFailpoint(headerCtx)
|
||||
authCtx := ts.CreateUserContext(failCtx, user.ID)
|
||||
_, err = ts.Service.DeleteUser(authCtx, &v1pb.DeleteUserRequest{
|
||||
Name: apiv1.BuildUserName(user.Username),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "attachment storage cleanup failed")
|
||||
require.ErrorContains(t, err, "attachment_id=")
|
||||
require.ErrorContains(t, err, store.ErrDeleteAttachmentStorageFailpoint.Error())
|
||||
|
||||
deletedUser, err := ts.Store.GetUser(ctx, &store.FindUser{ID: &user.ID})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, deletedUser)
|
||||
}
|
||||
|
||||
@@ -353,27 +353,37 @@ func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserR
|
||||
}
|
||||
isSelfDelete := currentUser.ID == userID
|
||||
|
||||
if err := s.Store.DeleteUserIdentities(ctx, &store.DeleteUserIdentity{
|
||||
UserID: &userID,
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete user identities: %v", err)
|
||||
}
|
||||
if err := s.Store.DeleteUserSettings(ctx, &store.DeleteUserSetting{
|
||||
UserID: &userID,
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete user settings: %v", err)
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
||||
attachments, err := s.Store.DeleteUserCompletely(ctx, &store.DeleteUser{
|
||||
ID: user.ID,
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete user: %v", err)
|
||||
}
|
||||
var attachmentCleanupErr error
|
||||
failedAttachmentIDs := make([]int32, 0)
|
||||
for _, attachment := range attachments {
|
||||
if err := s.Store.DeleteAttachmentStorage(ctx, attachment); err != nil {
|
||||
slog.Warn("failed to delete attachment storage after deleting user", "user_id", userID, "attachment_id", attachment.ID, "error", err)
|
||||
failedAttachmentIDs = append(failedAttachmentIDs, attachment.ID)
|
||||
if attachmentCleanupErr == nil {
|
||||
attachmentCleanupErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
if isSelfDelete {
|
||||
if err := s.clearAuthCookies(ctx); err != nil {
|
||||
slog.Warn("failed to clear auth cookies after self delete", "user_id", userID, "error", err)
|
||||
}
|
||||
}
|
||||
if attachmentCleanupErr != nil {
|
||||
return nil, status.Errorf(
|
||||
codes.Internal,
|
||||
"user was deleted but attachment storage cleanup failed for %d attachment(s), first attachment_id=%d: %v",
|
||||
len(failedAttachmentIDs),
|
||||
failedAttachmentIDs[0],
|
||||
attachmentCleanupErr,
|
||||
)
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
@@ -76,6 +76,16 @@ const (
|
||||
motionCacheFolder = ".motion_cache"
|
||||
)
|
||||
|
||||
type deleteAttachmentStorageFailpointKey struct{}
|
||||
|
||||
// ErrDeleteAttachmentStorageFailpoint is returned by the test-only attachment storage failpoint.
|
||||
var ErrDeleteAttachmentStorageFailpoint = errors.New("delete attachment storage failpoint")
|
||||
|
||||
// WithDeleteAttachmentStorageFailpoint forces DeleteAttachmentStorage to return a failpoint error.
|
||||
func WithDeleteAttachmentStorageFailpoint(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, deleteAttachmentStorageFailpointKey{}, true)
|
||||
}
|
||||
|
||||
func (s *Store) CreateAttachment(ctx context.Context, create *Attachment) (*Attachment, error) {
|
||||
if !base.UIDMatcher.MatchString(create.UID) {
|
||||
return nil, errors.New("invalid uid")
|
||||
@@ -177,6 +187,9 @@ func (s *Store) DeleteAttachmentStorage(ctx context.Context, attachment *Attachm
|
||||
if attachment == nil {
|
||||
return nil
|
||||
}
|
||||
if shouldFailDeleteAttachmentStorage(ctx) {
|
||||
return ErrDeleteAttachmentStorageFailpoint
|
||||
}
|
||||
|
||||
if attachment.StorageType == storepb.AttachmentStorageType_LOCAL {
|
||||
if err := func() error {
|
||||
@@ -237,3 +250,8 @@ func (s *Store) deleteAttachmentDerivedCaches(attachment *Attachment) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldFailDeleteAttachmentStorage(ctx context.Context) bool {
|
||||
failpoint, ok := ctx.Value(deleteAttachmentStorageFailpointKey{}).(bool)
|
||||
return ok && failpoint
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]*
|
||||
if find.MemoID != nil {
|
||||
where, args = append(where, "`memo_id` = ?"), append(args, *find.MemoID)
|
||||
}
|
||||
if find.CreatorID != nil {
|
||||
where, args = append(where, "`creator_id` = ?"), append(args, *find.CreatorID)
|
||||
}
|
||||
|
||||
rows, err := d.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
|
||||
@@ -40,6 +40,9 @@ func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]*
|
||||
if find.MemoID != nil {
|
||||
where, args = append(where, "memo_id = "+placeholder(len(args)+1)), append(args, *find.MemoID)
|
||||
}
|
||||
if find.CreatorID != nil {
|
||||
where, args = append(where, "creator_id = "+placeholder(len(args)+1)), append(args, *find.CreatorID)
|
||||
}
|
||||
|
||||
rows, err := d.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
@@ -93,6 +96,9 @@ func (d *DB) GetMemoShare(ctx context.Context, find *store.FindMemoShare) (*stor
|
||||
if find.MemoID != nil {
|
||||
where, args = append(where, "memo_id = "+placeholder(len(args)+1)), append(args, *find.MemoID)
|
||||
}
|
||||
if find.CreatorID != nil {
|
||||
where, args = append(where, "creator_id = "+placeholder(len(args)+1)), append(args, *find.CreatorID)
|
||||
}
|
||||
|
||||
ms := &store.MemoShare{}
|
||||
if err := d.db.QueryRowContext(ctx, `
|
||||
|
||||
@@ -42,6 +42,9 @@ func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]*
|
||||
if find.MemoID != nil {
|
||||
where, args = append(where, "`memo_id` = ?"), append(args, *find.MemoID)
|
||||
}
|
||||
if find.CreatorID != nil {
|
||||
where, args = append(where, "`creator_id` = ?"), append(args, *find.CreatorID)
|
||||
}
|
||||
|
||||
rows, err := d.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
@@ -95,6 +98,9 @@ func (d *DB) GetMemoShare(ctx context.Context, find *store.FindMemoShare) (*stor
|
||||
if find.MemoID != nil {
|
||||
where, args = append(where, "`memo_id` = ?"), append(args, *find.MemoID)
|
||||
}
|
||||
if find.CreatorID != nil {
|
||||
where, args = append(where, "`creator_id` = ?"), append(args, *find.CreatorID)
|
||||
}
|
||||
|
||||
ms := &store.MemoShare{}
|
||||
if err := d.db.QueryRowContext(ctx, `
|
||||
|
||||
+4
-3
@@ -14,9 +14,10 @@ type MemoShare struct {
|
||||
|
||||
// FindMemoShare is used to filter memo shares in list/get queries.
|
||||
type FindMemoShare struct {
|
||||
ID *int32
|
||||
UID *string
|
||||
MemoID *int32
|
||||
ID *int32
|
||||
UID *string
|
||||
MemoID *int32
|
||||
CreatorID *int32
|
||||
}
|
||||
|
||||
// DeleteMemoShare identifies a share grant to remove.
|
||||
|
||||
@@ -47,6 +47,11 @@ func (s *Store) GetDriver() Driver {
|
||||
return s.driver
|
||||
}
|
||||
|
||||
// GetDataDir returns the store data directory.
|
||||
func (s *Store) GetDataDir() string {
|
||||
return s.profile.Data
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
// Stop all cache cleanup goroutines
|
||||
s.instanceSettingCache.Close()
|
||||
|
||||
+11
-6
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Role is the type of a role.
|
||||
@@ -80,13 +81,17 @@ type DeleteUser struct {
|
||||
ID int32
|
||||
}
|
||||
|
||||
func userCacheKey(userID int32) string {
|
||||
return strconv.Itoa(int(userID))
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
||||
user, err := s.driver.CreateUser(ctx, create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.userCache.Set(ctx, string(user.ID), user)
|
||||
s.userCache.Set(ctx, userCacheKey(user.ID), user)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -96,7 +101,7 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.userCache.Set(ctx, string(user.ID), user)
|
||||
s.userCache.Set(ctx, userCacheKey(user.ID), user)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -107,14 +112,14 @@ func (s *Store) ListUsers(ctx context.Context, find *FindUser) ([]*User, error)
|
||||
}
|
||||
|
||||
for _, user := range list {
|
||||
s.userCache.Set(ctx, string(user.ID), user)
|
||||
s.userCache.Set(ctx, userCacheKey(user.ID), user)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) {
|
||||
if find.ID != nil {
|
||||
if cache, ok := s.userCache.Get(ctx, string(*find.ID)); ok {
|
||||
if cache, ok := s.userCache.Get(ctx, userCacheKey(*find.ID)); ok {
|
||||
user, ok := cache.(*User)
|
||||
if ok {
|
||||
return user, nil
|
||||
@@ -131,7 +136,7 @@ func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) {
|
||||
}
|
||||
|
||||
user := list[0]
|
||||
s.userCache.Set(ctx, string(user.ID), user)
|
||||
s.userCache.Set(ctx, userCacheKey(user.ID), user)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -140,6 +145,6 @@ func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.userCache.Delete(ctx, string(delete.ID))
|
||||
s.userCache.Delete(ctx, userCacheKey(delete.ID))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
)
|
||||
|
||||
// DeleteUserFailpoint is a test-only hook for forcing a delete-user rollback.
|
||||
type DeleteUserFailpoint string
|
||||
|
||||
const (
|
||||
// DeleteUserFailpointBeforeCommit aborts after all delete statements run but before commit.
|
||||
DeleteUserFailpointBeforeCommit DeleteUserFailpoint = "before_commit"
|
||||
)
|
||||
|
||||
type deleteUserFailpointKey struct{}
|
||||
|
||||
type deleteUserDialect string
|
||||
|
||||
const (
|
||||
deleteUserDialectSQLite deleteUserDialect = "sqlite"
|
||||
deleteUserDialectMySQL deleteUserDialect = "mysql"
|
||||
deleteUserDialectPostgres deleteUserDialect = "postgres"
|
||||
deleteUserBatchSize int = 500
|
||||
)
|
||||
|
||||
type deleteUserMemoRef struct {
|
||||
ID int32
|
||||
UID string
|
||||
}
|
||||
|
||||
type deleteUserTargetSet struct {
|
||||
memos []deleteUserMemoRef
|
||||
attachments []*Attachment
|
||||
attachmentIDs []int32
|
||||
userSettingKeys []storepb.UserSetting_Key
|
||||
inboxIDs []int32
|
||||
}
|
||||
|
||||
// WithDeleteUserFailpoint is a test-only helper that forces DeleteUserCompletely to roll back.
|
||||
func WithDeleteUserFailpoint(ctx context.Context, failpoint DeleteUserFailpoint) context.Context {
|
||||
return context.WithValue(ctx, deleteUserFailpointKey{}, failpoint)
|
||||
}
|
||||
|
||||
// DeleteUserCompletely deletes the user and all directly associated database resources in one transaction.
|
||||
// Attachment file/object cleanup must happen after commit because external storage cannot participate in SQL transactions.
|
||||
func (s *Store) DeleteUserCompletely(ctx context.Context, delete *DeleteUser) ([]*Attachment, error) {
|
||||
dialect, err := getDeleteUserDialect(s.profile.Driver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.driver.GetDB().BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to begin delete user transaction")
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
targets, err := collectDeleteUserTargets(ctx, tx, dialect, delete.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to collect delete user targets")
|
||||
}
|
||||
|
||||
if err := deleteUserTargetsTx(ctx, tx, dialect, delete.ID, targets); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to delete user targets")
|
||||
}
|
||||
|
||||
if getDeleteUserFailpoint(ctx) == DeleteUserFailpointBeforeCommit {
|
||||
return nil, errors.New("delete user failpoint before commit")
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to commit delete user transaction")
|
||||
}
|
||||
|
||||
s.userCache.Delete(ctx, userCacheKey(delete.ID))
|
||||
for _, key := range targets.userSettingKeys {
|
||||
s.userSettingCache.Delete(ctx, getUserSettingCacheKey(delete.ID, key.String()))
|
||||
}
|
||||
|
||||
return targets.attachments, nil
|
||||
}
|
||||
|
||||
func getDeleteUserFailpoint(ctx context.Context) DeleteUserFailpoint {
|
||||
failpoint, ok := ctx.Value(deleteUserFailpointKey{}).(DeleteUserFailpoint)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return failpoint
|
||||
}
|
||||
|
||||
func getDeleteUserDialect(driver string) (deleteUserDialect, error) {
|
||||
switch driver {
|
||||
case "sqlite":
|
||||
return deleteUserDialectSQLite, nil
|
||||
case "mysql":
|
||||
return deleteUserDialectMySQL, nil
|
||||
case "postgres":
|
||||
return deleteUserDialectPostgres, nil
|
||||
default:
|
||||
return "", errors.Errorf("unsupported delete user dialect: %s", driver)
|
||||
}
|
||||
}
|
||||
|
||||
func collectDeleteUserTargets(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, userID int32) (*deleteUserTargetSet, error) {
|
||||
targets := &deleteUserTargetSet{}
|
||||
|
||||
memos, err := listDeleteUserMemoTree(ctx, tx, dialect, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targets.memos = memos
|
||||
|
||||
attachments, err := listDeleteUserAttachments(ctx, tx, dialect, userID, memoIDsFromRefs(memos))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targets.attachments = attachments
|
||||
targets.attachmentIDs = attachmentIDsFromList(attachments)
|
||||
|
||||
userSettingKeys, err := listDeleteUserSettingKeys(ctx, tx, dialect, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targets.userSettingKeys = userSettingKeys
|
||||
|
||||
inboxIDs, err := listDeleteUserInboxIDs(ctx, tx, dialect, userID, memoIDSetFromRefs(memos))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targets.inboxIDs = inboxIDs
|
||||
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func deleteUserTargetsTx(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, userID int32, targets *deleteUserTargetSet) error {
|
||||
memoIDs := memoIDsFromRefs(targets.memos)
|
||||
contentIDs := memoContentIDsFromRefs(targets.memos)
|
||||
|
||||
if err := deleteReactionsByContentIDsTx(ctx, tx, dialect, contentIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteAttachmentsByIDsTx(ctx, tx, dialect, targets.attachmentIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteReactionsByCreatorTx(ctx, tx, dialect, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteMemoSharesTx(ctx, tx, dialect, userID, memoIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteInboxesByIDsTx(ctx, tx, dialect, targets.inboxIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteUserIdentitiesTx(ctx, tx, dialect, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteUserSettingsTx(ctx, tx, dialect, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteMemoRelationsTx(ctx, tx, dialect, memoIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteMemosTx(ctx, tx, dialect, memoIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteUserRowTx(ctx, tx, dialect, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func listDeleteUserMemoTree(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, userID int32) ([]deleteUserMemoRef, error) {
|
||||
if dialect == deleteUserDialectMySQL {
|
||||
return listDeleteUserMemoTreeIterative(ctx, tx, dialect, userID)
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
WITH RECURSIVE memo_tree(id, uid) AS (
|
||||
SELECT id, uid
|
||||
FROM memo
|
||||
WHERE creator_id = `+deleteUserPlaceholder(dialect, 1)+`
|
||||
UNION
|
||||
SELECT child.id, child.uid
|
||||
FROM memo child
|
||||
JOIN memo_relation rel ON rel.memo_id = child.id AND rel.type = 'COMMENT'
|
||||
JOIN memo_tree parent ON rel.related_memo_id = parent.id
|
||||
)
|
||||
SELECT id, uid
|
||||
FROM memo_tree
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
memos := make([]deleteUserMemoRef, 0)
|
||||
for rows.Next() {
|
||||
var memo deleteUserMemoRef
|
||||
if err := rows.Scan(&memo.ID, &memo.UID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memos = append(memos, memo)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return memos, nil
|
||||
}
|
||||
|
||||
func listDeleteUserMemoTreeIterative(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, userID int32) ([]deleteUserMemoRef, error) {
|
||||
roots, err := queryDeleteUserMemoRefs(ctx, tx, `
|
||||
SELECT id, uid
|
||||
FROM memo
|
||||
WHERE creator_id = `+deleteUserPlaceholder(dialect, 1), userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
memos := make([]deleteUserMemoRef, 0, len(roots))
|
||||
seen := make(map[int32]struct{})
|
||||
frontier := make([]int32, 0, len(roots))
|
||||
for _, memo := range roots {
|
||||
if _, exists := seen[memo.ID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[memo.ID] = struct{}{}
|
||||
memos = append(memos, memo)
|
||||
frontier = append(frontier, memo.ID)
|
||||
}
|
||||
|
||||
for len(frontier) > 0 {
|
||||
currentFrontier := frontier
|
||||
nextFrontier := make([]int32, 0)
|
||||
for _, batch := range deleteUserBatches(currentFrontier, deleteUserBatchSize) {
|
||||
clause, args := deleteUserInClause(dialect, 1, batch)
|
||||
children, err := queryDeleteUserMemoRefs(ctx, tx, `
|
||||
SELECT child.id, child.uid
|
||||
FROM memo child
|
||||
JOIN memo_relation rel ON rel.memo_id = child.id AND rel.type = 'COMMENT'
|
||||
WHERE rel.related_memo_id IN `+clause, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, child := range children {
|
||||
if _, exists := seen[child.ID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[child.ID] = struct{}{}
|
||||
memos = append(memos, child)
|
||||
nextFrontier = append(nextFrontier, child.ID)
|
||||
}
|
||||
}
|
||||
frontier = nextFrontier
|
||||
}
|
||||
|
||||
return memos, nil
|
||||
}
|
||||
|
||||
func queryDeleteUserMemoRefs(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]deleteUserMemoRef, error) {
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
memos := make([]deleteUserMemoRef, 0)
|
||||
for rows.Next() {
|
||||
var memo deleteUserMemoRef
|
||||
if err := rows.Scan(&memo.ID, &memo.UID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memos = append(memos, memo)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return memos, nil
|
||||
}
|
||||
|
||||
func listDeleteUserAttachments(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, userID int32, memoIDs []int32) ([]*Attachment, error) {
|
||||
attachments := make([]*Attachment, 0)
|
||||
seen := make(map[int32]struct{})
|
||||
if err := appendDeleteUserAttachments(ctx, tx, `
|
||||
SELECT
|
||||
id,
|
||||
uid,
|
||||
creator_id,
|
||||
memo_id,
|
||||
storage_type,
|
||||
reference,
|
||||
payload
|
||||
FROM attachment
|
||||
WHERE creator_id = `+deleteUserPlaceholder(dialect, 1), []any{userID}, seen, &attachments); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, batch := range deleteUserBatches(memoIDs, deleteUserBatchSize) {
|
||||
clause, args := deleteUserInClause(dialect, 1, batch)
|
||||
if err := appendDeleteUserAttachments(ctx, tx, `
|
||||
SELECT
|
||||
id,
|
||||
uid,
|
||||
creator_id,
|
||||
memo_id,
|
||||
storage_type,
|
||||
reference,
|
||||
payload
|
||||
FROM attachment
|
||||
WHERE memo_id IN `+clause, args, seen, &attachments); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return attachments, nil
|
||||
}
|
||||
|
||||
func appendDeleteUserAttachments(ctx context.Context, tx *sql.Tx, query string, args []any, seen map[int32]struct{}, attachments *[]*Attachment) error {
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
attachment := &Attachment{}
|
||||
var memoID sql.NullInt32
|
||||
var storageType string
|
||||
var payloadBytes []byte
|
||||
if err := rows.Scan(&attachment.ID, &attachment.UID, &attachment.CreatorID, &memoID, &storageType, &attachment.Reference, &payloadBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := seen[attachment.ID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[attachment.ID] = struct{}{}
|
||||
if memoID.Valid {
|
||||
attachment.MemoID = &memoID.Int32
|
||||
}
|
||||
attachment.StorageType = storepb.AttachmentStorageType(storepb.AttachmentStorageType_value[storageType])
|
||||
payload := &storepb.AttachmentPayload{}
|
||||
if len(payloadBytes) > 0 {
|
||||
if err := protojsonUnmarshaler.Unmarshal(payloadBytes, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
attachment.Payload = payload
|
||||
*attachments = append(*attachments, attachment)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func listDeleteUserSettingKeys(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, userID int32) ([]storepb.UserSetting_Key, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT key FROM user_setting WHERE user_id = `+deleteUserPlaceholder(dialect, 1), userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := make([]storepb.UserSetting_Key, 0)
|
||||
for rows.Next() {
|
||||
var keyString string
|
||||
if err := rows.Scan(&keyString); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := storepb.UserSetting_Key(storepb.UserSetting_Key_value[keyString])
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func listDeleteUserInboxIDs(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, userID int32, memoIDSet map[int32]struct{}) ([]int32, error) {
|
||||
directIDs, err := listDeleteUserDirectInboxIDs(ctx, tx, dialect, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inboxIDs := append([]int32{}, directIDs...)
|
||||
if len(memoIDSet) == 0 {
|
||||
return inboxIDs, nil
|
||||
}
|
||||
|
||||
memoIDs, err := listDeleteUserMemoReferencedInboxIDs(ctx, tx, dialect, userID, memoIDSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(inboxIDs, memoIDs...), nil
|
||||
}
|
||||
|
||||
func listDeleteUserDirectInboxIDs(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, userID int32) ([]int32, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT id
|
||||
FROM inbox
|
||||
WHERE sender_id = `+deleteUserPlaceholder(dialect, 1)+`
|
||||
OR receiver_id = `+deleteUserPlaceholder(dialect, 2), userID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
inboxIDs := make([]int32, 0)
|
||||
for rows.Next() {
|
||||
var inboxID int32
|
||||
if err := rows.Scan(&inboxID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inboxIDs = append(inboxIDs, inboxID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return inboxIDs, nil
|
||||
}
|
||||
|
||||
func listDeleteUserMemoReferencedInboxIDs(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, userID int32, memoIDSet map[int32]struct{}) ([]int32, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT id, message
|
||||
FROM inbox
|
||||
WHERE sender_id <> `+deleteUserPlaceholder(dialect, 1)+`
|
||||
AND receiver_id <> `+deleteUserPlaceholder(dialect, 2), userID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
inboxIDs := make([]int32, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
inboxID int32
|
||||
messageRaw []byte
|
||||
)
|
||||
if err := rows.Scan(&inboxID, &messageRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(messageRaw) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
message := &storepb.InboxMessage{}
|
||||
if err := protojsonUnmarshaler.Unmarshal(messageRaw, message); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inboxMessageTouchesMemoSet(message, memoIDSet) {
|
||||
inboxIDs = append(inboxIDs, inboxID)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return inboxIDs, nil
|
||||
}
|
||||
|
||||
func inboxMessageTouchesMemoSet(message *storepb.InboxMessage, memoIDSet map[int32]struct{}) bool {
|
||||
if message == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch message.Type {
|
||||
case storepb.InboxMessage_MEMO_COMMENT:
|
||||
payload := message.GetMemoComment()
|
||||
if payload == nil {
|
||||
return false
|
||||
}
|
||||
return memoIDInSet(payload.MemoId, memoIDSet) || memoIDInSet(payload.RelatedMemoId, memoIDSet)
|
||||
case storepb.InboxMessage_MEMO_MENTION:
|
||||
payload := message.GetMemoMention()
|
||||
if payload == nil {
|
||||
return false
|
||||
}
|
||||
return memoIDInSet(payload.MemoId, memoIDSet) || memoIDInSet(payload.RelatedMemoId, memoIDSet)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func memoIDInSet(id int32, memoIDSet map[int32]struct{}) bool {
|
||||
if id == 0 {
|
||||
return false
|
||||
}
|
||||
_, exists := memoIDSet[id]
|
||||
return exists
|
||||
}
|
||||
|
||||
func deleteReactionsByContentIDsTx(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, contentIDs []string) error {
|
||||
for _, batch := range deleteUserBatches(contentIDs, deleteUserBatchSize) {
|
||||
clause, args := deleteUserInClause(dialect, 1, batch)
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM reaction WHERE content_id IN `+clause, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteAttachmentsByIDsTx(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, attachmentIDs []int32) error {
|
||||
for _, batch := range deleteUserBatches(attachmentIDs, deleteUserBatchSize) {
|
||||
clause, args := deleteUserInClause(dialect, 1, batch)
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM attachment WHERE id IN `+clause, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteReactionsByCreatorTx(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, userID int32) error {
|
||||
_, err := tx.ExecContext(ctx, `DELETE FROM reaction WHERE creator_id = `+deleteUserPlaceholder(dialect, 1), userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func deleteMemoSharesTx(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, userID int32, memoIDs []int32) error {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM memo_share WHERE creator_id = `+deleteUserPlaceholder(dialect, 1), userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, batch := range deleteUserBatches(memoIDs, deleteUserBatchSize) {
|
||||
clause, args := deleteUserInClause(dialect, 1, batch)
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM memo_share WHERE memo_id IN `+clause, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteInboxesByIDsTx(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, inboxIDs []int32) error {
|
||||
for _, batch := range deleteUserBatches(inboxIDs, deleteUserBatchSize) {
|
||||
clause, args := deleteUserInClause(dialect, 1, batch)
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM inbox WHERE id IN `+clause, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteUserIdentitiesTx(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, userID int32) error {
|
||||
_, err := tx.ExecContext(ctx, `DELETE FROM user_identity WHERE user_id = `+deleteUserPlaceholder(dialect, 1), userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func deleteUserSettingsTx(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, userID int32) error {
|
||||
_, err := tx.ExecContext(ctx, `DELETE FROM user_setting WHERE user_id = `+deleteUserPlaceholder(dialect, 1), userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func deleteMemoRelationsTx(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, memoIDs []int32) error {
|
||||
for _, batch := range deleteUserBatches(memoIDs, deleteUserBatchSize) {
|
||||
memoClause, args := deleteUserInClause(dialect, 1, batch)
|
||||
relatedClause, relatedArgs := deleteUserInClause(dialect, len(args)+1, batch)
|
||||
query := `DELETE FROM memo_relation WHERE memo_id IN ` + memoClause + ` OR related_memo_id IN ` + relatedClause
|
||||
args = append(args, relatedArgs...)
|
||||
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteMemosTx(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, memoIDs []int32) error {
|
||||
for _, batch := range deleteUserBatches(memoIDs, deleteUserBatchSize) {
|
||||
clause, args := deleteUserInClause(dialect, 1, batch)
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM memo WHERE id IN `+clause, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteUserRowTx(ctx context.Context, tx *sql.Tx, dialect deleteUserDialect, userID int32) error {
|
||||
_, err := tx.ExecContext(ctx, `DELETE FROM `+deleteUserTableName(dialect, "user")+` WHERE id = `+deleteUserPlaceholder(dialect, 1), userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func deleteUserTableName(dialect deleteUserDialect, table string) string {
|
||||
switch dialect {
|
||||
case deleteUserDialectMySQL:
|
||||
return "`" + table + "`"
|
||||
case deleteUserDialectPostgres:
|
||||
return `"` + table + `"`
|
||||
default:
|
||||
return table
|
||||
}
|
||||
}
|
||||
|
||||
func deleteUserPlaceholder(dialect deleteUserDialect, index int) string {
|
||||
if dialect == deleteUserDialectPostgres {
|
||||
return fmt.Sprintf("$%d", index)
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
func deleteUserInClause[T any](dialect deleteUserDialect, start int, values []T) (string, []any) {
|
||||
placeholders := make([]string, 0, len(values))
|
||||
args := make([]any, 0, len(values))
|
||||
for i, value := range values {
|
||||
placeholders = append(placeholders, deleteUserPlaceholder(dialect, start+i))
|
||||
args = append(args, value)
|
||||
}
|
||||
return "(" + strings.Join(placeholders, ", ") + ")", args
|
||||
}
|
||||
|
||||
func deleteUserBatches[T any](values []T, size int) [][]T {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
if size <= 0 {
|
||||
size = len(values)
|
||||
}
|
||||
|
||||
batches := make([][]T, 0, (len(values)+size-1)/size)
|
||||
for start := 0; start < len(values); start += size {
|
||||
end := start + size
|
||||
if end > len(values) {
|
||||
end = len(values)
|
||||
}
|
||||
batches = append(batches, values[start:end])
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
func memoIDsFromRefs(memos []deleteUserMemoRef) []int32 {
|
||||
ids := make([]int32, 0, len(memos))
|
||||
for _, memo := range memos {
|
||||
ids = append(ids, memo.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func memoIDSetFromRefs(memos []deleteUserMemoRef) map[int32]struct{} {
|
||||
idSet := make(map[int32]struct{}, len(memos))
|
||||
for _, memo := range memos {
|
||||
idSet[memo.ID] = struct{}{}
|
||||
}
|
||||
return idSet
|
||||
}
|
||||
|
||||
func memoContentIDsFromRefs(memos []deleteUserMemoRef) []string {
|
||||
contentIDs := make([]string, 0, len(memos))
|
||||
for _, memo := range memos {
|
||||
contentIDs = append(contentIDs, "memos/"+memo.UID)
|
||||
}
|
||||
return contentIDs
|
||||
}
|
||||
|
||||
func attachmentIDsFromList(attachments []*Attachment) []int32 {
|
||||
ids := make([]int32, 0, len(attachments))
|
||||
for _, attachment := range attachments {
|
||||
if attachment == nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, attachment.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { identityProviderServiceClient } from "@/connect";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import { handleError } from "@/lib/error";
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
} from "@/types/proto/api/v1/idp_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const DEFAULT_TEMPLATE = "GitHub";
|
||||
|
||||
const templateList: IdentityProvider[] = [
|
||||
create(IdentityProviderSchema, {
|
||||
name: "",
|
||||
@@ -128,150 +130,216 @@ interface Props {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const identityProviderTypes = [...new Set(templateList.map((t) => t.type))];
|
||||
const [basicInfo, setBasicInfo] = useState({
|
||||
interface BasicInfoState {
|
||||
title: string;
|
||||
identifier: string;
|
||||
identifierFilter: string;
|
||||
}
|
||||
|
||||
function createEmptyFieldMapping(): FieldMapping {
|
||||
return create(FieldMappingSchema, {
|
||||
identifier: "",
|
||||
displayName: "",
|
||||
email: "",
|
||||
avatarUrl: "",
|
||||
});
|
||||
}
|
||||
|
||||
function createEmptyOAuth2Config(): OAuth2Config {
|
||||
return create(OAuth2ConfigSchema, {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "",
|
||||
tokenUrl: "",
|
||||
userInfoUrl: "",
|
||||
scopes: [],
|
||||
fieldMapping: createEmptyFieldMapping(),
|
||||
});
|
||||
}
|
||||
|
||||
function createEmptyBasicInfo(): BasicInfoState {
|
||||
return {
|
||||
title: "",
|
||||
identifier: "",
|
||||
identifierFilter: "",
|
||||
});
|
||||
const [type, setType] = useState<IdentityProvider_Type>(IdentityProvider_Type.OAUTH2);
|
||||
const [oauth2Config, setOAuth2Config] = useState<OAuth2Config>(
|
||||
create(OAuth2ConfigSchema, {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "",
|
||||
tokenUrl: "",
|
||||
userInfoUrl: "",
|
||||
scopes: [],
|
||||
fieldMapping: create(FieldMappingSchema, {
|
||||
identifier: "",
|
||||
displayName: "",
|
||||
email: "",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const [oauth2Scopes, setOAuth2Scopes] = useState<string>("");
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>("GitHub");
|
||||
const isCreating = identityProvider === undefined;
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeIdentifier(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "-")
|
||||
.replace(/--+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function normalizeScopes(value: string): string[] {
|
||||
return value
|
||||
.split(/\s+/)
|
||||
.map((scope) => scope.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildDialogStateFromTemplate(templateName: string) {
|
||||
const template = templateList.find((item) => item.title === templateName) ?? templateList[0];
|
||||
const oauth2Config =
|
||||
template.type === IdentityProvider_Type.OAUTH2 && template.config?.config.case === "oauth2Config"
|
||||
? create(OAuth2ConfigSchema, template.config.config.value)
|
||||
: createEmptyOAuth2Config();
|
||||
|
||||
return {
|
||||
basicInfo: {
|
||||
title: template.title,
|
||||
identifier: sanitizeIdentifier(template.title),
|
||||
identifierFilter: template.identifierFilter,
|
||||
},
|
||||
type: template.type,
|
||||
oauth2Config,
|
||||
oauth2Scopes: oauth2Config.scopes.join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDialogStateFromProvider(identityProvider: IdentityProvider) {
|
||||
const oauth2Config =
|
||||
identityProvider.type === IdentityProvider_Type.OAUTH2 && identityProvider.config?.config.case === "oauth2Config"
|
||||
? create(OAuth2ConfigSchema, identityProvider.config.config.value)
|
||||
: createEmptyOAuth2Config();
|
||||
|
||||
return {
|
||||
basicInfo: {
|
||||
title: identityProvider.title,
|
||||
identifier: "",
|
||||
identifierFilter: identityProvider.identifierFilter,
|
||||
},
|
||||
type: identityProvider.type,
|
||||
oauth2Config,
|
||||
oauth2Scopes: oauth2Config.scopes.join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
function FormSection({ title, description, children }: { title: string; description?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="space-y-4 rounded-lg border bg-muted/20 p-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
<div className="space-y-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
required = false,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{label}
|
||||
{required ? <span className="text-destructive">*</span> : null}
|
||||
</Label>
|
||||
{children}
|
||||
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const identityProviderTypes = [...new Set(templateList.map((template) => template.type))];
|
||||
const [basicInfo, setBasicInfo] = useState<BasicInfoState>(createEmptyBasicInfo);
|
||||
const [type, setType] = useState<IdentityProvider_Type>(IdentityProvider_Type.OAUTH2);
|
||||
const [oauth2Config, setOAuth2Config] = useState<OAuth2Config>(createEmptyOAuth2Config);
|
||||
const [oauth2Scopes, setOAuth2Scopes] = useState<string>("");
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>(DEFAULT_TEMPLATE);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const isCreating = identityProvider === undefined;
|
||||
const oauth2FieldMapping = oauth2Config.fieldMapping ?? createEmptyFieldMapping();
|
||||
|
||||
// Reset state when dialog is closed
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Reset to default state when dialog is closed
|
||||
setBasicInfo({
|
||||
title: "",
|
||||
identifier: "",
|
||||
identifierFilter: "",
|
||||
});
|
||||
setSelectedTemplate(DEFAULT_TEMPLATE);
|
||||
setBasicInfo(createEmptyBasicInfo());
|
||||
setType(IdentityProvider_Type.OAUTH2);
|
||||
setOAuth2Config(
|
||||
create(OAuth2ConfigSchema, {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "",
|
||||
tokenUrl: "",
|
||||
userInfoUrl: "",
|
||||
scopes: [],
|
||||
fieldMapping: create(FieldMappingSchema, {
|
||||
identifier: "",
|
||||
displayName: "",
|
||||
email: "",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
setOAuth2Config(createEmptyOAuth2Config());
|
||||
setOAuth2Scopes("");
|
||||
setSelectedTemplate("GitHub");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Load existing identity provider data when editing
|
||||
useEffect(() => {
|
||||
if (open && identityProvider) {
|
||||
setBasicInfo({
|
||||
title: identityProvider.title,
|
||||
identifier: "",
|
||||
identifierFilter: identityProvider.identifierFilter,
|
||||
});
|
||||
setType(identityProvider.type);
|
||||
if (identityProvider.type === IdentityProvider_Type.OAUTH2 && identityProvider.config?.config?.case === "oauth2Config") {
|
||||
const oauth2Config = create(OAuth2ConfigSchema, identityProvider.config.config.value || {});
|
||||
setOAuth2Config(oauth2Config);
|
||||
setOAuth2Scopes(oauth2Config.scopes.join(" "));
|
||||
}
|
||||
}
|
||||
}, [open, identityProvider]);
|
||||
|
||||
// Load template data when creating new IDP
|
||||
useEffect(() => {
|
||||
if (!isCreating || !open) {
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const template = templateList.find((t) => t.title === selectedTemplate);
|
||||
if (template) {
|
||||
setBasicInfo({
|
||||
title: template.title,
|
||||
identifier: template.title.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||
identifierFilter: template.identifierFilter,
|
||||
});
|
||||
setType(template.type);
|
||||
if (template.type === IdentityProvider_Type.OAUTH2 && template.config?.config?.case === "oauth2Config") {
|
||||
const oauth2Config = create(OAuth2ConfigSchema, template.config.config.value || {});
|
||||
setOAuth2Config(oauth2Config);
|
||||
setOAuth2Scopes(oauth2Config.scopes.join(" "));
|
||||
}
|
||||
const nextState = isCreating ? buildDialogStateFromTemplate(selectedTemplate) : buildDialogStateFromProvider(identityProvider!);
|
||||
setBasicInfo(nextState.basicInfo);
|
||||
setType(nextState.type);
|
||||
setOAuth2Config(nextState.oauth2Config);
|
||||
setOAuth2Scopes(nextState.oauth2Scopes);
|
||||
}, [open, isCreating, identityProvider, selectedTemplate]);
|
||||
|
||||
const handleDialogClose = (nextOpen: boolean) => {
|
||||
if (isSubmitting && !nextOpen) {
|
||||
return;
|
||||
}
|
||||
}, [selectedTemplate, isCreating, open]);
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
onOpenChange(false);
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
handleDialogClose(false);
|
||||
};
|
||||
|
||||
const allowConfirmAction = () => {
|
||||
if (basicInfo.title === "") {
|
||||
if (basicInfo.title.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
if (isCreating && basicInfo.identifier === "") {
|
||||
if (isCreating && basicInfo.identifier.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
if (type === IdentityProvider_Type.OAUTH2) {
|
||||
if (
|
||||
oauth2Config.clientId === "" ||
|
||||
oauth2Config.authUrl === "" ||
|
||||
oauth2Config.tokenUrl === "" ||
|
||||
oauth2Config.userInfoUrl === "" ||
|
||||
oauth2Scopes === "" ||
|
||||
oauth2Config.fieldMapping?.identifier === ""
|
||||
oauth2Config.clientId.trim() === "" ||
|
||||
oauth2Config.authUrl.trim() === "" ||
|
||||
oauth2Config.tokenUrl.trim() === "" ||
|
||||
oauth2Config.userInfoUrl.trim() === "" ||
|
||||
normalizeScopes(oauth2Scopes).length === 0 ||
|
||||
oauth2FieldMapping.identifier.trim() === ""
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (isCreating) {
|
||||
if (oauth2Config.clientSecret === "") {
|
||||
return false;
|
||||
}
|
||||
if (isCreating && oauth2Config.clientSecret.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return !isSubmitting;
|
||||
};
|
||||
|
||||
const handleConfirmBtnClick = async () => {
|
||||
setIsSubmitting(true);
|
||||
const normalizedScopes = normalizeScopes(oauth2Scopes);
|
||||
|
||||
try {
|
||||
if (isCreating) {
|
||||
await identityProviderServiceClient.createIdentityProvider({
|
||||
identityProviderId: basicInfo.identifier,
|
||||
identityProvider: create(IdentityProviderSchema, {
|
||||
title: basicInfo.title,
|
||||
identifierFilter: basicInfo.identifierFilter,
|
||||
type: type,
|
||||
title: basicInfo.title.trim(),
|
||||
identifierFilter: basicInfo.identifierFilter.trim(),
|
||||
type,
|
||||
config: create(IdentityProviderConfigSchema, {
|
||||
config: {
|
||||
case: "oauth2Config",
|
||||
value: {
|
||||
...oauth2Config,
|
||||
scopes: oauth2Scopes.split(" "),
|
||||
scopes: normalizedScopes,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -281,15 +349,16 @@ function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, on
|
||||
} else {
|
||||
await identityProviderServiceClient.updateIdentityProvider({
|
||||
identityProvider: create(IdentityProviderSchema, {
|
||||
...basicInfo,
|
||||
name: identityProvider!.name,
|
||||
type: type,
|
||||
title: basicInfo.title.trim(),
|
||||
identifierFilter: basicInfo.identifierFilter.trim(),
|
||||
type,
|
||||
config: create(IdentityProviderConfigSchema, {
|
||||
config: {
|
||||
case: "oauth2Config",
|
||||
value: {
|
||||
...oauth2Config,
|
||||
scopes: oauth2Scopes.split(" "),
|
||||
scopes: normalizedScopes,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -299,225 +368,239 @@ function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, on
|
||||
toast.success(t("setting.sso.sso-updated", { name: basicInfo.title }));
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setIsSubmitting(false);
|
||||
await handleError(error, toast.error, {
|
||||
context: isCreating ? "Create identity provider" : "Update identity provider",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
handleDialogClose(false);
|
||||
};
|
||||
|
||||
const setPartialOAuth2Config = (state: Partial<OAuth2Config>) => {
|
||||
setOAuth2Config({
|
||||
...oauth2Config,
|
||||
setOAuth2Config((current) => ({
|
||||
...current,
|
||||
...state,
|
||||
}));
|
||||
};
|
||||
|
||||
const setPartialFieldMapping = (state: Partial<FieldMapping>) => {
|
||||
setPartialOAuth2Config({
|
||||
fieldMapping: {
|
||||
...oauth2FieldMapping,
|
||||
...state,
|
||||
} as FieldMapping,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||
<DialogContent size="2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(isCreating ? "setting.sso.create-sso" : "setting.sso.update-sso")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(isCreating ? "setting.sso.create-sso-description" : "setting.sso.update-sso-description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col justify-start items-start w-full space-y-4">
|
||||
{isCreating && (
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormSection title={t("setting.sso.basic-settings")} description={t("setting.sso.basic-settings-description")}>
|
||||
{isCreating ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label={t("common.type")} required>
|
||||
<Select value={String(type)} onValueChange={(value) => setType(Number(value) as IdentityProvider_Type)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{identityProviderTypes.map((kind) => (
|
||||
<SelectItem key={kind} value={String(kind)}>
|
||||
{IdentityProvider_Type[kind] || kind}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("setting.sso.template")} required description={t("setting.sso.template-description")}>
|
||||
<Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templateList.map((template) => (
|
||||
<SelectItem key={template.title} value={template.title}>
|
||||
{template.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{isCreating ? (
|
||||
<FormField label={t("setting.sso.provider-id")} required description={t("setting.sso.provider-id-description")}>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="e.g. github, okta-corp"
|
||||
maxLength={32}
|
||||
value={basicInfo.identifier}
|
||||
onChange={(e) =>
|
||||
setBasicInfo((current) => ({
|
||||
...current,
|
||||
identifier: sanitizeIdentifier(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
|
||||
<FormField label={t("common.name")} required>
|
||||
<Input
|
||||
placeholder={t("common.name")}
|
||||
value={basicInfo.title}
|
||||
onChange={(e) =>
|
||||
setBasicInfo((current) => ({
|
||||
...current,
|
||||
title: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label={t("setting.sso.identifier-filter")} description={t("setting.sso.identifier-filter-description")}>
|
||||
<Input
|
||||
placeholder={t("setting.sso.identifier-filter")}
|
||||
value={basicInfo.identifierFilter}
|
||||
onChange={(e) =>
|
||||
setBasicInfo((current) => ({
|
||||
...current,
|
||||
identifierFilter: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
{type === IdentityProvider_Type.OAUTH2 ? (
|
||||
<>
|
||||
<p className="mb-1!">{t("common.type")}</p>
|
||||
<Select value={String(type)} onValueChange={(value) => setType(parseInt(value) as unknown as IdentityProvider_Type)}>
|
||||
<SelectTrigger className="w-full mb-4">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{identityProviderTypes.map((kind) => (
|
||||
<SelectItem key={kind} value={String(kind)}>
|
||||
{IdentityProvider_Type[kind] || kind}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mb-2 text-sm font-medium">{t("setting.sso.template")}</p>
|
||||
<Select value={selectedTemplate} onValueChange={(value) => setSelectedTemplate(value)}>
|
||||
<SelectTrigger className="mb-1 h-auto w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templateList.map((template) => (
|
||||
<SelectItem key={template.title} value={template.title}>
|
||||
{template.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Separator className="my-2" />
|
||||
<FormSection title={t("setting.sso.oauth-configuration")} description={t("setting.sso.oauth-configuration-description")}>
|
||||
<div className="rounded-md border bg-background px-3 py-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("setting.sso.redirect-url")}</p>
|
||||
<p className="mt-2 break-all font-mono text-xs text-foreground sm:text-sm">{absolutifyLink("/auth/callback")}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t("setting.sso.redirect-url-description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label={t("setting.sso.client-id")} required>
|
||||
<Input
|
||||
placeholder={t("setting.sso.client-id")}
|
||||
value={oauth2Config.clientId}
|
||||
onChange={(e) => setPartialOAuth2Config({ clientId: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t("setting.sso.client-secret")}
|
||||
required={isCreating}
|
||||
description={isCreating ? undefined : t("setting.sso.client-secret-optional-description")}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={t("setting.sso.client-secret")}
|
||||
value={oauth2Config.clientSecret}
|
||||
onChange={(e) => setPartialOAuth2Config({ clientSecret: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label={t("setting.sso.authorization-endpoint")} required>
|
||||
<Input
|
||||
placeholder={t("setting.sso.authorization-endpoint")}
|
||||
value={oauth2Config.authUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ authUrl: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("setting.sso.token-endpoint")} required>
|
||||
<Input
|
||||
placeholder={t("setting.sso.token-endpoint")}
|
||||
value={oauth2Config.tokenUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ tokenUrl: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label={t("setting.sso.user-endpoint")} required>
|
||||
<Input
|
||||
placeholder={t("setting.sso.user-endpoint")}
|
||||
value={oauth2Config.userInfoUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ userInfoUrl: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("setting.sso.scopes")} required description={t("setting.sso.scopes-description")}>
|
||||
<Input placeholder={t("setting.sso.scopes")} value={oauth2Scopes} onChange={(e) => setOAuth2Scopes(e.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title={t("setting.sso.field-mapping")} description={t("setting.sso.field-mapping-description")}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
label={t("setting.sso.identifier")}
|
||||
required
|
||||
description={t("setting.sso.field-mapping-identifier-description")}
|
||||
>
|
||||
<Input
|
||||
placeholder={t("setting.sso.identifier")}
|
||||
value={oauth2FieldMapping.identifier}
|
||||
onChange={(e) => setPartialFieldMapping({ identifier: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("setting.sso.display-name")}>
|
||||
<Input
|
||||
placeholder={t("setting.sso.display-name")}
|
||||
value={oauth2FieldMapping.displayName}
|
||||
onChange={(e) => setPartialFieldMapping({ displayName: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label={t("common.email")}>
|
||||
<Input
|
||||
placeholder={t("common.email")}
|
||||
value={oauth2FieldMapping.email}
|
||||
onChange={(e) => setPartialFieldMapping({ email: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("setting.sso.avatar-url")}>
|
||||
<Input
|
||||
placeholder={t("setting.sso.avatar-url")}
|
||||
value={oauth2FieldMapping.avatarUrl}
|
||||
onChange={(e) => setPartialFieldMapping({ avatarUrl: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormSection>
|
||||
</>
|
||||
)}
|
||||
{isCreating && (
|
||||
<>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
ID
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full font-mono"
|
||||
placeholder="e.g. github, okta-corp"
|
||||
maxLength={32}
|
||||
value={basicInfo.identifier}
|
||||
onChange={(e) =>
|
||||
setBasicInfo({
|
||||
...basicInfo,
|
||||
identifier: e.target.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "-")
|
||||
.replace(/--+/g, "-"),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
A unique identifier for this provider. Lowercase letters, numbers, and hyphens only.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("common.name")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("common.name")}
|
||||
value={basicInfo.title}
|
||||
onChange={(e) =>
|
||||
setBasicInfo({
|
||||
...basicInfo,
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">{t("setting.sso.identifier-filter")}</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso.identifier-filter")}
|
||||
value={basicInfo.identifierFilter}
|
||||
onChange={(e) =>
|
||||
setBasicInfo({
|
||||
...basicInfo,
|
||||
identifierFilter: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Separator className="my-2" />
|
||||
{type === IdentityProvider_Type.OAUTH2 && (
|
||||
<>
|
||||
{isCreating && (
|
||||
<p className="border border-border rounded-md p-2 text-sm w-full mb-2 break-all">
|
||||
{t("setting.sso.redirect-url")}: {absolutifyLink("/auth/callback")}
|
||||
</p>
|
||||
)}
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso.client-id")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso.client-id")}
|
||||
value={oauth2Config.clientId}
|
||||
onChange={(e) => setPartialOAuth2Config({ clientId: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso.client-secret")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso.client-secret")}
|
||||
value={oauth2Config.clientSecret}
|
||||
onChange={(e) => setPartialOAuth2Config({ clientSecret: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso.authorization-endpoint")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso.authorization-endpoint")}
|
||||
value={oauth2Config.authUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ authUrl: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso.token-endpoint")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso.token-endpoint")}
|
||||
value={oauth2Config.tokenUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ tokenUrl: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso.user-endpoint")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso.user-endpoint")}
|
||||
value={oauth2Config.userInfoUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ userInfoUrl: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso.scopes")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso.scopes")}
|
||||
value={oauth2Scopes}
|
||||
onChange={(e) => setOAuth2Scopes(e.target.value)}
|
||||
/>
|
||||
<Separator className="my-2" />
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso.identifier")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso.identifier")}
|
||||
value={oauth2Config.fieldMapping!.identifier}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, identifier: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">{t("setting.sso.display-name")}</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso.display-name")}
|
||||
value={oauth2Config.fieldMapping!.displayName}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, displayName: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">{t("common.email")}</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("common.email")}
|
||||
value={oauth2Config.fieldMapping!.email}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, email: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">Avatar URL</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={"Avatar URL"}
|
||||
value={oauth2Config.fieldMapping!.avatarUrl}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, avatarUrl: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleCloseBtnClick}>
|
||||
<Button variant="ghost" onClick={handleCloseBtnClick} disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmBtnClick} disabled={!allowConfirmAction()}>
|
||||
|
||||
@@ -79,7 +79,16 @@ const AccessTokenSection = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingGroup title={t("setting.access-token.title")} description={t("setting.access-token.description")}>
|
||||
<SettingGroup
|
||||
title={t("setting.access-token.title")}
|
||||
description={t("setting.access-token.description")}
|
||||
actions={
|
||||
<Button onClick={createTokenDialog.open} size="sm">
|
||||
<PlusIcon className="w-4 h-4 mr-1.5" />
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SettingTable
|
||||
columns={[
|
||||
{
|
||||
@@ -115,13 +124,6 @@ const AccessTokenSection = () => {
|
||||
getRowKey={(token) => token.name}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={createTokenDialog.open} size="sm">
|
||||
<PlusIcon className="w-4 h-4 mr-1.5" />
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Access Token Dialog */}
|
||||
<CreateAccessTokenDialog
|
||||
open={createTokenDialog.isOpen}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: string;
|
||||
tooltip?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const InfoChip = ({ label, value, tooltip, className }: Props) => {
|
||||
const chip = (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"max-w-full items-start gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-normal leading-4 whitespace-nowrap",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="max-w-[20rem] truncate text-foreground">{value}</span>
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return chip;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex" tabIndex={0} aria-label={tooltip}>
|
||||
{chip}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs whitespace-pre-wrap break-words">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoChip;
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import InfoChip from "@/components/Settings/InfoChip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { identityProviderServiceClient, userServiceClient } from "@/connect";
|
||||
import { getIdentityProviderTypeLabel, getSSOProviderUid } from "@/helpers/sso-display";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { handleError } from "@/lib/error";
|
||||
@@ -14,8 +17,11 @@ import SettingTable from "./SettingTable";
|
||||
|
||||
interface LinkedIdentityRow extends Record<string, unknown> {
|
||||
name: string;
|
||||
providerUid: string;
|
||||
title: string;
|
||||
typeLabel: string;
|
||||
externUid: string;
|
||||
isLinked: boolean;
|
||||
linkedIdentity?: LinkedIdentity;
|
||||
identityProvider: IdentityProvider;
|
||||
}
|
||||
@@ -70,8 +76,11 @@ const LinkedIdentitySection = () => {
|
||||
const linkedIdentity = linkedIdentityByProviderName.get(identityProvider.name);
|
||||
return {
|
||||
name: identityProvider.name,
|
||||
providerUid: getSSOProviderUid(identityProvider.name),
|
||||
title: identityProvider.title,
|
||||
typeLabel: getIdentityProviderTypeLabel(identityProvider.type),
|
||||
externUid: linkedIdentity?.externUid ?? "",
|
||||
isLinked: !!linkedIdentity,
|
||||
linkedIdentity,
|
||||
identityProvider,
|
||||
};
|
||||
@@ -122,7 +131,7 @@ const LinkedIdentitySection = () => {
|
||||
name: row.linkedIdentity.name,
|
||||
});
|
||||
await fetchData();
|
||||
toast.success(`Unlinked ${row.title}.`);
|
||||
toast.success(t("setting.sso.unlink-success", { name: row.title }));
|
||||
} catch (error) {
|
||||
handleError(error, toast.error, {
|
||||
context: "Delete linked identity",
|
||||
@@ -131,40 +140,55 @@ const LinkedIdentitySection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (oauthIdentityProviders.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingGroup
|
||||
showSeparator
|
||||
title="SSO accounts"
|
||||
description="Each provider can be linked to this account at most once. A linked row shows the current extern_uid and can be unlinked."
|
||||
>
|
||||
<SettingGroup showSeparator title={t("setting.sso.accounts-title")} description={t("setting.sso.accounts-description")}>
|
||||
<SettingTable<LinkedIdentityRow>
|
||||
variant="info-flow"
|
||||
columns={[
|
||||
{
|
||||
key: "title",
|
||||
header: "SSO provider",
|
||||
render: (_, row: LinkedIdentityRow) => <span className="text-foreground">{row.title}</span>,
|
||||
header: t("setting.sso.provider"),
|
||||
render: (_, row: LinkedIdentityRow) => (
|
||||
<div className="flex min-w-[16rem] flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{row.title}</span>
|
||||
<Badge variant="secondary" className="rounded-full px-2.5 py-0.5">
|
||||
{row.typeLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<InfoChip label={t("setting.sso.provider-uid")} value={row.providerUid} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "externUid",
|
||||
header: "extern_uid",
|
||||
header: t("setting.sso.account"),
|
||||
render: (_, row: LinkedIdentityRow) => (
|
||||
<span className={row.externUid ? "text-foreground" : "text-muted-foreground"}>
|
||||
{row.externUid || t("attachment-library.labels.not-linked")}
|
||||
</span>
|
||||
<div className="flex min-w-[22rem] flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={row.isLinked ? "default" : "outline"} className="rounded-full px-2.5 py-0.5">
|
||||
{row.isLinked ? t("setting.sso.linked") : t("setting.sso.not-linked")}
|
||||
</Badge>
|
||||
{row.isLinked && row.externUid ? (
|
||||
<InfoChip label={t("setting.sso.extern-uid")} value={row.externUid} tooltip={row.externUid} />
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.isLinked ? t("setting.sso.extern-uid-description") : t("setting.sso.not-linked-description")}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
className: "text-right",
|
||||
className: "w-px text-right",
|
||||
render: (_, row: LinkedIdentityRow) =>
|
||||
row.linkedIdentity ? (
|
||||
<Button variant="outline" size="sm" onClick={() => handleUnlinkIdentityProvider(row)}>
|
||||
Unlink
|
||||
{t("common.unlink")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => handleLinkIdentityProvider(row.identityProvider)}>
|
||||
@@ -174,7 +198,7 @@ const LinkedIdentitySection = () => {
|
||||
},
|
||||
]}
|
||||
data={rows}
|
||||
emptyMessage="No SSO providers found."
|
||||
emptyMessage={t("setting.sso.no-sso-found")}
|
||||
getRowKey={(row) => row.name}
|
||||
/>
|
||||
</SettingGroup>
|
||||
|
||||
@@ -5,6 +5,9 @@ import { MoreVerticalIcon, PlusIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import InfoChip from "@/components/Settings/InfoChip";
|
||||
import UserAvatar from "@/components/UserAvatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
@@ -113,40 +116,57 @@ const MemberSection = () => {
|
||||
}
|
||||
>
|
||||
<SettingTable
|
||||
variant="info-flow"
|
||||
columns={[
|
||||
{
|
||||
key: "username",
|
||||
header: t("common.username"),
|
||||
key: "member",
|
||||
header: t("setting.member.member-column"),
|
||||
render: (_, user: User) => (
|
||||
<span className="text-foreground">
|
||||
{user.username}
|
||||
{user.state === State.ARCHIVED && <span className="ml-2 italic text-muted-foreground">({t("common.archived")})</span>}
|
||||
</span>
|
||||
<div className="flex min-w-[18rem] items-start gap-3">
|
||||
<UserAvatar className="h-10 w-10 shrink-0 rounded-xl" avatarUrl={user.avatarUrl} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span
|
||||
className={
|
||||
user.displayName ? "text-sm font-medium text-foreground" : "text-sm font-medium text-muted-foreground italic"
|
||||
}
|
||||
>
|
||||
{user.displayName || t("common.empty-placeholder")}
|
||||
</span>
|
||||
{currentUser?.name === user.name ? <span className="text-xs text-muted-foreground">{t("common.yourself")}</span> : null}
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground">@{user.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "role",
|
||||
header: t("common.role"),
|
||||
render: (_, user: User) => stringifyUserRole(user.role),
|
||||
},
|
||||
{
|
||||
key: "displayName",
|
||||
header: t("common.nickname"),
|
||||
render: (_, user: User) => user.displayName,
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
header: t("common.email"),
|
||||
render: (_, user: User) => user.email,
|
||||
key: "summary",
|
||||
header: t("setting.member.summary-column"),
|
||||
render: (_, user: User) => (
|
||||
<div className="flex min-w-[18rem] flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="rounded-full px-2.5 py-0.5">
|
||||
{stringifyUserRole(user.role)}
|
||||
</Badge>
|
||||
<Badge variant={user.state === State.ARCHIVED ? "outline" : "default"} className="rounded-full px-2.5 py-0.5">
|
||||
{user.state === State.ARCHIVED ? t("setting.member.archived") : t("setting.member.active")}
|
||||
</Badge>
|
||||
</div>
|
||||
{user.email ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<InfoChip label={t("common.email")} value={user.email} tooltip={user.email} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
className: "text-right",
|
||||
className: "w-px text-right",
|
||||
render: (_, user: User) =>
|
||||
currentUser?.name === user.name ? (
|
||||
<span className="text-muted-foreground">{t("common.yourself")}</span>
|
||||
) : (
|
||||
currentUser?.name === user.name ? null : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MoreVerticalIcon, PenLineIcon } from "lucide-react";
|
||||
import { AlertTriangleIcon, KeyRoundIcon, PenLineIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
@@ -14,7 +14,6 @@ import { useTranslate } from "@/utils/i18n";
|
||||
import ChangeMemberPasswordDialog from "../ChangeMemberPasswordDialog";
|
||||
import UpdateAccountDialog from "../UpdateAccountDialog";
|
||||
import UserAvatar from "../UserAvatar";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
|
||||
import AccessTokenSection from "./AccessTokenSection";
|
||||
import LinkedIdentitySection from "./LinkedIdentitySection";
|
||||
import SettingGroup from "./SettingGroup";
|
||||
@@ -61,19 +60,10 @@ const MyAccountSection = () => {
|
||||
<PenLineIcon className="w-4 h-4 mr-1.5" />
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<MoreVerticalIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={passwordDialog.open}>{t("setting.account.change-password")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDeleteOpen(true)} className="text-destructive focus:text-destructive">
|
||||
{t("setting.account.delete-account")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="outline" size="sm" onClick={passwordDialog.open}>
|
||||
<KeyRoundIcon className="w-4 h-4 mr-1.5" />
|
||||
{t("setting.account.change-password")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingGroup>
|
||||
@@ -82,6 +72,25 @@ const MyAccountSection = () => {
|
||||
|
||||
<AccessTokenSection />
|
||||
|
||||
<SettingGroup showSeparator title={t("setting.account.danger-area")} description={t("setting.account.danger-area-description")}>
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-full bg-destructive/10 p-2 text-destructive">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">{t("setting.account.delete-account")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("setting.account.delete-account-description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="destructive" size="sm" onClick={() => setDeleteOpen(true)}>
|
||||
{t("setting.account.delete-account")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingGroup>
|
||||
|
||||
{/* Update Account Dialog */}
|
||||
<UpdateAccountDialog open={accountDialog.isOpen} onOpenChange={accountDialog.setOpen} />
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@ import { MoreVerticalIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import InfoChip from "@/components/Settings/InfoChip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { identityProviderServiceClient } from "@/connect";
|
||||
import { getIdentityProviderTypeLabel, getOAuth2SummaryItems, getSSOProviderUid, type SummaryItem } from "@/helpers/sso-display";
|
||||
import { useDialog } from "@/hooks/useDialog";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb";
|
||||
import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import CreateIdentityProviderDialog from "../CreateIdentityProviderDialog";
|
||||
import LearnMore from "../LearnMore";
|
||||
@@ -19,11 +22,10 @@ interface IdentityProviderRow extends Record<string, unknown> {
|
||||
providerUid: string;
|
||||
title: string;
|
||||
typeLabel: string;
|
||||
summaryItems: SummaryItem[];
|
||||
provider: IdentityProvider;
|
||||
}
|
||||
|
||||
const getIdentityProviderUID = (name: string) => name.replace(/^identity-providers\//, "");
|
||||
|
||||
const SSOSection = () => {
|
||||
const t = useTranslate();
|
||||
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
|
||||
@@ -50,12 +52,13 @@ const SSOSection = () => {
|
||||
() =>
|
||||
identityProviderList.map((provider) => ({
|
||||
name: provider.name,
|
||||
providerUid: getIdentityProviderUID(provider.name),
|
||||
providerUid: getSSOProviderUid(provider.name),
|
||||
title: provider.title,
|
||||
typeLabel: IdentityProvider_Type[provider.type] ?? "TYPE_UNSPECIFIED",
|
||||
typeLabel: getIdentityProviderTypeLabel(provider.type),
|
||||
summaryItems: getOAuth2SummaryItems(provider, t),
|
||||
provider,
|
||||
})),
|
||||
[identityProviderList],
|
||||
[identityProviderList, t],
|
||||
);
|
||||
|
||||
const handleDeleteIdentityProvider = (identityProvider: IdentityProvider) => {
|
||||
@@ -114,26 +117,43 @@ const SSOSection = () => {
|
||||
}
|
||||
>
|
||||
<SettingTable
|
||||
variant="info-flow"
|
||||
columns={[
|
||||
{
|
||||
key: "providerUid",
|
||||
header: "provider_uid",
|
||||
key: "title",
|
||||
header: t("setting.sso.provider"),
|
||||
render: (_, row: IdentityProviderRow) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground">{row.providerUid}</span>
|
||||
{row.title ? <span className="text-sm text-muted-foreground">{row.title}</span> : null}
|
||||
<div className="flex min-w-[16rem] flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{row.title}</span>
|
||||
<Badge variant="secondary" className="rounded-full px-2.5 py-0.5">
|
||||
{row.typeLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<InfoChip label={t("setting.sso.provider-uid")} value={row.providerUid} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "typeLabel",
|
||||
header: t("common.type"),
|
||||
render: (_, row: IdentityProviderRow) => <span className="text-muted-foreground">{row.typeLabel}</span>,
|
||||
key: "summaryItems",
|
||||
header: t("setting.sso.configuration"),
|
||||
render: (_, row: IdentityProviderRow) => (
|
||||
<div className="flex min-w-[24rem] flex-col gap-2">
|
||||
<p className="text-xs text-muted-foreground">{t("setting.sso.configuration-summary-description")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{row.summaryItems.map((item) => (
|
||||
<InfoChip key={item.key} label={item.label} value={item.value} tooltip={item.tooltip} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
className: "text-right",
|
||||
className: "w-px text-right",
|
||||
render: (_, row: IdentityProviderRow) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -8,17 +8,23 @@ interface SettingGroupProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
showSeparator?: boolean;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const SettingGroup: React.FC<SettingGroupProps> = ({ title, description, children, className, showSeparator = false }) => {
|
||||
const SettingGroup: React.FC<SettingGroupProps> = ({ title, description, children, className, showSeparator = false, actions }) => {
|
||||
return (
|
||||
<>
|
||||
{showSeparator && <Separator className="my-2" />}
|
||||
<div className={cn("flex flex-col gap-3", className)}>
|
||||
{(title || description) && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{title && <h4 className="text-sm font-medium text-muted-foreground">{title}</h4>}
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
{(title || description || actions) && (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{(title || description) && (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
{title && <h4 className="text-sm font-medium text-muted-foreground">{title}</h4>}
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
{actions ? <div className="ml-auto shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">{children}</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface SettingTableColumn<T = Record<string, unknown>> {
|
||||
key: string;
|
||||
header: string;
|
||||
header: React.ReactNode;
|
||||
className?: string;
|
||||
render?: (value: T[keyof T], row: T) => React.ReactNode;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ interface SettingTableProps<T = Record<string, unknown>> {
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
getRowKey?: (row: T, index: number) => string;
|
||||
variant?: "default" | "info-flow";
|
||||
}
|
||||
|
||||
const SettingTable = <T extends Record<string, unknown>>({
|
||||
@@ -22,6 +23,7 @@ const SettingTable = <T extends Record<string, unknown>>({
|
||||
emptyMessage = "No data",
|
||||
className,
|
||||
getRowKey,
|
||||
variant = "default",
|
||||
}: SettingTableProps<T>) => {
|
||||
return (
|
||||
<div className={cn("w-full overflow-x-auto", className)}>
|
||||
@@ -52,7 +54,14 @@ const SettingTable = <T extends Record<string, unknown>>({
|
||||
const value = row[column.key as keyof T] as T[keyof T];
|
||||
const content = column.render ? column.render(value, row) : (value as React.ReactNode);
|
||||
return (
|
||||
<td key={column.key} className={cn("whitespace-nowrap px-3 py-2 text-sm text-muted-foreground", column.className)}>
|
||||
<td
|
||||
key={column.key}
|
||||
className={cn(
|
||||
"px-3 text-sm text-muted-foreground",
|
||||
variant === "default" ? "whitespace-nowrap py-2" : "py-3 align-top whitespace-normal",
|
||||
column.className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</td>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { extractIdentityProviderUidFromName } from "@/helpers/resource-names";
|
||||
import { type FieldMapping, type IdentityProvider, IdentityProvider_Type, type OAuth2Config } from "@/types/proto/api/v1/idp_service_pb";
|
||||
import type { Translations } from "@/utils/i18n";
|
||||
|
||||
type Translate = (key: Translations, params?: Record<string, unknown>) => string;
|
||||
|
||||
export interface SummaryItem {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const SUMMARY_TEXT_MAX = 48;
|
||||
export function getSSOProviderUid(name: string): string {
|
||||
return extractIdentityProviderUidFromName(name);
|
||||
}
|
||||
|
||||
export function getIdentityProviderTypeLabel(type: IdentityProvider_Type): string {
|
||||
switch (type) {
|
||||
case IdentityProvider_Type.OAUTH2:
|
||||
return "OAuth2";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function getEndpointSummary(url: string): string {
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, "");
|
||||
return `${parsed.host}${path}`;
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
export function getFieldMappingSummary(mapping: FieldMapping | undefined, t: Translate): string {
|
||||
if (!mapping?.identifier) {
|
||||
return t("setting.sso.mapping-none");
|
||||
}
|
||||
|
||||
const parts = [`${t("setting.sso.mapping-identifier-short")}=${mapping.identifier}`];
|
||||
if (mapping.displayName) {
|
||||
parts.push(`${t("setting.sso.mapping-display-name-short")}=${mapping.displayName}`);
|
||||
}
|
||||
if (mapping.email) {
|
||||
parts.push(`${t("setting.sso.mapping-email-short")}=${mapping.email}`);
|
||||
}
|
||||
if (mapping.avatarUrl) {
|
||||
parts.push(`${t("setting.sso.mapping-avatar-short")}=${mapping.avatarUrl}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function getIdentifierFilterSummary(filter: string, t: Translate): string {
|
||||
if (!filter) {
|
||||
return t("setting.sso.filter-disabled");
|
||||
}
|
||||
return truncateMiddle(filter, SUMMARY_TEXT_MAX);
|
||||
}
|
||||
|
||||
export function getOAuth2SummaryItems(provider: IdentityProvider, t: Translate): SummaryItem[] {
|
||||
const oauth2Config = provider.config?.config.case === "oauth2Config" ? provider.config.config.value : undefined;
|
||||
if (!oauth2Config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return buildOAuth2SummaryItems(oauth2Config, provider.identifierFilter, t);
|
||||
}
|
||||
|
||||
export function buildOAuth2SummaryItems(oauth2Config: OAuth2Config, identifierFilter: string, t: Translate): SummaryItem[] {
|
||||
const endpointSummaries = [oauth2Config.authUrl, oauth2Config.tokenUrl, oauth2Config.userInfoUrl].map(getEndpointSummary).filter(Boolean);
|
||||
const uniqueEndpointSummaries = [...new Set(endpointSummaries)];
|
||||
|
||||
return [
|
||||
{
|
||||
key: "endpoints",
|
||||
label: t("setting.sso.endpoints"),
|
||||
value: uniqueEndpointSummaries.join(" · "),
|
||||
tooltip: [oauth2Config.authUrl, oauth2Config.tokenUrl, oauth2Config.userInfoUrl].filter(Boolean).join("\n"),
|
||||
},
|
||||
{
|
||||
key: "mapping",
|
||||
label: t("setting.sso.mapping"),
|
||||
value: getFieldMappingSummary(oauth2Config.fieldMapping, t),
|
||||
tooltip: oauth2Config.fieldMapping ? getFieldMappingSummary(oauth2Config.fieldMapping, t) : undefined,
|
||||
},
|
||||
{
|
||||
key: "scopes",
|
||||
label: t("setting.sso.scopes"),
|
||||
value:
|
||||
oauth2Config.scopes.length === 1
|
||||
? t("setting.sso.scope-count_one", { count: oauth2Config.scopes.length })
|
||||
: t("setting.sso.scope-count_other", { count: oauth2Config.scopes.length }),
|
||||
tooltip: oauth2Config.scopes.length > 0 ? oauth2Config.scopes.join("\n") : undefined,
|
||||
},
|
||||
...(identifierFilter
|
||||
? [
|
||||
{
|
||||
key: "filter",
|
||||
label: t("setting.sso.identifier-filter"),
|
||||
value: getIdentifierFilterSummary(identifierFilter, t),
|
||||
tooltip: identifierFilter,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
].filter((item) => item.value);
|
||||
}
|
||||
|
||||
function truncateMiddle(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const prefixLength = Math.ceil((maxLength - 1) / 2);
|
||||
const suffixLength = Math.floor((maxLength - 1) / 2);
|
||||
return `${value.slice(0, prefixLength)}…${value.slice(-suffixLength)}`;
|
||||
}
|
||||
@@ -89,6 +89,7 @@
|
||||
"delete": "Delete",
|
||||
"description": "Description",
|
||||
"edit": "Edit",
|
||||
"empty-placeholder": "Empty",
|
||||
"email": "Email",
|
||||
"expand": "Expand",
|
||||
"explore": "Explore",
|
||||
@@ -145,6 +146,7 @@
|
||||
"today": "Today",
|
||||
"tree-mode": "Tree mode",
|
||||
"type": "Type",
|
||||
"unlink": "Unlink",
|
||||
"unpin": "Unpin",
|
||||
"update": "Update",
|
||||
"upload": "Upload",
|
||||
@@ -386,7 +388,10 @@
|
||||
},
|
||||
"account": {
|
||||
"change-password": "Change password",
|
||||
"danger-area": "Danger area",
|
||||
"danger-area-description": "Irreversible account actions live here. Review them carefully before continuing.",
|
||||
"delete-account": "Delete account",
|
||||
"delete-account-description": "Permanently remove this account and all associated access from this instance. This action cannot be undone.",
|
||||
"email-note": "Optional",
|
||||
"export-memos": "Export Memos",
|
||||
"nickname-note": "Displayed in the banner",
|
||||
@@ -436,8 +441,10 @@
|
||||
"week-start-day": "Week start day"
|
||||
},
|
||||
"member": {
|
||||
"active": "Active",
|
||||
"admin": "Admin",
|
||||
"archive-member": "Archive member",
|
||||
"archived": "Archived",
|
||||
"archive-success": "{{username}} archived successfully",
|
||||
"archive-warning": "Are you sure you want to archive {{username}}?",
|
||||
"archive-warning-description": "Archiving disables the account. You can restore or delete it later.",
|
||||
@@ -448,7 +455,9 @@
|
||||
"delete-warning-description": "THIS ACTION IS IRREVERSIBLE",
|
||||
"label": "Member",
|
||||
"list-title": "Member list",
|
||||
"member-column": "Member",
|
||||
"restore-success": "{{username}} restored successfully",
|
||||
"summary-column": "Summary",
|
||||
"user": "User",
|
||||
"no-members-found": "No members found"
|
||||
},
|
||||
@@ -476,28 +485,68 @@
|
||||
"delete-success": "Shortcut `{{title}}` deleted successfully"
|
||||
},
|
||||
"sso": {
|
||||
"account": "Account",
|
||||
"accounts-description": "Review each identity provider, see the current link state, and connect or disconnect external identities from this account.",
|
||||
"accounts-title": "SSO Accounts",
|
||||
"authorization-endpoint": "Authorization endpoint",
|
||||
"avatar-url": "Avatar URL",
|
||||
"basic-settings": "Basic settings",
|
||||
"basic-settings-description": "Set the provider identity, display name, and optional identifier rules before filling in the OAuth details.",
|
||||
"client-id": "Client ID",
|
||||
"client-secret": "Client secret",
|
||||
"client-secret-optional-description": "Leave blank to keep the existing client secret unchanged.",
|
||||
"configuration": "Configuration",
|
||||
"configuration-summary-description": "Show the essentials that help identify and audit a provider without exposing the full configuration inline.",
|
||||
"confirm-delete": "Are you sure you want to delete `{{name}}` SSO configuration? THIS ACTION IS IRREVERSIBLE",
|
||||
"create-sso": "Create SSO",
|
||||
"create-sso-description": "Create a new identity provider for administrator-managed single sign-on.",
|
||||
"custom": "Custom",
|
||||
"delete-sso": "Confirm delete",
|
||||
"disabled-password-login-warning": "Password-login is disabled, be extra careful when removing identity providers",
|
||||
"endpoints": "Endpoints",
|
||||
"display-name": "Display Name",
|
||||
"extern-uid": "External ID",
|
||||
"extern-uid-description": "This is the provider-side identity currently linked to your account.",
|
||||
"filter-disabled": "Disabled",
|
||||
"identifier": "Identifier",
|
||||
"identifier-filter": "Identifier Filter",
|
||||
"identifier-filter-description": "Optional regex used to allow or restrict which external identifiers may sign in.",
|
||||
"field-mapping": "Claims mapping",
|
||||
"field-mapping-description": "Map the upstream profile fields used to identify the user and prefill profile data.",
|
||||
"field-mapping-identifier-description": "Used as the stable external identifier when signing in or linking an account.",
|
||||
"linked": "Linked",
|
||||
"label": "SSO",
|
||||
"mapping": "Mapping",
|
||||
"mapping-avatar-short": "avatar",
|
||||
"mapping-display-name-short": "name",
|
||||
"mapping-email-short": "email",
|
||||
"mapping-identifier-short": "id",
|
||||
"mapping-none": "Not configured",
|
||||
"no-sso-found": "No SSO found.",
|
||||
"not-linked": "Not linked",
|
||||
"not-linked-description": "No external identity is linked yet. You can connect this provider to sign in with it later.",
|
||||
"oauth-configuration": "OAuth configuration",
|
||||
"oauth-configuration-description": "Fill in the OAuth client credentials and the provider endpoints used during sign-in.",
|
||||
"provider": "Provider",
|
||||
"provider-id": "Provider ID",
|
||||
"provider-id-description": "Lowercase letters, numbers, and hyphens only. This value becomes part of the provider resource name.",
|
||||
"provider-uid": "UID",
|
||||
"redirect-url": "Redirect URL",
|
||||
"redirect-url-description": "Register this callback URL with your identity provider so the authorization code flow can complete.",
|
||||
"scope-count_one": "{{count}} scope",
|
||||
"scope-count_other": "{{count}} scopes",
|
||||
"scopes": "Scopes",
|
||||
"scopes-description": "Separate scopes with spaces. Most providers only need a small set such as profile or email access.",
|
||||
"single-sign-on": "Configuring Single Sign-On (SSO) for Authentication",
|
||||
"sso-created": "SSO {{name}} created",
|
||||
"sso-list": "SSO List",
|
||||
"sso-updated": "SSO {{name}} updated",
|
||||
"template": "Template",
|
||||
"template-description": "Start from a provider preset, then adjust the credentials and endpoints for your tenant.",
|
||||
"unlink-success": "Unlinked {{name}}.",
|
||||
"token-endpoint": "Token endpoint",
|
||||
"update-sso": "Update SSO",
|
||||
"update-sso-description": "Review the provider configuration, then save the fields that should change.",
|
||||
"user-endpoint": "User endpoint"
|
||||
},
|
||||
"storage": {
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"delete": "删除",
|
||||
"description": "说明",
|
||||
"edit": "编辑",
|
||||
"empty-placeholder": "空",
|
||||
"email": "邮箱",
|
||||
"expand": "展开",
|
||||
"explore": "发现",
|
||||
@@ -112,6 +113,7 @@
|
||||
"today": "今天",
|
||||
"tree-mode": "树模式",
|
||||
"type": "类型",
|
||||
"unlink": "解绑",
|
||||
"unpin": "取消置顶",
|
||||
"update": "更新",
|
||||
"upload": "上传",
|
||||
@@ -325,8 +327,10 @@
|
||||
},
|
||||
"setting": {
|
||||
"member": {
|
||||
"active": "启用中",
|
||||
"admin": "管理员",
|
||||
"archive-member": "归档成员",
|
||||
"archived": "已归档",
|
||||
"archive-success": "{{username}} 归档成功",
|
||||
"archive-warning": "您确定要归档 {{username}} 吗?",
|
||||
"archive-warning-description": "归档会禁用用户。您可以稍后恢复或删除它。",
|
||||
@@ -339,6 +343,8 @@
|
||||
"user": "普通用户",
|
||||
"label": "成员",
|
||||
"list-title": "成员列表",
|
||||
"member-column": "成员",
|
||||
"summary-column": "摘要",
|
||||
"no-members-found": "没有找到会员"
|
||||
},
|
||||
"my-account": {
|
||||
@@ -382,29 +388,70 @@
|
||||
"delete-success": "捷径 `{{title}}` 删除成功"
|
||||
},
|
||||
"sso": {
|
||||
"account": "账户",
|
||||
"accounts-description": "查看每个身份提供程序的当前绑定状态,并为当前账户连接或解绑外部身份。",
|
||||
"accounts-title": "SSO 账户",
|
||||
"authorization-endpoint": "授权端点(Authorization Endpoint)",
|
||||
"avatar-url": "头像链接(Avatar URL)",
|
||||
"basic-settings": "基础信息",
|
||||
"basic-settings-description": "先设置 provider 的标识、展示名称和可选的标识符规则,再补充 OAuth 配置。",
|
||||
"client-id": "客户端ID(Client ID)",
|
||||
"client-secret": "客户端密钥(Client Secret)",
|
||||
"client-secret-optional-description": "留空则保留现有的客户端密钥,不会覆盖。",
|
||||
"configuration": "配置摘要",
|
||||
"configuration-summary-description": "这里只展示便于识别和审查 provider 的关键信息,完整配置仍然通过编辑入口查看。",
|
||||
"confirm-delete": "您确定要删除“{{name}}”单点登录配置吗?(此操作不可逆)",
|
||||
"create-sso": "创建单点登录",
|
||||
"create-sso-description": "为管理员管理的单点登录创建新的身份提供程序。",
|
||||
"custom": "自定义",
|
||||
"delete-sso": "确认删除",
|
||||
"disabled-password-login-warning": "密码登录已被禁用,删除身份提供程序时要格外小心",
|
||||
"endpoints": "端点",
|
||||
"display-name": "显示名称",
|
||||
"extern-uid": "外部 ID",
|
||||
"extern-uid-description": "这是当前绑定到您账户上的身份提供程序侧标识。",
|
||||
"filter-disabled": "未启用",
|
||||
"field-mapping": "字段映射",
|
||||
"field-mapping-description": "映射上游用户信息字段,用于识别用户并预填展示资料。",
|
||||
"field-mapping-identifier-description": "这是登录或绑定账户时使用的稳定外部标识字段。",
|
||||
"identifier": "标识符(Identifier)",
|
||||
"identifier-filter": "标识符过滤器(Identifier Filter)",
|
||||
"identifier-filter-description": "可选正则表达式,用来限制或允许哪些外部标识符可以登录。",
|
||||
"linked": "已绑定",
|
||||
"no-sso-found": "没有 SSO 配置",
|
||||
"no-scopes": "无 Scopes",
|
||||
"not-linked": "未绑定",
|
||||
"oauth-configuration": "OAuth 配置",
|
||||
"oauth-configuration-description": "填写 OAuth 客户端凭据,以及登录流程中使用的 provider 端点。",
|
||||
"provider": "提供程序",
|
||||
"provider-id": "Provider ID",
|
||||
"provider-id-description": "仅支持小写字母、数字和连字符。该值会成为 provider 资源名的一部分。",
|
||||
"provider-uid": "UID",
|
||||
"redirect-url": "重定向链接",
|
||||
"redirect-url-description": "将这个回调地址注册到身份提供程序中,授权码流程才能正确返回。",
|
||||
"scopes": "范围",
|
||||
"scopes-description": "使用空格分隔多个 scope。大多数 provider 只需要 profile、email 这类基础 scope。",
|
||||
"single-sign-on": "配置单点登录(SSO)进行身份验证",
|
||||
"sso-created": "单点登录 {{name}} 已创建",
|
||||
"sso-list": "单点登录列表",
|
||||
"sso-updated": "单点登录 {{name}} 已更新",
|
||||
"template": "模板",
|
||||
"template-description": "先选择一个 provider 预设,再按你的租户信息调整凭据和端点。",
|
||||
"mapping": "映射",
|
||||
"mapping-avatar-short": "avatar",
|
||||
"mapping-display-name-short": "name",
|
||||
"mapping-email-short": "email",
|
||||
"mapping-identifier-short": "id",
|
||||
"mapping-none": "未配置",
|
||||
"unlink-success": "已解绑 {{name}}。",
|
||||
"label": "单点登录",
|
||||
"not-linked-description": "当前还没有绑定外部身份。绑定后即可使用这个提供程序登录。",
|
||||
"scope-count_one": "{{count}} 个 scope",
|
||||
"scope-count_other": "{{count}} 个 scopes",
|
||||
"token-endpoint": "令牌端点(Token Endpoint)",
|
||||
"update-sso": "更新单点登录",
|
||||
"user-endpoint": "用户端点(User Endpoint)",
|
||||
"label": "单点登录"
|
||||
"update-sso-description": "检查当前 provider 配置,只保存你需要变更的字段。",
|
||||
"user-endpoint": "用户端点(User Endpoint)"
|
||||
},
|
||||
"storage": {
|
||||
"accesskey": "访问密钥(Access key)",
|
||||
@@ -493,7 +540,10 @@
|
||||
},
|
||||
"account": {
|
||||
"change-password": "修改密码",
|
||||
"danger-area": "危险操作区",
|
||||
"danger-area-description": "不可逆的账号操作统一放在这里,执行前请再次确认影响。",
|
||||
"delete-account": "删除账号",
|
||||
"delete-account-description": "永久删除当前账号,并移除它在这个实例中的全部访问权限。此操作无法撤销。",
|
||||
"email-note": "可选",
|
||||
"export-memos": "导出备忘录",
|
||||
"nickname-note": "显示在横幅中",
|
||||
|
||||
Reference in New Issue
Block a user