From ee1799851e88674a6920c7a56d93428fcf95e662 Mon Sep 17 00:00:00 2001 From: boojack Date: Fri, 24 Apr 2026 09:08:58 +0800 Subject: [PATCH] feat: redesign account and SSO management (#5886) --- internal/idp/oauth2/oauth2.go | 17 +- internal/idp/oauth2/oauth2_test.go | 54 +- server/router/api/v1/auth_service.go | 2 +- server/router/api/v1/test/test_helper.go | 6 +- .../api/v1/test/user_service_delete_test.go | 422 +++++++++++ server/router/api/v1/user_service.go | 36 +- store/attachment.go | 18 + store/db/mysql/memo_share.go | 3 + store/db/postgres/memo_share.go | 6 + store/db/sqlite/memo_share.go | 6 + store/memo_share.go | 7 +- store/store.go | 5 + store/user.go | 17 +- store/user_delete.go | 666 +++++++++++++++++ .../CreateIdentityProviderDialog.tsx | 681 ++++++++++-------- .../Settings/AccessTokenSection.tsx | 18 +- web/src/components/Settings/InfoChip.tsx | 42 ++ .../Settings/LinkedIdentitySection.tsx | 62 +- web/src/components/Settings/MemberSection.tsx | 66 +- .../components/Settings/MyAccountSection.tsx | 39 +- web/src/components/Settings/SSOSection.tsx | 50 +- web/src/components/Settings/SettingGroup.tsx | 16 +- web/src/components/Settings/SettingTable.tsx | 13 +- web/src/helpers/sso-display.ts | 123 ++++ web/src/locales/en.json | 49 ++ web/src/locales/zh-Hans.json | 54 +- 26 files changed, 2060 insertions(+), 418 deletions(-) create mode 100644 store/user_delete.go create mode 100644 web/src/components/Settings/InfoChip.tsx create mode 100644 web/src/helpers/sso-display.ts diff --git a/internal/idp/oauth2/oauth2.go b/internal/idp/oauth2/oauth2.go index 8354024a5..a6915e41b 100644 --- a/internal/idp/oauth2/oauth2.go +++ b/internal/idp/oauth2/oauth2.go @@ -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") diff --git a/internal/idp/oauth2/oauth2_test.go b/internal/idp/oauth2/oauth2_test.go index c7039f5bd..0bec3d69a 100644 --- a/internal/idp/oauth2/oauth2_test.go +++ b/internal/idp/oauth2/oauth2_test.go @@ -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") +} diff --git a/server/router/api/v1/auth_service.go b/server/router/api/v1/auth_service.go index 86ae6cd9e..c2bfd7105 100644 --- a/server/router/api/v1/auth_service.go +++ b/server/router/api/v1/auth_service.go @@ -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) } diff --git a/server/router/api/v1/test/test_helper.go b/server/router/api/v1/test/test_helper.go index b3397d9c6..166929944 100644 --- a/server/router/api/v1/test/test_helper.go +++ b/server/router/api/v1/test/test_helper.go @@ -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 diff --git a/server/router/api/v1/test/user_service_delete_test.go b/server/router/api/v1/test/user_service_delete_test.go index 110e5a83d..2d9018913 100644 --- a/server/router/api/v1/test/user_service_delete_test.go +++ b/server/router/api/v1/test/user_service_delete_test.go @@ -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) +} diff --git a/server/router/api/v1/user_service.go b/server/router/api/v1/user_service.go index 9e587859c..4550815f9 100644 --- a/server/router/api/v1/user_service.go +++ b/server/router/api/v1/user_service.go @@ -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 } diff --git a/store/attachment.go b/store/attachment.go index 08b6233e8..38245855d 100644 --- a/store/attachment.go +++ b/store/attachment.go @@ -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 +} diff --git a/store/db/mysql/memo_share.go b/store/db/mysql/memo_share.go index 0abf702bd..29c278d51 100644 --- a/store/db/mysql/memo_share.go +++ b/store/db/mysql/memo_share.go @@ -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 diff --git a/store/db/postgres/memo_share.go b/store/db/postgres/memo_share.go index 77b5be985..a802d3e28 100644 --- a/store/db/postgres/memo_share.go +++ b/store/db/postgres/memo_share.go @@ -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, ` diff --git a/store/db/sqlite/memo_share.go b/store/db/sqlite/memo_share.go index c484ffce0..ab4667996 100644 --- a/store/db/sqlite/memo_share.go +++ b/store/db/sqlite/memo_share.go @@ -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, ` diff --git a/store/memo_share.go b/store/memo_share.go index 89110dda6..709da93a3 100644 --- a/store/memo_share.go +++ b/store/memo_share.go @@ -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. diff --git a/store/store.go b/store/store.go index d53c70e92..bb3e54dad 100644 --- a/store/store.go +++ b/store/store.go @@ -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() diff --git a/store/user.go b/store/user.go index 3da4f4546..9826c0a77 100644 --- a/store/user.go +++ b/store/user.go @@ -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 } diff --git a/store/user_delete.go b/store/user_delete.go new file mode 100644 index 000000000..110c7fa20 --- /dev/null +++ b/store/user_delete.go @@ -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 +} diff --git a/web/src/components/CreateIdentityProviderDialog.tsx b/web/src/components/CreateIdentityProviderDialog.tsx index 64d370a6d..3197502d1 100644 --- a/web/src/components/CreateIdentityProviderDialog.tsx +++ b/web/src/components/CreateIdentityProviderDialog.tsx @@ -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.OAUTH2); - const [oauth2Config, setOAuth2Config] = useState( - create(OAuth2ConfigSchema, { - clientId: "", - clientSecret: "", - authUrl: "", - tokenUrl: "", - userInfoUrl: "", - scopes: [], - fieldMapping: create(FieldMappingSchema, { - identifier: "", - displayName: "", - email: "", - }), - }), + }; +} + +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 ( +
+
+

{title}

+ {description ?

{description}

: null} +
+
{children}
+
); +} + +function FormField({ + label, + required = false, + description, + children, +}: { + label: string; + required?: boolean; + description?: string; + children: ReactNode; +}) { + return ( +
+ + {children} + {description ?

{description}

: null} +
+ ); +} + +function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, onSuccess }: Props) { + const t = useTranslate(); + const identityProviderTypes = [...new Set(templateList.map((template) => template.type))]; + const [basicInfo, setBasicInfo] = useState(createEmptyBasicInfo); + const [type, setType] = useState(IdentityProvider_Type.OAUTH2); + const [oauth2Config, setOAuth2Config] = useState(createEmptyOAuth2Config); const [oauth2Scopes, setOAuth2Scopes] = useState(""); - const [selectedTemplate, setSelectedTemplate] = useState("GitHub"); + const [selectedTemplate, setSelectedTemplate] = useState(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"); + setIsSubmitting(false); + return; } - }, [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]); + const nextState = isCreating ? buildDialogStateFromTemplate(selectedTemplate) : buildDialogStateFromProvider(identityProvider!); + setBasicInfo(nextState.basicInfo); + setType(nextState.type); + setOAuth2Config(nextState.oauth2Config); + setOAuth2Scopes(nextState.oauth2Scopes); + }, [open, isCreating, identityProvider, selectedTemplate]); - // Load template data when creating new IDP - useEffect(() => { - if (!isCreating || !open) { + const handleDialogClose = (nextOpen: boolean) => { + if (isSubmitting && !nextOpen) { 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(" ")); - } - } - }, [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) => { - setOAuth2Config({ - ...oauth2Config, + setOAuth2Config((current) => ({ + ...current, ...state, + })); + }; + + const setPartialFieldMapping = (state: Partial) => { + setPartialOAuth2Config({ + fieldMapping: { + ...oauth2FieldMapping, + ...state, + } as FieldMapping, }); }; return ( - - + + {t(isCreating ? "setting.sso.create-sso" : "setting.sso.update-sso")} + + {t(isCreating ? "setting.sso.create-sso-description" : "setting.sso.update-sso-description")} + -
- {isCreating && ( - <> -

{t("common.type")}

- -

{t("setting.sso.template")}

- - - - )} - {isCreating && ( - <> -

- ID - * -

+ +
+ + {isCreating ? ( +
+ + + + + + + +
+ ) : null} + +
+ {isCreating ? ( + + + setBasicInfo((current) => ({ + ...current, + identifier: sanitizeIdentifier(e.target.value), + })) + } + /> + + ) : null} + + + + setBasicInfo((current) => ({ + ...current, + title: e.target.value, + })) + } + /> + +
+ + - setBasicInfo({ - ...basicInfo, - identifier: e.target.value - .toLowerCase() - .replace(/[^a-z0-9-]/g, "-") - .replace(/--+/g, "-"), - }) + setBasicInfo((current) => ({ + ...current, + identifierFilter: e.target.value, + })) } /> -

- A unique identifier for this provider. Lowercase letters, numbers, and hyphens only. -

- - )} -

- {t("common.name")} - * -

- - setBasicInfo({ - ...basicInfo, - title: e.target.value, - }) - } - /> -

{t("setting.sso.identifier-filter")}

- - setBasicInfo({ - ...basicInfo, - identifierFilter: e.target.value, - }) - } - /> - - {type === IdentityProvider_Type.OAUTH2 && ( +
+
+ + {type === IdentityProvider_Type.OAUTH2 ? ( <> - {isCreating && ( -

- {t("setting.sso.redirect-url")}: {absolutifyLink("/auth/callback")} -

- )} -

- {t("setting.sso.client-id")} - * -

- setPartialOAuth2Config({ clientId: e.target.value })} - /> -

- {t("setting.sso.client-secret")} - * -

- setPartialOAuth2Config({ clientSecret: e.target.value })} - /> -

- {t("setting.sso.authorization-endpoint")} - * -

- setPartialOAuth2Config({ authUrl: e.target.value })} - /> -

- {t("setting.sso.token-endpoint")} - * -

- setPartialOAuth2Config({ tokenUrl: e.target.value })} - /> -

- {t("setting.sso.user-endpoint")} - * -

- setPartialOAuth2Config({ userInfoUrl: e.target.value })} - /> -

- {t("setting.sso.scopes")} - * -

- setOAuth2Scopes(e.target.value)} - /> - -

