Unverified Commit ee179985 authored by boojack's avatar boojack Committed by GitHub

feat: redesign account and SSO management (#5886)

parent 30c0611a
...@@ -8,6 +8,7 @@ import ( ...@@ -8,6 +8,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"time"
"github.com/pkg/errors" "github.com/pkg/errors"
"golang.org/x/oauth2" "golang.org/x/oauth2"
...@@ -21,6 +22,8 @@ type IdentityProvider struct { ...@@ -21,6 +22,8 @@ type IdentityProvider struct {
config *storepb.OAuth2Config config *storepb.OAuth2Config
} }
const userInfoRequestTimeout = 10 * time.Second
// NewIdentityProvider initializes a new OAuth2 Identity Provider with the given configuration. // NewIdentityProvider initializes a new OAuth2 Identity Provider with the given configuration.
func NewIdentityProvider(config *storepb.OAuth2Config) (*IdentityProvider, error) { func NewIdentityProvider(config *storepb.OAuth2Config) (*IdentityProvider, error) {
for v, field := range map[string]string{ for v, field := range map[string]string{
...@@ -78,9 +81,9 @@ func (p *IdentityProvider) ExchangeToken(ctx context.Context, redirectURL, code, ...@@ -78,9 +81,9 @@ func (p *IdentityProvider) ExchangeToken(ctx context.Context, redirectURL, code,
} }
// UserInfo returns the parsed user information using the given OAuth2 token. // UserInfo returns the parsed user information using the given OAuth2 token.
func (p *IdentityProvider) UserInfo(token string) (*idp.IdentityProviderUserInfo, error) { func (p *IdentityProvider) UserInfo(ctx context.Context, token string) (*idp.IdentityProviderUserInfo, error) {
client := &http.Client{} client := &http.Client{Timeout: userInfoRequestTimeout}
req, err := http.NewRequest(http.MethodGet, p.config.UserInfoUrl, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.config.UserInfoUrl, nil)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to create http request") return nil, errors.Wrap(err, "failed to create http request")
} }
...@@ -92,6 +95,14 @@ func (p *IdentityProvider) UserInfo(token string) (*idp.IdentityProviderUserInfo ...@@ -92,6 +95,14 @@ func (p *IdentityProvider) UserInfo(token string) (*idp.IdentityProviderUserInfo
} }
defer resp.Body.Close() 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) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to read response body") return nil, errors.Wrap(err, "failed to read response body")
......
...@@ -152,7 +152,7 @@ func TestIdentityProvider(t *testing.T) { ...@@ -152,7 +152,7 @@ func TestIdentityProvider(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, testAccessToken, oauthToken) require.Equal(t, testAccessToken, oauthToken)
userInfoResult, err := oauth2.UserInfo(oauthToken) userInfoResult, err := oauth2.UserInfo(ctx, oauthToken)
require.NoError(t, err) require.NoError(t, err)
wantUserInfo := &idp.IdentityProviderUserInfo{ wantUserInfo := &idp.IdentityProviderUserInfo{
...@@ -162,3 +162,55 @@ func TestIdentityProvider(t *testing.T) { ...@@ -162,3 +162,55 @@ func TestIdentityProvider(t *testing.T) {
} }
assert.Equal(t, wantUserInfo, userInfoResult) 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 ...@@ -242,7 +242,7 @@ func (s *APIV1Service) resolveSSOIdentity(ctx context.Context, idpName, code, re
if err != nil { if err != nil {
return nil, nil, status.Errorf(codes.Internal, "failed to exchange token, error: %v", err) 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 { if err != nil {
return nil, nil, status.Errorf(codes.Internal, "failed to get user info, error: %v", err) 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 { ...@@ -27,15 +27,15 @@ func NewTestService(t *testing.T) *TestService {
// Create a test store with SQLite // Create a test store with SQLite
testStore := teststore.NewTestingStore(ctx, t) testStore := teststore.NewTestingStore(ctx, t)
// Create a test profile with a temp directory for file storage, // Align the profile data directory with the test store so attachment files and
// so tests that create attachments don't leave artifacts in the source tree. // derived caches resolve against the same location as DeleteAttachmentStorage.
testProfile := &profile.Profile{ testProfile := &profile.Profile{
Demo: true, Demo: true,
Version: "test-1.0.0", Version: "test-1.0.0",
InstanceURL: "http://localhost:8080", InstanceURL: "http://localhost:8080",
Driver: "sqlite", Driver: "sqlite",
DSN: ":memory:", DSN: ":memory:",
Data: t.TempDir(), Data: testStore.GetDataDir(),
} }
// Create APIV1Service with nil grpcServer since we're testing direct calls // Create APIV1Service with nil grpcServer since we're testing direct calls
......
...@@ -2,6 +2,8 @@ package test ...@@ -2,6 +2,8 @@ package test
import ( import (
"context" "context"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
...@@ -65,3 +67,423 @@ func TestDeleteUserSelfDeleteCleansAccountDataAndAuthCookies(t *testing.T) { ...@@ -65,3 +67,423 @@ func TestDeleteUserSelfDeleteCleansAccountDataAndAuthCookies(t *testing.T) {
require.NotNil(t, carrier) require.NotNil(t, carrier)
require.Contains(t, strings.ToLower(carrier.Get("Set-Cookie")), "memos_refresh=") 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 ...@@ -353,27 +353,37 @@ func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserR
} }
isSelfDelete := currentUser.ID == userID isSelfDelete := currentUser.ID == userID
if err := s.Store.DeleteUserIdentities(ctx, &store.DeleteUserIdentity{ attachments, err := s.Store.DeleteUserCompletely(ctx, &store.DeleteUser{
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{
ID: user.ID, ID: user.ID,
}); err != nil { })
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete user: %v", err) 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 isSelfDelete {
if err := s.clearAuthCookies(ctx); err != nil { if err := s.clearAuthCookies(ctx); err != nil {
slog.Warn("failed to clear auth cookies after self delete", "user_id", userID, "error", err) 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 return &emptypb.Empty{}, nil
} }
......
...@@ -76,6 +76,16 @@ const ( ...@@ -76,6 +76,16 @@ const (
motionCacheFolder = ".motion_cache" 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) { func (s *Store) CreateAttachment(ctx context.Context, create *Attachment) (*Attachment, error) {
if !base.UIDMatcher.MatchString(create.UID) { if !base.UIDMatcher.MatchString(create.UID) {
return nil, errors.New("invalid uid") return nil, errors.New("invalid uid")
...@@ -177,6 +187,9 @@ func (s *Store) DeleteAttachmentStorage(ctx context.Context, attachment *Attachm ...@@ -177,6 +187,9 @@ func (s *Store) DeleteAttachmentStorage(ctx context.Context, attachment *Attachm
if attachment == nil { if attachment == nil {
return nil return nil
} }
if shouldFailDeleteAttachmentStorage(ctx) {
return ErrDeleteAttachmentStorageFailpoint
}
if attachment.StorageType == storepb.AttachmentStorageType_LOCAL { if attachment.StorageType == storepb.AttachmentStorageType_LOCAL {
if err := func() error { if err := func() error {
...@@ -237,3 +250,8 @@ func (s *Store) deleteAttachmentDerivedCaches(attachment *Attachment) { ...@@ -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) ([]* ...@@ -53,6 +53,9 @@ func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]*
if find.MemoID != nil { if find.MemoID != nil {
where, args = append(where, "`memo_id` = ?"), append(args, *find.MemoID) 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, ` rows, err := d.db.QueryContext(ctx, `
SELECT SELECT
......
...@@ -40,6 +40,9 @@ func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]* ...@@ -40,6 +40,9 @@ func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]*
if find.MemoID != nil { if find.MemoID != nil {
where, args = append(where, "memo_id = "+placeholder(len(args)+1)), append(args, *find.MemoID) 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, ` rows, err := d.db.QueryContext(ctx, `
SELECT SELECT
...@@ -93,6 +96,9 @@ func (d *DB) GetMemoShare(ctx context.Context, find *store.FindMemoShare) (*stor ...@@ -93,6 +96,9 @@ func (d *DB) GetMemoShare(ctx context.Context, find *store.FindMemoShare) (*stor
if find.MemoID != nil { if find.MemoID != nil {
where, args = append(where, "memo_id = "+placeholder(len(args)+1)), append(args, *find.MemoID) 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{} ms := &store.MemoShare{}
if err := d.db.QueryRowContext(ctx, ` if err := d.db.QueryRowContext(ctx, `
......
...@@ -42,6 +42,9 @@ func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]* ...@@ -42,6 +42,9 @@ func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]*
if find.MemoID != nil { if find.MemoID != nil {
where, args = append(where, "`memo_id` = ?"), append(args, *find.MemoID) 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, ` rows, err := d.db.QueryContext(ctx, `
SELECT SELECT
...@@ -95,6 +98,9 @@ func (d *DB) GetMemoShare(ctx context.Context, find *store.FindMemoShare) (*stor ...@@ -95,6 +98,9 @@ func (d *DB) GetMemoShare(ctx context.Context, find *store.FindMemoShare) (*stor
if find.MemoID != nil { if find.MemoID != nil {
where, args = append(where, "`memo_id` = ?"), append(args, *find.MemoID) 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{} ms := &store.MemoShare{}
if err := d.db.QueryRowContext(ctx, ` if err := d.db.QueryRowContext(ctx, `
......
...@@ -14,9 +14,10 @@ type MemoShare struct { ...@@ -14,9 +14,10 @@ type MemoShare struct {
// FindMemoShare is used to filter memo shares in list/get queries. // FindMemoShare is used to filter memo shares in list/get queries.
type FindMemoShare struct { type FindMemoShare struct {
ID *int32 ID *int32
UID *string UID *string
MemoID *int32 MemoID *int32
CreatorID *int32
} }
// DeleteMemoShare identifies a share grant to remove. // DeleteMemoShare identifies a share grant to remove.
......
...@@ -47,6 +47,11 @@ func (s *Store) GetDriver() Driver { ...@@ -47,6 +47,11 @@ func (s *Store) GetDriver() Driver {
return s.driver return s.driver
} }
// GetDataDir returns the store data directory.
func (s *Store) GetDataDir() string {
return s.profile.Data
}
func (s *Store) Close() error { func (s *Store) Close() error {
// Stop all cache cleanup goroutines // Stop all cache cleanup goroutines
s.instanceSettingCache.Close() s.instanceSettingCache.Close()
......
...@@ -2,6 +2,7 @@ package store ...@@ -2,6 +2,7 @@ package store
import ( import (
"context" "context"
"strconv"
) )
// Role is the type of a role. // Role is the type of a role.
...@@ -80,13 +81,17 @@ type DeleteUser struct { ...@@ -80,13 +81,17 @@ type DeleteUser struct {
ID int32 ID int32
} }
func userCacheKey(userID int32) string {
return strconv.Itoa(int(userID))
}
func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) { func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
user, err := s.driver.CreateUser(ctx, create) user, err := s.driver.CreateUser(ctx, create)
if err != nil { if err != nil {
return nil, err return nil, err
} }
s.userCache.Set(ctx, string(user.ID), user) s.userCache.Set(ctx, userCacheKey(user.ID), user)
return user, nil return user, nil
} }
...@@ -96,7 +101,7 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro ...@@ -96,7 +101,7 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
return nil, err return nil, err
} }
s.userCache.Set(ctx, string(user.ID), user) s.userCache.Set(ctx, userCacheKey(user.ID), user)
return user, nil return user, nil
} }
...@@ -107,14 +112,14 @@ func (s *Store) ListUsers(ctx context.Context, find *FindUser) ([]*User, error) ...@@ -107,14 +112,14 @@ func (s *Store) ListUsers(ctx context.Context, find *FindUser) ([]*User, error)
} }
for _, user := range list { for _, user := range list {
s.userCache.Set(ctx, string(user.ID), user) s.userCache.Set(ctx, userCacheKey(user.ID), user)
} }
return list, nil return list, nil
} }
func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) { func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) {
if find.ID != nil { 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) user, ok := cache.(*User)
if ok { if ok {
return user, nil return user, nil
...@@ -131,7 +136,7 @@ func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) { ...@@ -131,7 +136,7 @@ func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) {
} }
user := list[0] user := list[0]
s.userCache.Set(ctx, string(user.ID), user) s.userCache.Set(ctx, userCacheKey(user.ID), user)
return user, nil return user, nil
} }
...@@ -140,6 +145,6 @@ func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error { ...@@ -140,6 +145,6 @@ func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
if err != nil { if err != nil {
return err return err
} }
s.userCache.Delete(ctx, string(delete.ID)) s.userCache.Delete(ctx, userCacheKey(delete.ID))
return nil return nil
} }
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
}
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; 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 { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { identityProviderServiceClient } from "@/connect"; import { identityProviderServiceClient } from "@/connect";
import { absolutifyLink } from "@/helpers/utils"; import { absolutifyLink } from "@/helpers/utils";
import { handleError } from "@/lib/error"; import { handleError } from "@/lib/error";
...@@ -22,6 +22,8 @@ import { ...@@ -22,6 +22,8 @@ import {
} from "@/types/proto/api/v1/idp_service_pb"; } from "@/types/proto/api/v1/idp_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
const DEFAULT_TEMPLATE = "GitHub";
const templateList: IdentityProvider[] = [ const templateList: IdentityProvider[] = [
create(IdentityProviderSchema, { create(IdentityProviderSchema, {
name: "", name: "",
...@@ -128,150 +130,216 @@ interface Props { ...@@ -128,150 +130,216 @@ interface Props {
onSuccess?: () => void; onSuccess?: () => void;
} }
function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, onSuccess }: Props) { interface BasicInfoState {
const t = useTranslate(); title: string;
const identityProviderTypes = [...new Set(templateList.map((t) => t.type))]; identifier: string;
const [basicInfo, setBasicInfo] = useState({ 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: "", title: "",
identifier: "", identifier: "",
identifierFilter: "", identifierFilter: "",
}); };
const [type, setType] = useState<IdentityProvider_Type>(IdentityProvider_Type.OAUTH2); }
const [oauth2Config, setOAuth2Config] = useState<OAuth2Config>(
create(OAuth2ConfigSchema, { function sanitizeIdentifier(value: string): string {
clientId: "", return value
clientSecret: "", .toLowerCase()
authUrl: "", .replace(/[^a-z0-9-]/g, "-")
tokenUrl: "", .replace(/--+/g, "-")
userInfoUrl: "", .replace(/^-+|-+$/g, "");
scopes: [], }
fieldMapping: create(FieldMappingSchema, {
identifier: "", function normalizeScopes(value: string): string[] {
displayName: "", return value
email: "", .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 [oauth2Scopes, setOAuth2Scopes] = useState<string>("");
const [selectedTemplate, setSelectedTemplate] = useState<string>("GitHub"); const [selectedTemplate, setSelectedTemplate] = useState<string>(DEFAULT_TEMPLATE);
const [isSubmitting, setIsSubmitting] = useState(false);
const isCreating = identityProvider === undefined; const isCreating = identityProvider === undefined;
const oauth2FieldMapping = oauth2Config.fieldMapping ?? createEmptyFieldMapping();
// Reset state when dialog is closed
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
// Reset to default state when dialog is closed setSelectedTemplate(DEFAULT_TEMPLATE);
setBasicInfo({ setBasicInfo(createEmptyBasicInfo());
title: "",
identifier: "",
identifierFilter: "",
});
setType(IdentityProvider_Type.OAUTH2); setType(IdentityProvider_Type.OAUTH2);
setOAuth2Config( setOAuth2Config(createEmptyOAuth2Config());
create(OAuth2ConfigSchema, {
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
userInfoUrl: "",
scopes: [],
fieldMapping: create(FieldMappingSchema, {
identifier: "",
displayName: "",
email: "",
}),
}),
);
setOAuth2Scopes(""); setOAuth2Scopes("");
setSelectedTemplate("GitHub"); setIsSubmitting(false);
return;
} }
}, [open]);
// Load existing identity provider data when editing const nextState = isCreating ? buildDialogStateFromTemplate(selectedTemplate) : buildDialogStateFromProvider(identityProvider!);
useEffect(() => { setBasicInfo(nextState.basicInfo);
if (open && identityProvider) { setType(nextState.type);
setBasicInfo({ setOAuth2Config(nextState.oauth2Config);
title: identityProvider.title, setOAuth2Scopes(nextState.oauth2Scopes);
identifier: "", }, [open, isCreating, identityProvider, selectedTemplate]);
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 const handleDialogClose = (nextOpen: boolean) => {
useEffect(() => { if (isSubmitting && !nextOpen) {
if (!isCreating || !open) {
return; return;
} }
onOpenChange(nextOpen);
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]);
const handleCloseBtnClick = () => { const handleCloseBtnClick = () => {
onOpenChange(false); if (isSubmitting) {
return;
}
handleDialogClose(false);
}; };
const allowConfirmAction = () => { const allowConfirmAction = () => {
if (basicInfo.title === "") { if (basicInfo.title.trim() === "") {
return false; return false;
} }
if (isCreating && basicInfo.identifier === "") { if (isCreating && basicInfo.identifier.trim() === "") {
return false; return false;
} }
if (type === IdentityProvider_Type.OAUTH2) { if (type === IdentityProvider_Type.OAUTH2) {
if ( if (
oauth2Config.clientId === "" || oauth2Config.clientId.trim() === "" ||
oauth2Config.authUrl === "" || oauth2Config.authUrl.trim() === "" ||
oauth2Config.tokenUrl === "" || oauth2Config.tokenUrl.trim() === "" ||
oauth2Config.userInfoUrl === "" || oauth2Config.userInfoUrl.trim() === "" ||
oauth2Scopes === "" || normalizeScopes(oauth2Scopes).length === 0 ||
oauth2Config.fieldMapping?.identifier === "" oauth2FieldMapping.identifier.trim() === ""
) { ) {
return false; return false;
} }
if (isCreating) { if (isCreating && oauth2Config.clientSecret.trim() === "") {
if (oauth2Config.clientSecret === "") { return false;
return false;
}
} }
} }
return true; return !isSubmitting;
}; };
const handleConfirmBtnClick = async () => { const handleConfirmBtnClick = async () => {
setIsSubmitting(true);
const normalizedScopes = normalizeScopes(oauth2Scopes);
try { try {
if (isCreating) { if (isCreating) {
await identityProviderServiceClient.createIdentityProvider({ await identityProviderServiceClient.createIdentityProvider({
identityProviderId: basicInfo.identifier, identityProviderId: basicInfo.identifier,
identityProvider: create(IdentityProviderSchema, { identityProvider: create(IdentityProviderSchema, {
title: basicInfo.title, title: basicInfo.title.trim(),
identifierFilter: basicInfo.identifierFilter, identifierFilter: basicInfo.identifierFilter.trim(),
type: type, type,
config: create(IdentityProviderConfigSchema, { config: create(IdentityProviderConfigSchema, {
config: { config: {
case: "oauth2Config", case: "oauth2Config",
value: { value: {
...oauth2Config, ...oauth2Config,
scopes: oauth2Scopes.split(" "), scopes: normalizedScopes,
}, },
}, },
}), }),
...@@ -281,15 +349,16 @@ function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, on ...@@ -281,15 +349,16 @@ function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, on
} else { } else {
await identityProviderServiceClient.updateIdentityProvider({ await identityProviderServiceClient.updateIdentityProvider({
identityProvider: create(IdentityProviderSchema, { identityProvider: create(IdentityProviderSchema, {
...basicInfo,
name: identityProvider!.name, name: identityProvider!.name,
type: type, title: basicInfo.title.trim(),
identifierFilter: basicInfo.identifierFilter.trim(),
type,
config: create(IdentityProviderConfigSchema, { config: create(IdentityProviderConfigSchema, {
config: { config: {
case: "oauth2Config", case: "oauth2Config",
value: { value: {
...oauth2Config, ...oauth2Config,
scopes: oauth2Scopes.split(" "), scopes: normalizedScopes,
}, },
}, },
}), }),
...@@ -299,225 +368,239 @@ function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, on ...@@ -299,225 +368,239 @@ function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, on
toast.success(t("setting.sso.sso-updated", { name: basicInfo.title })); toast.success(t("setting.sso.sso-updated", { name: basicInfo.title }));
} }
} catch (error: unknown) { } catch (error: unknown) {
setIsSubmitting(false);
await handleError(error, toast.error, { await handleError(error, toast.error, {
context: isCreating ? "Create identity provider" : "Update identity provider", context: isCreating ? "Create identity provider" : "Update identity provider",
}); });
return;
} }
setIsSubmitting(false);
onSuccess?.(); onSuccess?.();
onOpenChange(false); handleDialogClose(false);
}; };
const setPartialOAuth2Config = (state: Partial<OAuth2Config>) => { const setPartialOAuth2Config = (state: Partial<OAuth2Config>) => {
setOAuth2Config({ setOAuth2Config((current) => ({
...oauth2Config, ...current,
...state, ...state,
}));
};
const setPartialFieldMapping = (state: Partial<FieldMapping>) => {
setPartialOAuth2Config({
fieldMapping: {
...oauth2FieldMapping,
...state,
} as FieldMapping,
}); });
}; };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={handleDialogClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> <DialogContent size="2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{t(isCreating ? "setting.sso.create-sso" : "setting.sso.update-sso")}</DialogTitle> <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> </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")}>
<p className="mb-1!">{t("common.type")}</p> {isCreating ? (
<Select value={String(type)} onValueChange={(value) => setType(parseInt(value) as unknown as IdentityProvider_Type)}> <div className="grid gap-4 md:grid-cols-2">
<SelectTrigger className="w-full mb-4"> <FormField label={t("common.type")} required>
<SelectValue /> <Select value={String(type)} onValueChange={(value) => setType(Number(value) as IdentityProvider_Type)}>
</SelectTrigger> <SelectTrigger>
<SelectContent> <SelectValue />
{identityProviderTypes.map((kind) => ( </SelectTrigger>
<SelectItem key={kind} value={String(kind)}> <SelectContent>
{IdentityProvider_Type[kind] || kind} {identityProviderTypes.map((kind) => (
</SelectItem> <SelectItem key={kind} value={String(kind)}>
))} {IdentityProvider_Type[kind] || kind}
</SelectContent> </SelectItem>
</Select> ))}
<p className="mb-2 text-sm font-medium">{t("setting.sso.template")}</p> </SelectContent>
<Select value={selectedTemplate} onValueChange={(value) => setSelectedTemplate(value)}> </Select>
<SelectTrigger className="mb-1 h-auto w-full"> </FormField>
<SelectValue />
</SelectTrigger> <FormField label={t("setting.sso.template")} required description={t("setting.sso.template-description")}>
<SelectContent> <Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
{templateList.map((template) => ( <SelectTrigger>
<SelectItem key={template.title} value={template.title}> <SelectValue />
{template.title} </SelectTrigger>
</SelectItem> <SelectContent>
))} {templateList.map((template) => (
</SelectContent> <SelectItem key={template.title} value={template.title}>
</Select> {template.title}
<Separator className="my-2" /> </SelectItem>
</> ))}
)} </SelectContent>
{isCreating && ( </Select>
<> </FormField>
<p className="mb-1 text-sm font-medium"> </div>
ID ) : null}
<span className="text-destructive">*</span>
</p> <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 <Input
className="mb-2 w-full font-mono" placeholder={t("setting.sso.identifier-filter")}
placeholder="e.g. github, okta-corp" value={basicInfo.identifierFilter}
maxLength={32}
value={basicInfo.identifier}
onChange={(e) => onChange={(e) =>
setBasicInfo({ setBasicInfo((current) => ({
...basicInfo, ...current,
identifier: e.target.value identifierFilter: e.target.value,
.toLowerCase() }))
.replace(/[^a-z0-9-]/g, "-")
.replace(/--+/g, "-"),
})
} }
/> />
<p className="mb-2 text-xs text-muted-foreground"> </FormField>
A unique identifier for this provider. Lowercase letters, numbers, and hyphens only. </FormSection>
</p>
</> {type === IdentityProvider_Type.OAUTH2 ? (
)}
<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 && ( <FormSection title={t("setting.sso.oauth-configuration")} description={t("setting.sso.oauth-configuration-description")}>
<p className="border border-border rounded-md p-2 text-sm w-full mb-2 break-all"> <div className="rounded-md border bg-background px-3 py-3">
{t("setting.sso.redirect-url")}: {absolutifyLink("/auth/callback")} <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("setting.sso.redirect-url")}</p>
</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>
<p className="mb-1 text-sm font-medium"> </div>
{t("setting.sso.client-id")}
<span className="text-destructive">*</span> <div className="grid gap-4 md:grid-cols-2">
</p> <FormField label={t("setting.sso.client-id")} required>
<Input <Input
className="mb-2 w-full" placeholder={t("setting.sso.client-id")}
placeholder={t("setting.sso.client-id")} value={oauth2Config.clientId}
value={oauth2Config.clientId} onChange={(e) => setPartialOAuth2Config({ clientId: e.target.value })}
onChange={(e) => setPartialOAuth2Config({ clientId: e.target.value })} />
/> </FormField>
<p className="mb-1 text-sm font-medium">
{t("setting.sso.client-secret")} <FormField
<span className="text-destructive">*</span> label={t("setting.sso.client-secret")}
</p> required={isCreating}
<Input description={isCreating ? undefined : t("setting.sso.client-secret-optional-description")}
className="mb-2 w-full" >
placeholder={t("setting.sso.client-secret")} <Input
value={oauth2Config.clientSecret} type="password"
onChange={(e) => setPartialOAuth2Config({ clientSecret: e.target.value })} autoComplete="off"
/> placeholder={t("setting.sso.client-secret")}
<p className="mb-1 text-sm font-medium"> value={oauth2Config.clientSecret}
{t("setting.sso.authorization-endpoint")} onChange={(e) => setPartialOAuth2Config({ clientSecret: e.target.value })}
<span className="text-destructive">*</span> />
</p> </FormField>
<Input </div>
className="mb-2 w-full"
placeholder={t("setting.sso.authorization-endpoint")} <div className="grid gap-4 md:grid-cols-2">
value={oauth2Config.authUrl} <FormField label={t("setting.sso.authorization-endpoint")} required>
onChange={(e) => setPartialOAuth2Config({ authUrl: e.target.value })} <Input
/> placeholder={t("setting.sso.authorization-endpoint")}
<p className="mb-1 text-sm font-medium"> value={oauth2Config.authUrl}
{t("setting.sso.token-endpoint")} onChange={(e) => setPartialOAuth2Config({ authUrl: e.target.value })}
<span className="text-destructive">*</span> />
</p> </FormField>
<Input
className="mb-2 w-full" <FormField label={t("setting.sso.token-endpoint")} required>
placeholder={t("setting.sso.token-endpoint")} <Input
value={oauth2Config.tokenUrl} placeholder={t("setting.sso.token-endpoint")}
onChange={(e) => setPartialOAuth2Config({ tokenUrl: e.target.value })} value={oauth2Config.tokenUrl}
/> onChange={(e) => setPartialOAuth2Config({ tokenUrl: e.target.value })}
<p className="mb-1 text-sm font-medium"> />
{t("setting.sso.user-endpoint")} </FormField>
<span className="text-destructive">*</span> </div>
</p>
<Input <div className="grid gap-4 md:grid-cols-2">
className="mb-2 w-full" <FormField label={t("setting.sso.user-endpoint")} required>
placeholder={t("setting.sso.user-endpoint")} <Input
value={oauth2Config.userInfoUrl} placeholder={t("setting.sso.user-endpoint")}
onChange={(e) => setPartialOAuth2Config({ userInfoUrl: e.target.value })} value={oauth2Config.userInfoUrl}
/> onChange={(e) => setPartialOAuth2Config({ userInfoUrl: e.target.value })}
<p className="mb-1 text-sm font-medium"> />
{t("setting.sso.scopes")} </FormField>
<span className="text-destructive">*</span>
</p> <FormField label={t("setting.sso.scopes")} required description={t("setting.sso.scopes-description")}>
<Input <Input placeholder={t("setting.sso.scopes")} value={oauth2Scopes} onChange={(e) => setOAuth2Scopes(e.target.value)} />
className="mb-2 w-full" </FormField>
placeholder={t("setting.sso.scopes")} </div>
value={oauth2Scopes} </FormSection>
onChange={(e) => setOAuth2Scopes(e.target.value)}
/> <FormSection title={t("setting.sso.field-mapping")} description={t("setting.sso.field-mapping-description")}>
<Separator className="my-2" /> <div className="grid gap-4 md:grid-cols-2">
<p className="mb-1 text-sm font-medium"> <FormField
{t("setting.sso.identifier")} label={t("setting.sso.identifier")}
<span className="text-destructive">*</span> required
</p> description={t("setting.sso.field-mapping-identifier-description")}
<Input >
className="mb-2 w-full" <Input
placeholder={t("setting.sso.identifier")} placeholder={t("setting.sso.identifier")}
value={oauth2Config.fieldMapping!.identifier} value={oauth2FieldMapping.identifier}
onChange={(e) => onChange={(e) => setPartialFieldMapping({ identifier: e.target.value })}
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, identifier: e.target.value } as FieldMapping }) />
} </FormField>
/>
<p className="mb-1 text-sm font-medium">{t("setting.sso.display-name")}</p> <FormField label={t("setting.sso.display-name")}>
<Input <Input
className="mb-2 w-full" placeholder={t("setting.sso.display-name")}
placeholder={t("setting.sso.display-name")} value={oauth2FieldMapping.displayName}
value={oauth2Config.fieldMapping!.displayName} onChange={(e) => setPartialFieldMapping({ displayName: e.target.value })}
onChange={(e) => />
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, displayName: e.target.value } as FieldMapping }) </FormField>
} </div>
/>
<p className="mb-1 text-sm font-medium">{t("common.email")}</p> <div className="grid gap-4 md:grid-cols-2">
<Input <FormField label={t("common.email")}>
className="mb-2 w-full" <Input
placeholder={t("common.email")} placeholder={t("common.email")}
value={oauth2Config.fieldMapping!.email} value={oauth2FieldMapping.email}
onChange={(e) => onChange={(e) => setPartialFieldMapping({ email: e.target.value })}
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, email: e.target.value } as FieldMapping }) />
} </FormField>
/>
<p className="mb-1 text-sm font-medium">Avatar URL</p> <FormField label={t("setting.sso.avatar-url")}>
<Input <Input
className="mb-2 w-full" placeholder={t("setting.sso.avatar-url")}
placeholder={"Avatar URL"} value={oauth2FieldMapping.avatarUrl}
value={oauth2Config.fieldMapping!.avatarUrl} onChange={(e) => setPartialFieldMapping({ avatarUrl: e.target.value })}
onChange={(e) => />
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, avatarUrl: e.target.value } as FieldMapping }) </FormField>
} </div>
/> </FormSection>
</> </>
)} ) : null}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={handleCloseBtnClick}> <Button variant="ghost" onClick={handleCloseBtnClick} disabled={isSubmitting}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
<Button onClick={handleConfirmBtnClick} disabled={!allowConfirmAction()}> <Button onClick={handleConfirmBtnClick} disabled={!allowConfirmAction()}>
......
...@@ -79,7 +79,16 @@ const AccessTokenSection = () => { ...@@ -79,7 +79,16 @@ const AccessTokenSection = () => {
}; };
return ( 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 <SettingTable
columns={[ columns={[
{ {
...@@ -115,13 +124,6 @@ const AccessTokenSection = () => { ...@@ -115,13 +124,6 @@ const AccessTokenSection = () => {
getRowKey={(token) => token.name} 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 */} {/* Create Access Token Dialog */}
<CreateAccessTokenDialog <CreateAccessTokenDialog
open={createTokenDialog.isOpen} open={createTokenDialog.isOpen}
......
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;
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast"; 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 { Button } from "@/components/ui/button";
import { identityProviderServiceClient, userServiceClient } from "@/connect"; import { identityProviderServiceClient, userServiceClient } from "@/connect";
import { getIdentityProviderTypeLabel, getSSOProviderUid } from "@/helpers/sso-display";
import { absolutifyLink } from "@/helpers/utils"; import { absolutifyLink } from "@/helpers/utils";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { handleError } from "@/lib/error"; import { handleError } from "@/lib/error";
...@@ -14,8 +17,11 @@ import SettingTable from "./SettingTable"; ...@@ -14,8 +17,11 @@ import SettingTable from "./SettingTable";
interface LinkedIdentityRow extends Record<string, unknown> { interface LinkedIdentityRow extends Record<string, unknown> {
name: string; name: string;
providerUid: string;
title: string; title: string;
typeLabel: string;
externUid: string; externUid: string;
isLinked: boolean;
linkedIdentity?: LinkedIdentity; linkedIdentity?: LinkedIdentity;
identityProvider: IdentityProvider; identityProvider: IdentityProvider;
} }
...@@ -70,8 +76,11 @@ const LinkedIdentitySection = () => { ...@@ -70,8 +76,11 @@ const LinkedIdentitySection = () => {
const linkedIdentity = linkedIdentityByProviderName.get(identityProvider.name); const linkedIdentity = linkedIdentityByProviderName.get(identityProvider.name);
return { return {
name: identityProvider.name, name: identityProvider.name,
providerUid: getSSOProviderUid(identityProvider.name),
title: identityProvider.title, title: identityProvider.title,
typeLabel: getIdentityProviderTypeLabel(identityProvider.type),
externUid: linkedIdentity?.externUid ?? "", externUid: linkedIdentity?.externUid ?? "",
isLinked: !!linkedIdentity,
linkedIdentity, linkedIdentity,
identityProvider, identityProvider,
}; };
...@@ -122,7 +131,7 @@ const LinkedIdentitySection = () => { ...@@ -122,7 +131,7 @@ const LinkedIdentitySection = () => {
name: row.linkedIdentity.name, name: row.linkedIdentity.name,
}); });
await fetchData(); await fetchData();
toast.success(`Unlinked ${row.title}.`); toast.success(t("setting.sso.unlink-success", { name: row.title }));
} catch (error) { } catch (error) {
handleError(error, toast.error, { handleError(error, toast.error, {
context: "Delete linked identity", context: "Delete linked identity",
...@@ -131,40 +140,55 @@ const LinkedIdentitySection = () => { ...@@ -131,40 +140,55 @@ const LinkedIdentitySection = () => {
} }
}; };
if (oauthIdentityProviders.length === 0) {
return null;
}
return ( return (
<SettingGroup <SettingGroup showSeparator title={t("setting.sso.accounts-title")} description={t("setting.sso.accounts-description")}>
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."
>
<SettingTable<LinkedIdentityRow> <SettingTable<LinkedIdentityRow>
variant="info-flow"
columns={[ columns={[
{ {
key: "title", key: "title",
header: "SSO provider", header: t("setting.sso.provider"),
render: (_, row: LinkedIdentityRow) => <span className="text-foreground">{row.title}</span>, 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", key: "externUid",
header: "extern_uid", header: t("setting.sso.account"),
render: (_, row: LinkedIdentityRow) => ( render: (_, row: LinkedIdentityRow) => (
<span className={row.externUid ? "text-foreground" : "text-muted-foreground"}> <div className="flex min-w-[22rem] flex-col gap-2">
{row.externUid || t("attachment-library.labels.not-linked")} <div className="flex flex-wrap items-center gap-2">
</span> <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", key: "actions",
header: "", header: "",
className: "text-right", className: "w-px text-right",
render: (_, row: LinkedIdentityRow) => render: (_, row: LinkedIdentityRow) =>
row.linkedIdentity ? ( row.linkedIdentity ? (
<Button variant="outline" size="sm" onClick={() => handleUnlinkIdentityProvider(row)}> <Button variant="outline" size="sm" onClick={() => handleUnlinkIdentityProvider(row)}>
Unlink {t("common.unlink")}
</Button> </Button>
) : ( ) : (
<Button variant="outline" size="sm" onClick={() => handleLinkIdentityProvider(row.identityProvider)}> <Button variant="outline" size="sm" onClick={() => handleLinkIdentityProvider(row.identityProvider)}>
...@@ -174,7 +198,7 @@ const LinkedIdentitySection = () => { ...@@ -174,7 +198,7 @@ const LinkedIdentitySection = () => {
}, },
]} ]}
data={rows} data={rows}
emptyMessage="No SSO providers found." emptyMessage={t("setting.sso.no-sso-found")}
getRowKey={(row) => row.name} getRowKey={(row) => row.name}
/> />
</SettingGroup> </SettingGroup>
......
...@@ -5,6 +5,9 @@ import { MoreVerticalIcon, PlusIcon } from "lucide-react"; ...@@ -5,6 +5,9 @@ import { MoreVerticalIcon, PlusIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog"; 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 { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect"; import { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
...@@ -113,40 +116,57 @@ const MemberSection = () => { ...@@ -113,40 +116,57 @@ const MemberSection = () => {
} }
> >
<SettingTable <SettingTable
variant="info-flow"
columns={[ columns={[
{ {
key: "username", key: "member",
header: t("common.username"), header: t("setting.member.member-column"),
render: (_, user: User) => ( render: (_, user: User) => (
<span className="text-foreground"> <div className="flex min-w-[18rem] items-start gap-3">
{user.username} <UserAvatar className="h-10 w-10 shrink-0 rounded-xl" avatarUrl={user.avatarUrl} />
{user.state === State.ARCHIVED && <span className="ml-2 italic text-muted-foreground">({t("common.archived")})</span>} <div className="flex min-w-0 flex-1 flex-col">
</span> <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", key: "summary",
header: t("common.role"), header: t("setting.member.summary-column"),
render: (_, user: User) => stringifyUserRole(user.role), render: (_, user: User) => (
}, <div className="flex min-w-[18rem] flex-col gap-2">
{ <div className="flex flex-wrap items-center gap-2">
key: "displayName", <Badge variant="secondary" className="rounded-full px-2.5 py-0.5">
header: t("common.nickname"), {stringifyUserRole(user.role)}
render: (_, user: User) => user.displayName, </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")}
key: "email", </Badge>
header: t("common.email"), </div>
render: (_, user: User) => user.email, {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", key: "actions",
header: "", header: "",
className: "text-right", className: "w-px text-right",
render: (_, user: User) => render: (_, user: User) =>
currentUser?.name === user.name ? ( currentUser?.name === user.name ? null : (
<span className="text-muted-foreground">{t("common.yourself")}</span>
) : (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
......
import { MoreVerticalIcon, PenLineIcon } from "lucide-react"; import { AlertTriangleIcon, KeyRoundIcon, PenLineIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
...@@ -14,7 +14,6 @@ import { useTranslate } from "@/utils/i18n"; ...@@ -14,7 +14,6 @@ import { useTranslate } from "@/utils/i18n";
import ChangeMemberPasswordDialog from "../ChangeMemberPasswordDialog"; import ChangeMemberPasswordDialog from "../ChangeMemberPasswordDialog";
import UpdateAccountDialog from "../UpdateAccountDialog"; import UpdateAccountDialog from "../UpdateAccountDialog";
import UserAvatar from "../UserAvatar"; import UserAvatar from "../UserAvatar";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
import AccessTokenSection from "./AccessTokenSection"; import AccessTokenSection from "./AccessTokenSection";
import LinkedIdentitySection from "./LinkedIdentitySection"; import LinkedIdentitySection from "./LinkedIdentitySection";
import SettingGroup from "./SettingGroup"; import SettingGroup from "./SettingGroup";
...@@ -61,19 +60,10 @@ const MyAccountSection = () => { ...@@ -61,19 +60,10 @@ const MyAccountSection = () => {
<PenLineIcon className="w-4 h-4 mr-1.5" /> <PenLineIcon className="w-4 h-4 mr-1.5" />
{t("common.edit")} {t("common.edit")}
</Button> </Button>
<DropdownMenu> <Button variant="outline" size="sm" onClick={passwordDialog.open}>
<DropdownMenuTrigger asChild> <KeyRoundIcon className="w-4 h-4 mr-1.5" />
<Button variant="outline" size="sm"> {t("setting.account.change-password")}
<MoreVerticalIcon className="w-4 h-4" /> </Button>
</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>
</div> </div>
</div> </div>
</SettingGroup> </SettingGroup>
...@@ -82,6 +72,25 @@ const MyAccountSection = () => { ...@@ -82,6 +72,25 @@ const MyAccountSection = () => {
<AccessTokenSection /> <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 */} {/* Update Account Dialog */}
<UpdateAccountDialog open={accountDialog.isOpen} onOpenChange={accountDialog.setOpen} /> <UpdateAccountDialog open={accountDialog.isOpen} onOpenChange={accountDialog.setOpen} />
......
...@@ -2,12 +2,15 @@ import { MoreVerticalIcon, PlusIcon } from "lucide-react"; ...@@ -2,12 +2,15 @@ import { MoreVerticalIcon, PlusIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import InfoChip from "@/components/Settings/InfoChip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { identityProviderServiceClient } from "@/connect"; import { identityProviderServiceClient } from "@/connect";
import { getIdentityProviderTypeLabel, getOAuth2SummaryItems, getSSOProviderUid, type SummaryItem } from "@/helpers/sso-display";
import { useDialog } from "@/hooks/useDialog"; import { useDialog } from "@/hooks/useDialog";
import { handleError } from "@/lib/error"; 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 { useTranslate } from "@/utils/i18n";
import CreateIdentityProviderDialog from "../CreateIdentityProviderDialog"; import CreateIdentityProviderDialog from "../CreateIdentityProviderDialog";
import LearnMore from "../LearnMore"; import LearnMore from "../LearnMore";
...@@ -19,11 +22,10 @@ interface IdentityProviderRow extends Record<string, unknown> { ...@@ -19,11 +22,10 @@ interface IdentityProviderRow extends Record<string, unknown> {
providerUid: string; providerUid: string;
title: string; title: string;
typeLabel: string; typeLabel: string;
summaryItems: SummaryItem[];
provider: IdentityProvider; provider: IdentityProvider;
} }
const getIdentityProviderUID = (name: string) => name.replace(/^identity-providers\//, "");
const SSOSection = () => { const SSOSection = () => {
const t = useTranslate(); const t = useTranslate();
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]); const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
...@@ -50,12 +52,13 @@ const SSOSection = () => { ...@@ -50,12 +52,13 @@ const SSOSection = () => {
() => () =>
identityProviderList.map((provider) => ({ identityProviderList.map((provider) => ({
name: provider.name, name: provider.name,
providerUid: getIdentityProviderUID(provider.name), providerUid: getSSOProviderUid(provider.name),
title: provider.title, title: provider.title,
typeLabel: IdentityProvider_Type[provider.type] ?? "TYPE_UNSPECIFIED", typeLabel: getIdentityProviderTypeLabel(provider.type),
summaryItems: getOAuth2SummaryItems(provider, t),
provider, provider,
})), })),
[identityProviderList], [identityProviderList, t],
); );
const handleDeleteIdentityProvider = (identityProvider: IdentityProvider) => { const handleDeleteIdentityProvider = (identityProvider: IdentityProvider) => {
...@@ -114,26 +117,43 @@ const SSOSection = () => { ...@@ -114,26 +117,43 @@ const SSOSection = () => {
} }
> >
<SettingTable <SettingTable
variant="info-flow"
columns={[ columns={[
{ {
key: "providerUid", key: "title",
header: "provider_uid", header: t("setting.sso.provider"),
render: (_, row: IdentityProviderRow) => ( render: (_, row: IdentityProviderRow) => (
<div className="flex flex-col"> <div className="flex min-w-[16rem] flex-col gap-2">
<span className="text-foreground">{row.providerUid}</span> <div className="flex flex-wrap items-center gap-2">
{row.title ? <span className="text-sm text-muted-foreground">{row.title}</span> : null} <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> </div>
), ),
}, },
{ {
key: "typeLabel", key: "summaryItems",
header: t("common.type"), header: t("setting.sso.configuration"),
render: (_, row: IdentityProviderRow) => <span className="text-muted-foreground">{row.typeLabel}</span>, 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", key: "actions",
header: "", header: "",
className: "text-right", className: "w-px text-right",
render: (_, row: IdentityProviderRow) => ( render: (_, row: IdentityProviderRow) => (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
......
...@@ -8,17 +8,23 @@ interface SettingGroupProps { ...@@ -8,17 +8,23 @@ interface SettingGroupProps {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
showSeparator?: boolean; 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 ( return (
<> <>
{showSeparator && <Separator className="my-2" />} {showSeparator && <Separator className="my-2" />}
<div className={cn("flex flex-col gap-3", className)}> <div className={cn("flex flex-col gap-3", className)}>
{(title || description) && ( {(title || description || actions) && (
<div className="flex flex-col gap-1"> <div className="flex items-start justify-between gap-3">
{title && <h4 className="text-sm font-medium text-muted-foreground">{title}</h4>} {(title || description) && (
{description && <p className="text-xs text-muted-foreground">{description}</p>} <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>
)} )}
<div className="flex flex-col gap-3">{children}</div> <div className="flex flex-col gap-3">{children}</div>
......
...@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils"; ...@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils";
interface SettingTableColumn<T = Record<string, unknown>> { interface SettingTableColumn<T = Record<string, unknown>> {
key: string; key: string;
header: string; header: React.ReactNode;
className?: string; className?: string;
render?: (value: T[keyof T], row: T) => React.ReactNode; render?: (value: T[keyof T], row: T) => React.ReactNode;
} }
...@@ -14,6 +14,7 @@ interface SettingTableProps<T = Record<string, unknown>> { ...@@ -14,6 +14,7 @@ interface SettingTableProps<T = Record<string, unknown>> {
emptyMessage?: string; emptyMessage?: string;
className?: string; className?: string;
getRowKey?: (row: T, index: number) => string; getRowKey?: (row: T, index: number) => string;
variant?: "default" | "info-flow";
} }
const SettingTable = <T extends Record<string, unknown>>({ const SettingTable = <T extends Record<string, unknown>>({
...@@ -22,6 +23,7 @@ const SettingTable = <T extends Record<string, unknown>>({ ...@@ -22,6 +23,7 @@ const SettingTable = <T extends Record<string, unknown>>({
emptyMessage = "No data", emptyMessage = "No data",
className, className,
getRowKey, getRowKey,
variant = "default",
}: SettingTableProps<T>) => { }: SettingTableProps<T>) => {
return ( return (
<div className={cn("w-full overflow-x-auto", className)}> <div className={cn("w-full overflow-x-auto", className)}>
...@@ -52,7 +54,14 @@ const SettingTable = <T extends Record<string, unknown>>({ ...@@ -52,7 +54,14 @@ const SettingTable = <T extends Record<string, unknown>>({
const value = row[column.key as keyof T] as T[keyof T]; const value = row[column.key as keyof T] as T[keyof T];
const content = column.render ? column.render(value, row) : (value as React.ReactNode); const content = column.render ? column.render(value, row) : (value as React.ReactNode);
return ( 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} {content}
</td> </td>
); );
......
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 @@ ...@@ -89,6 +89,7 @@
"delete": "Delete", "delete": "Delete",
"description": "Description", "description": "Description",
"edit": "Edit", "edit": "Edit",
"empty-placeholder": "Empty",
"email": "Email", "email": "Email",
"expand": "Expand", "expand": "Expand",
"explore": "Explore", "explore": "Explore",
...@@ -145,6 +146,7 @@ ...@@ -145,6 +146,7 @@
"today": "Today", "today": "Today",
"tree-mode": "Tree mode", "tree-mode": "Tree mode",
"type": "Type", "type": "Type",
"unlink": "Unlink",
"unpin": "Unpin", "unpin": "Unpin",
"update": "Update", "update": "Update",
"upload": "Upload", "upload": "Upload",
...@@ -386,7 +388,10 @@ ...@@ -386,7 +388,10 @@
}, },
"account": { "account": {
"change-password": "Change password", "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": "Delete account",
"delete-account-description": "Permanently remove this account and all associated access from this instance. This action cannot be undone.",
"email-note": "Optional", "email-note": "Optional",
"export-memos": "Export Memos", "export-memos": "Export Memos",
"nickname-note": "Displayed in the banner", "nickname-note": "Displayed in the banner",
...@@ -436,8 +441,10 @@ ...@@ -436,8 +441,10 @@
"week-start-day": "Week start day" "week-start-day": "Week start day"
}, },
"member": { "member": {
"active": "Active",
"admin": "Admin", "admin": "Admin",
"archive-member": "Archive member", "archive-member": "Archive member",
"archived": "Archived",
"archive-success": "{{username}} archived successfully", "archive-success": "{{username}} archived successfully",
"archive-warning": "Are you sure you want to archive {{username}}?", "archive-warning": "Are you sure you want to archive {{username}}?",
"archive-warning-description": "Archiving disables the account. You can restore or delete it later.", "archive-warning-description": "Archiving disables the account. You can restore or delete it later.",
...@@ -448,7 +455,9 @@ ...@@ -448,7 +455,9 @@
"delete-warning-description": "THIS ACTION IS IRREVERSIBLE", "delete-warning-description": "THIS ACTION IS IRREVERSIBLE",
"label": "Member", "label": "Member",
"list-title": "Member list", "list-title": "Member list",
"member-column": "Member",
"restore-success": "{{username}} restored successfully", "restore-success": "{{username}} restored successfully",
"summary-column": "Summary",
"user": "User", "user": "User",
"no-members-found": "No members found" "no-members-found": "No members found"
}, },
...@@ -476,28 +485,68 @@ ...@@ -476,28 +485,68 @@
"delete-success": "Shortcut `{{title}}` deleted successfully" "delete-success": "Shortcut `{{title}}` deleted successfully"
}, },
"sso": { "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", "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-id": "Client ID",
"client-secret": "Client secret", "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", "confirm-delete": "Are you sure you want to delete `{{name}}` SSO configuration? THIS ACTION IS IRREVERSIBLE",
"create-sso": "Create SSO", "create-sso": "Create SSO",
"create-sso-description": "Create a new identity provider for administrator-managed single sign-on.",
"custom": "Custom", "custom": "Custom",
"delete-sso": "Confirm delete", "delete-sso": "Confirm delete",
"disabled-password-login-warning": "Password-login is disabled, be extra careful when removing identity providers", "disabled-password-login-warning": "Password-login is disabled, be extra careful when removing identity providers",
"endpoints": "Endpoints",
"display-name": "Display Name", "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": "Identifier",
"identifier-filter": "Identifier Filter", "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", "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.", "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": "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": "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", "single-sign-on": "Configuring Single Sign-On (SSO) for Authentication",
"sso-created": "SSO {{name}} created", "sso-created": "SSO {{name}} created",
"sso-list": "SSO List", "sso-list": "SSO List",
"sso-updated": "SSO {{name}} updated", "sso-updated": "SSO {{name}} updated",
"template": "Template", "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", "token-endpoint": "Token endpoint",
"update-sso": "Update SSO", "update-sso": "Update SSO",
"update-sso-description": "Review the provider configuration, then save the fields that should change.",
"user-endpoint": "User endpoint" "user-endpoint": "User endpoint"
}, },
"storage": { "storage": {
......
...@@ -56,6 +56,7 @@ ...@@ -56,6 +56,7 @@
"delete": "删除", "delete": "删除",
"description": "说明", "description": "说明",
"edit": "编辑", "edit": "编辑",
"empty-placeholder": "空",
"email": "邮箱", "email": "邮箱",
"expand": "展开", "expand": "展开",
"explore": "发现", "explore": "发现",
...@@ -112,6 +113,7 @@ ...@@ -112,6 +113,7 @@
"today": "今天", "today": "今天",
"tree-mode": "树模式", "tree-mode": "树模式",
"type": "类型", "type": "类型",
"unlink": "解绑",
"unpin": "取消置顶", "unpin": "取消置顶",
"update": "更新", "update": "更新",
"upload": "上传", "upload": "上传",
...@@ -325,8 +327,10 @@ ...@@ -325,8 +327,10 @@
}, },
"setting": { "setting": {
"member": { "member": {
"active": "启用中",
"admin": "管理员", "admin": "管理员",
"archive-member": "归档成员", "archive-member": "归档成员",
"archived": "已归档",
"archive-success": "{{username}} 归档成功", "archive-success": "{{username}} 归档成功",
"archive-warning": "您确定要归档 {{username}} 吗?", "archive-warning": "您确定要归档 {{username}} 吗?",
"archive-warning-description": "归档会禁用用户。您可以稍后恢复或删除它。", "archive-warning-description": "归档会禁用用户。您可以稍后恢复或删除它。",
...@@ -339,6 +343,8 @@ ...@@ -339,6 +343,8 @@
"user": "普通用户", "user": "普通用户",
"label": "成员", "label": "成员",
"list-title": "成员列表", "list-title": "成员列表",
"member-column": "成员",
"summary-column": "摘要",
"no-members-found": "没有找到会员" "no-members-found": "没有找到会员"
}, },
"my-account": { "my-account": {
...@@ -382,29 +388,70 @@ ...@@ -382,29 +388,70 @@
"delete-success": "捷径 `{{title}}` 删除成功" "delete-success": "捷径 `{{title}}` 删除成功"
}, },
"sso": { "sso": {
"account": "账户",
"accounts-description": "查看每个身份提供程序的当前绑定状态,并为当前账户连接或解绑外部身份。",
"accounts-title": "SSO 账户",
"authorization-endpoint": "授权端点(Authorization Endpoint)", "authorization-endpoint": "授权端点(Authorization Endpoint)",
"avatar-url": "头像链接(Avatar URL)",
"basic-settings": "基础信息",
"basic-settings-description": "先设置 provider 的标识、展示名称和可选的标识符规则,再补充 OAuth 配置。",
"client-id": "客户端ID(Client ID)", "client-id": "客户端ID(Client ID)",
"client-secret": "客户端密钥(Client Secret)", "client-secret": "客户端密钥(Client Secret)",
"client-secret-optional-description": "留空则保留现有的客户端密钥,不会覆盖。",
"configuration": "配置摘要",
"configuration-summary-description": "这里只展示便于识别和审查 provider 的关键信息,完整配置仍然通过编辑入口查看。",
"confirm-delete": "您确定要删除“{{name}}”单点登录配置吗?(此操作不可逆)", "confirm-delete": "您确定要删除“{{name}}”单点登录配置吗?(此操作不可逆)",
"create-sso": "创建单点登录", "create-sso": "创建单点登录",
"create-sso-description": "为管理员管理的单点登录创建新的身份提供程序。",
"custom": "自定义", "custom": "自定义",
"delete-sso": "确认删除", "delete-sso": "确认删除",
"disabled-password-login-warning": "密码登录已被禁用,删除身份提供程序时要格外小心", "disabled-password-login-warning": "密码登录已被禁用,删除身份提供程序时要格外小心",
"endpoints": "端点",
"display-name": "显示名称", "display-name": "显示名称",
"extern-uid": "外部 ID",
"extern-uid-description": "这是当前绑定到您账户上的身份提供程序侧标识。",
"filter-disabled": "未启用",
"field-mapping": "字段映射",
"field-mapping-description": "映射上游用户信息字段,用于识别用户并预填展示资料。",
"field-mapping-identifier-description": "这是登录或绑定账户时使用的稳定外部标识字段。",
"identifier": "标识符(Identifier)", "identifier": "标识符(Identifier)",
"identifier-filter": "标识符过滤器(Identifier Filter)", "identifier-filter": "标识符过滤器(Identifier Filter)",
"identifier-filter-description": "可选正则表达式,用来限制或允许哪些外部标识符可以登录。",
"linked": "已绑定",
"no-sso-found": "没有 SSO 配置", "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": "重定向链接",
"redirect-url-description": "将这个回调地址注册到身份提供程序中,授权码流程才能正确返回。",
"scopes": "范围", "scopes": "范围",
"scopes-description": "使用空格分隔多个 scope。大多数 provider 只需要 profile、email 这类基础 scope。",
"single-sign-on": "配置单点登录(SSO)进行身份验证", "single-sign-on": "配置单点登录(SSO)进行身份验证",
"sso-created": "单点登录 {{name}} 已创建", "sso-created": "单点登录 {{name}} 已创建",
"sso-list": "单点登录列表", "sso-list": "单点登录列表",
"sso-updated": "单点登录 {{name}} 已更新", "sso-updated": "单点登录 {{name}} 已更新",
"template": "模板", "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)", "token-endpoint": "令牌端点(Token Endpoint)",
"update-sso": "更新单点登录", "update-sso": "更新单点登录",
"user-endpoint": "用户端点(User Endpoint)", "update-sso-description": "检查当前 provider 配置,只保存你需要变更的字段。",
"label": "单点登录" "user-endpoint": "用户端点(User Endpoint)"
}, },
"storage": { "storage": {
"accesskey": "访问密钥(Access key)", "accesskey": "访问密钥(Access key)",
...@@ -493,7 +540,10 @@ ...@@ -493,7 +540,10 @@
}, },
"account": { "account": {
"change-password": "修改密码", "change-password": "修改密码",
"danger-area": "危险操作区",
"danger-area-description": "不可逆的账号操作统一放在这里,执行前请再次确认影响。",
"delete-account": "删除账号", "delete-account": "删除账号",
"delete-account-description": "永久删除当前账号,并移除它在这个实例中的全部访问权限。此操作无法撤销。",
"email-note": "可选", "email-note": "可选",
"export-memos": "导出备忘录", "export-memos": "导出备忘录",
"nickname-note": "显示在横幅中", "nickname-note": "显示在横幅中",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment