Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
ee179985
Unverified
Commit
ee179985
authored
Apr 24, 2026
by
boojack
Committed by
GitHub
Apr 24, 2026
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: redesign account and SSO management (#5886)
parent
30c0611a
Changes
26
Show whitespace changes
Inline
Side-by-side
Showing
26 changed files
with
2060 additions
and
418 deletions
+2060
-418
oauth2.go
internal/idp/oauth2/oauth2.go
+14
-3
oauth2_test.go
internal/idp/oauth2/oauth2_test.go
+53
-1
auth_service.go
server/router/api/v1/auth_service.go
+1
-1
test_helper.go
server/router/api/v1/test/test_helper.go
+3
-3
user_service_delete_test.go
server/router/api/v1/test/user_service_delete_test.go
+422
-0
user_service.go
server/router/api/v1/user_service.go
+23
-13
attachment.go
store/attachment.go
+18
-0
memo_share.go
store/db/mysql/memo_share.go
+3
-0
memo_share.go
store/db/postgres/memo_share.go
+6
-0
memo_share.go
store/db/sqlite/memo_share.go
+6
-0
memo_share.go
store/memo_share.go
+4
-3
store.go
store/store.go
+5
-0
user.go
store/user.go
+11
-6
user_delete.go
store/user_delete.go
+666
-0
CreateIdentityProviderDialog.tsx
web/src/components/CreateIdentityProviderDialog.tsx
+382
-299
AccessTokenSection.tsx
web/src/components/Settings/AccessTokenSection.tsx
+10
-8
InfoChip.tsx
web/src/components/Settings/InfoChip.tsx
+42
-0
LinkedIdentitySection.tsx
web/src/components/Settings/LinkedIdentitySection.tsx
+43
-19
MemberSection.tsx
web/src/components/Settings/MemberSection.tsx
+43
-23
MyAccountSection.tsx
web/src/components/Settings/MyAccountSection.tsx
+24
-15
SSOSection.tsx
web/src/components/Settings/SSOSection.tsx
+35
-15
SettingGroup.tsx
web/src/components/Settings/SettingGroup.tsx
+11
-5
SettingTable.tsx
web/src/components/Settings/SettingTable.tsx
+11
-2
sso-display.ts
web/src/helpers/sso-display.ts
+123
-0
en.json
web/src/locales/en.json
+49
-0
zh-Hans.json
web/src/locales/zh-Hans.json
+52
-2
No files found.
internal/idp/oauth2/oauth2.go
View file @
ee179985
...
...
@@ -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
.
NewRequest
WithContext
(
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"
)
...
...
internal/idp/oauth2/oauth2_test.go
View file @
ee179985
...
...
@@ -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"
)
}
server/router/api/v1/auth_service.go
View file @
ee179985
...
...
@@ -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
)
}
...
...
server/router/api/v1/test/test_helper.go
View file @
ee179985
...
...
@@ -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 tre
e.
//
Align the profile data directory with the test store so attachment files and
//
derived caches resolve against the same location as DeleteAttachmentStorag
e.
testProfile
:=
&
profile
.
Profile
{
Demo
:
true
,
Version
:
"test-1.0.0"
,
InstanceURL
:
"http://localhost:8080"
,
Driver
:
"sqlite"
,
DSN
:
":memory:"
,
Data
:
t
.
Temp
Dir
(),
Data
:
t
estStore
.
GetData
Dir
(),
}
// Create APIV1Service with nil grpcServer since we're testing direct calls
...
...
server/router/api/v1/test/user_service_delete_test.go
View file @
ee179985
...
...
@@ -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
),
0
o755
))
require
.
NoError
(
t
,
os
.
WriteFile
(
thumbnailCachePath
,
[]
byte
(
"thumb"
),
0
o644
))
require
.
NoError
(
t
,
os
.
MkdirAll
(
filepath
.
Dir
(
motionCachePath
),
0
o755
))
require
.
NoError
(
t
,
os
.
WriteFile
(
motionCachePath
,
[]
byte
(
"motion"
),
0
o644
))
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
)
}
server/router/api/v1/user_service.go
View file @
ee179985
...
...
@@ -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
}
...
...
store/attachment.go
View file @
ee179985
...
...
@@ -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
}
store/db/mysql/memo_share.go
View file @
ee179985
...
...
@@ -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
...
...
store/db/postgres/memo_share.go
View file @
ee179985
...
...
@@ -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
,
`
...
...
store/db/sqlite/memo_share.go
View file @
ee179985
...
...
@@ -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
,
`
...
...
store/memo_share.go
View file @
ee179985
...
...
@@ -17,6 +17,7 @@ type FindMemoShare struct {
ID
*
int32
UID
*
string
MemoID
*
int32
CreatorID
*
int32
}
// DeleteMemoShare identifies a share grant to remove.
...
...
store/store.go
View file @
ee179985
...
...
@@ -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
()
...
...
store/user.go
View file @
ee179985
...
...
@@ -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
}
store/user_delete.go
0 → 100644
View file @
ee179985
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
}
web/src/components/CreateIdentityProviderDialog.tsx
View file @
ee179985
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
,
Dialog
Description
,
Dialog
Footer
,
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
===
""
||
oauth2
Config
.
fieldMapping
?.
identifier
===
""
oauth2Config
.
clientId
.
trim
()
===
""
||
oauth2Config
.
authUrl
.
trim
()
===
""
||
oauth2Config
.
tokenUrl
.
trim
()
===
""
||
oauth2Config
.
userInfoUrl
.
trim
()
===
""
||
normalizeScopes
(
oauth2Scopes
).
length
===
0
||
oauth2
FieldMapping
.
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
?.();
onOpenChang
e
(
false
);
handleDialogClos
e
(
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=
{
onOpenChang
e
}
>
<
DialogContent
className=
"max-w-2xl max-h-[80vh] overflow-y-auto
"
>
<
Dialog
open=
{
open
}
onOpenChange=
{
handleDialogClos
e
}
>
<
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
()
}
>
...
...
web/src/components/Settings/AccessTokenSection.tsx
View file @
ee179985
...
...
@@ -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
}
...
...
web/src/components/Settings/InfoChip.tsx
0 → 100644
View file @
ee179985
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
;
web/src/components/Settings/LinkedIdentitySection.tsx
View file @
ee179985
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
>
...
...
web/src/components/Settings/MemberSection.tsx
View file @
ee179985
...
...
@@ -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"
>
...
...
web/src/components/Settings/MyAccountSection.tsx
View file @
ee179985
import
{
MoreVertical
Icon
,
PenLineIcon
}
from
"lucide-react"
;
import
{
AlertTriangleIcon
,
KeyRound
Icon
,
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
}
/>
...
...
web/src/components/Settings/SSOSection.tsx
View file @
ee179985
...
...
@@ -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
:
get
IdentityProviderUID
(
provider
.
name
),
providerUid
:
get
SSOProviderUid
(
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
>
...
...
web/src/components/Settings/SettingGroup.tsx
View file @
ee179985
...
...
@@ -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
>
</>
...
...
web/src/components/Settings/SettingTable.tsx
View file @
ee179985
...
...
@@ -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
>
);
...
...
web/src/helpers/sso-display.ts
0 → 100644
View file @
ee179985
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
)}
`
;
}
web/src/locales/en.json
View file @
ee179985
...
...
@@ -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"
:
{
...
...
web/src/locales/zh-Hans.json
View file @
ee179985
...
...
@@ -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"
:
"更新单点登录"
,
"u
ser-endpoint"
:
"用户端点(User Endpoint)
"
,
"
label"
:
"单点登录
"
"u
pdate-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"
:
"显示在横幅中"
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment