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