- {t("setting.sso.identifier")} - * -

- - setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, identifier: e.target.value } as FieldMapping }) - } - /> -

{t("setting.sso.display-name")}

- - setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, displayName: e.target.value } as FieldMapping }) - } - /> -

{t("common.email")}

- - setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, email: e.target.value } as FieldMapping }) - } - /> -

Avatar URL

- - setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, avatarUrl: e.target.value } as FieldMapping }) - } - /> + +
+

{t("setting.sso.redirect-url")}

+

{absolutifyLink("/auth/callback")}

+

{t("setting.sso.redirect-url-description")}

+
+ +
+ + setPartialOAuth2Config({ clientId: e.target.value })} + /> + + + + setPartialOAuth2Config({ clientSecret: e.target.value })} + /> + +
+ +
+ + setPartialOAuth2Config({ authUrl: e.target.value })} + /> + + + + setPartialOAuth2Config({ tokenUrl: e.target.value })} + /> + +
+ +
+ + setPartialOAuth2Config({ userInfoUrl: e.target.value })} + /> + + + + setOAuth2Scopes(e.target.value)} /> + +
+
+ + +
+ + setPartialFieldMapping({ identifier: e.target.value })} + /> + + + + setPartialFieldMapping({ displayName: e.target.value })} + /> + +
+ +
+ + setPartialFieldMapping({ email: e.target.value })} + /> + + + + setPartialFieldMapping({ avatarUrl: e.target.value })} + /> + +
+
- )} + ) : null}
+ - + } + > { getRowKey={(token) => token.name} /> -
- -
- {/* Create Access Token Dialog */} { + const chip = ( + + {label} + {value} + + ); + + if (!tooltip) { + return chip; + } + + return ( + + + + {chip} + + + {tooltip} + + ); +}; + +export default InfoChip; diff --git a/web/src/components/Settings/LinkedIdentitySection.tsx b/web/src/components/Settings/LinkedIdentitySection.tsx index 2701a884c..fcf4293aa 100644 --- a/web/src/components/Settings/LinkedIdentitySection.tsx +++ b/web/src/components/Settings/LinkedIdentitySection.tsx @@ -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 { 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 ( - + + variant="info-flow" columns={[ { key: "title", - header: "SSO provider", - render: (_, row: LinkedIdentityRow) => {row.title}, + header: t("setting.sso.provider"), + render: (_, row: LinkedIdentityRow) => ( +
+
+ {row.title} + + {row.typeLabel} + +
+
+ +
+
+ ), }, { key: "externUid", - header: "extern_uid", + header: t("setting.sso.account"), render: (_, row: LinkedIdentityRow) => ( - - {row.externUid || t("attachment-library.labels.not-linked")} - +
+
+ + {row.isLinked ? t("setting.sso.linked") : t("setting.sso.not-linked")} + + {row.isLinked && row.externUid ? ( + + ) : null} +
+

+ {row.isLinked ? t("setting.sso.extern-uid-description") : t("setting.sso.not-linked-description")} +

+
), }, { key: "actions", header: "", - className: "text-right", + className: "w-px text-right", render: (_, row: LinkedIdentityRow) => row.linkedIdentity ? ( ) : ( - - - - - - {t("setting.account.change-password")} - setDeleteOpen(true)} className="text-destructive focus:text-destructive"> - {t("setting.account.delete-account")} - - - +
@@ -82,6 +72,25 @@ const MyAccountSection = () => { + +
+
+
+ +
+
+

{t("setting.account.delete-account")}

+

{t("setting.account.delete-account-description")}

+
+
+
+ +
+
+
+ {/* Update Account Dialog */} diff --git a/web/src/components/Settings/SSOSection.tsx b/web/src/components/Settings/SSOSection.tsx index 37b1bfce7..93938801d 100644 --- a/web/src/components/Settings/SSOSection.tsx +++ b/web/src/components/Settings/SSOSection.tsx @@ -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 { 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([]); @@ -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 = () => { } > ( -
- {row.providerUid} - {row.title ? {row.title} : null} +
+
+ {row.title} + + {row.typeLabel} + +
+
+ +
), }, { - key: "typeLabel", - header: t("common.type"), - render: (_, row: IdentityProviderRow) => {row.typeLabel}, + key: "summaryItems", + header: t("setting.sso.configuration"), + render: (_, row: IdentityProviderRow) => ( +
+

{t("setting.sso.configuration-summary-description")}

+
+ {row.summaryItems.map((item) => ( + + ))} +
+
+ ), }, { key: "actions", header: "", - className: "text-right", + className: "w-px text-right", render: (_, row: IdentityProviderRow) => ( diff --git a/web/src/components/Settings/SettingGroup.tsx b/web/src/components/Settings/SettingGroup.tsx index 7629aa3dc..507e91930 100644 --- a/web/src/components/Settings/SettingGroup.tsx +++ b/web/src/components/Settings/SettingGroup.tsx @@ -8,17 +8,23 @@ interface SettingGroupProps { children: React.ReactNode; className?: string; showSeparator?: boolean; + actions?: React.ReactNode; } -const SettingGroup: React.FC = ({ title, description, children, className, showSeparator = false }) => { +const SettingGroup: React.FC = ({ title, description, children, className, showSeparator = false, actions }) => { return ( <> {showSeparator && }
- {(title || description) && ( -
- {title &&

{title}

} - {description &&

{description}

} + {(title || description || actions) && ( +
+ {(title || description) && ( +
+ {title &&

{title}

} + {description &&

{description}

} +
+ )} + {actions ?
{actions}
: null}
)}
{children}
diff --git a/web/src/components/Settings/SettingTable.tsx b/web/src/components/Settings/SettingTable.tsx index fac859ba2..4ccef7ca8 100644 --- a/web/src/components/Settings/SettingTable.tsx +++ b/web/src/components/Settings/SettingTable.tsx @@ -3,7 +3,7 @@ import { cn } from "@/lib/utils"; interface SettingTableColumn> { key: string; - header: string; + header: React.ReactNode; className?: string; render?: (value: T[keyof T], row: T) => React.ReactNode; } @@ -14,6 +14,7 @@ interface SettingTableProps> { emptyMessage?: string; className?: string; getRowKey?: (row: T, index: number) => string; + variant?: "default" | "info-flow"; } const SettingTable = >({ @@ -22,6 +23,7 @@ const SettingTable = >({ emptyMessage = "No data", className, getRowKey, + variant = "default", }: SettingTableProps) => { return (
@@ -52,7 +54,14 @@ const SettingTable = >({ 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 ( - + {content} ); diff --git a/web/src/helpers/sso-display.ts b/web/src/helpers/sso-display.ts new file mode 100644 index 000000000..4b85c10f3 --- /dev/null +++ b/web/src/helpers/sso-display.ts @@ -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; + +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)}`; +} diff --git a/web/src/locales/en.json b/web/src/locales/en.json index ee32328b7..24032523e 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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": { diff --git a/web/src/locales/zh-Hans.json b/web/src/locales/zh-Hans.json index b5dc86f50..fdbe54033 100644 --- a/web/src/locales/zh-Hans.json +++ b/web/src/locales/zh-Hans.json @@ -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": "显示在横幅中",