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
Expand all
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 (
...
@@ -8,6 +8,7 @@ import (
"io"
"io"
"log/slog"
"log/slog"
"net/http"
"net/http"
"time"
"github.com/pkg/errors"
"github.com/pkg/errors"
"golang.org/x/oauth2"
"golang.org/x/oauth2"
...
@@ -21,6 +22,8 @@ type IdentityProvider struct {
...
@@ -21,6 +22,8 @@ type IdentityProvider struct {
config
*
storepb
.
OAuth2Config
config
*
storepb
.
OAuth2Config
}
}
const
userInfoRequestTimeout
=
10
*
time
.
Second
// NewIdentityProvider initializes a new OAuth2 Identity Provider with the given configuration.
// NewIdentityProvider initializes a new OAuth2 Identity Provider with the given configuration.
func
NewIdentityProvider
(
config
*
storepb
.
OAuth2Config
)
(
*
IdentityProvider
,
error
)
{
func
NewIdentityProvider
(
config
*
storepb
.
OAuth2Config
)
(
*
IdentityProvider
,
error
)
{
for
v
,
field
:=
range
map
[
string
]
string
{
for
v
,
field
:=
range
map
[
string
]
string
{
...
@@ -78,9 +81,9 @@ func (p *IdentityProvider) ExchangeToken(ctx context.Context, redirectURL, code,
...
@@ -78,9 +81,9 @@ func (p *IdentityProvider) ExchangeToken(ctx context.Context, redirectURL, code,
}
}
// UserInfo returns the parsed user information using the given OAuth2 token.
// UserInfo returns the parsed user information using the given OAuth2 token.
func
(
p
*
IdentityProvider
)
UserInfo
(
token
string
)
(
*
idp
.
IdentityProviderUserInfo
,
error
)
{
func
(
p
*
IdentityProvider
)
UserInfo
(
ctx
context
.
Context
,
token
string
)
(
*
idp
.
IdentityProviderUserInfo
,
error
)
{
client
:=
&
http
.
Client
{}
client
:=
&
http
.
Client
{
Timeout
:
userInfoRequestTimeout
}
req
,
err
:=
http
.
NewRequest
(
http
.
MethodGet
,
p
.
config
.
UserInfoUrl
,
nil
)
req
,
err
:=
http
.
NewRequest
WithContext
(
ctx
,
http
.
MethodGet
,
p
.
config
.
UserInfoUrl
,
nil
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
errors
.
Wrap
(
err
,
"failed to create http request"
)
return
nil
,
errors
.
Wrap
(
err
,
"failed to create http request"
)
}
}
...
@@ -92,6 +95,14 @@ func (p *IdentityProvider) UserInfo(token string) (*idp.IdentityProviderUserInfo
...
@@ -92,6 +95,14 @@ func (p *IdentityProvider) UserInfo(token string) (*idp.IdentityProviderUserInfo
}
}
defer
resp
.
Body
.
Close
()
defer
resp
.
Body
.
Close
()
if
resp
.
StatusCode
<
http
.
StatusOK
||
resp
.
StatusCode
>=
http
.
StatusMultipleChoices
{
body
,
readErr
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
4096
))
if
readErr
!=
nil
{
return
nil
,
errors
.
Wrap
(
readErr
,
"failed to read error response body"
)
}
return
nil
,
errors
.
Errorf
(
"userinfo request failed with status %d: %s"
,
resp
.
StatusCode
,
string
(
body
))
}
body
,
err
:=
io
.
ReadAll
(
resp
.
Body
)
body
,
err
:=
io
.
ReadAll
(
resp
.
Body
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
errors
.
Wrap
(
err
,
"failed to read response body"
)
return
nil
,
errors
.
Wrap
(
err
,
"failed to read response body"
)
...
...
internal/idp/oauth2/oauth2_test.go
View file @
ee179985
...
@@ -152,7 +152,7 @@ func TestIdentityProvider(t *testing.T) {
...
@@ -152,7 +152,7 @@ func TestIdentityProvider(t *testing.T) {
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
testAccessToken
,
oauthToken
)
require
.
Equal
(
t
,
testAccessToken
,
oauthToken
)
userInfoResult
,
err
:=
oauth2
.
UserInfo
(
oauthToken
)
userInfoResult
,
err
:=
oauth2
.
UserInfo
(
ctx
,
oauthToken
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
wantUserInfo
:=
&
idp
.
IdentityProviderUserInfo
{
wantUserInfo
:=
&
idp
.
IdentityProviderUserInfo
{
...
@@ -162,3 +162,55 @@ func TestIdentityProvider(t *testing.T) {
...
@@ -162,3 +162,55 @@ func TestIdentityProvider(t *testing.T) {
}
}
assert
.
Equal
(
t
,
wantUserInfo
,
userInfoResult
)
assert
.
Equal
(
t
,
wantUserInfo
,
userInfoResult
)
}
}
func
TestIdentityProviderUserInfoUsesContext
(
t
*
testing
.
T
)
{
ctx
,
cancel
:=
context
.
WithCancel
(
context
.
Background
())
cancel
()
s
:=
httptest
.
NewServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
_
*
http
.
Request
)
{
w
.
WriteHeader
(
http
.
StatusOK
)
}))
defer
s
.
Close
()
oauth2
,
err
:=
NewIdentityProvider
(
&
storepb
.
OAuth2Config
{
ClientId
:
"test-client-id"
,
ClientSecret
:
"test-client-secret"
,
TokenUrl
:
"https://example.com/oauth2/token"
,
UserInfoUrl
:
s
.
URL
,
FieldMapping
:
&
storepb
.
FieldMapping
{
Identifier
:
"sub"
,
},
},
)
require
.
NoError
(
t
,
err
)
_
,
err
=
oauth2
.
UserInfo
(
ctx
,
"test-access-token"
)
require
.
Error
(
t
,
err
)
assert
.
ErrorContains
(
t
,
err
,
"failed to get user information"
)
}
func
TestIdentityProviderUserInfoRejectsNon2xx
(
t
*
testing
.
T
)
{
s
:=
httptest
.
NewServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
_
*
http
.
Request
)
{
http
.
Error
(
w
,
"upstream failure"
,
http
.
StatusBadGateway
)
}))
defer
s
.
Close
()
oauth2
,
err
:=
NewIdentityProvider
(
&
storepb
.
OAuth2Config
{
ClientId
:
"test-client-id"
,
ClientSecret
:
"test-client-secret"
,
TokenUrl
:
"https://example.com/oauth2/token"
,
UserInfoUrl
:
s
.
URL
,
FieldMapping
:
&
storepb
.
FieldMapping
{
Identifier
:
"sub"
,
},
},
)
require
.
NoError
(
t
,
err
)
_
,
err
=
oauth2
.
UserInfo
(
context
.
Background
(),
"test-access-token"
)
require
.
Error
(
t
,
err
)
assert
.
ErrorContains
(
t
,
err
,
"userinfo request failed with status 502"
)
assert
.
ErrorContains
(
t
,
err
,
"upstream failure"
)
}
server/router/api/v1/auth_service.go
View file @
ee179985
...
@@ -242,7 +242,7 @@ func (s *APIV1Service) resolveSSOIdentity(ctx context.Context, idpName, code, re
...
@@ -242,7 +242,7 @@ func (s *APIV1Service) resolveSSOIdentity(ctx context.Context, idpName, code, re
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to exchange token, error: %v"
,
err
)
return
nil
,
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to exchange token, error: %v"
,
err
)
}
}
userInfo
,
err
=
oauth2IdentityProvider
.
UserInfo
(
token
)
userInfo
,
err
=
oauth2IdentityProvider
.
UserInfo
(
ctx
,
token
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to get user info, error: %v"
,
err
)
return
nil
,
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to get user info, error: %v"
,
err
)
}
}
...
...
server/router/api/v1/test/test_helper.go
View file @
ee179985
...
@@ -27,15 +27,15 @@ func NewTestService(t *testing.T) *TestService {
...
@@ -27,15 +27,15 @@ func NewTestService(t *testing.T) *TestService {
// Create a test store with SQLite
// Create a test store with SQLite
testStore
:=
teststore
.
NewTestingStore
(
ctx
,
t
)
testStore
:=
teststore
.
NewTestingStore
(
ctx
,
t
)
//
Create a test profile with a temp directory for file storage,
//
Align the profile data directory with the test store so attachment files and
//
so tests that create attachments don't leave artifacts in the source tre
e.
//
derived caches resolve against the same location as DeleteAttachmentStorag
e.
testProfile
:=
&
profile
.
Profile
{
testProfile
:=
&
profile
.
Profile
{
Demo
:
true
,
Demo
:
true
,
Version
:
"test-1.0.0"
,
Version
:
"test-1.0.0"
,
InstanceURL
:
"http://localhost:8080"
,
InstanceURL
:
"http://localhost:8080"
,
Driver
:
"sqlite"
,
Driver
:
"sqlite"
,
DSN
:
":memory:"
,
DSN
:
":memory:"
,
Data
:
t
.
Temp
Dir
(),
Data
:
t
estStore
.
GetData
Dir
(),
}
}
// Create APIV1Service with nil grpcServer since we're testing direct calls
// Create APIV1Service with nil grpcServer since we're testing direct calls
...
...
server/router/api/v1/test/user_service_delete_test.go
View file @
ee179985
This diff is collapsed.
Click to expand it.
server/router/api/v1/user_service.go
View file @
ee179985
...
@@ -353,27 +353,37 @@ func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserR
...
@@ -353,27 +353,37 @@ func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserR
}
}
isSelfDelete
:=
currentUser
.
ID
==
userID
isSelfDelete
:=
currentUser
.
ID
==
userID
if
err
:=
s
.
Store
.
DeleteUserIdentities
(
ctx
,
&
store
.
DeleteUserIdentity
{
attachments
,
err
:=
s
.
Store
.
DeleteUserCompletely
(
ctx
,
&
store
.
DeleteUser
{
UserID
:
&
userID
,
});
err
!=
nil
{
return
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to delete user identities: %v"
,
err
)
}
if
err
:=
s
.
Store
.
DeleteUserSettings
(
ctx
,
&
store
.
DeleteUserSetting
{
UserID
:
&
userID
,
});
err
!=
nil
{
return
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to delete user settings: %v"
,
err
)
}
if
err
:=
s
.
Store
.
DeleteUser
(
ctx
,
&
store
.
DeleteUser
{
ID
:
user
.
ID
,
ID
:
user
.
ID
,
});
err
!=
nil
{
})
if
err
!=
nil
{
return
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to delete user: %v"
,
err
)
return
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to delete user: %v"
,
err
)
}
}
var
attachmentCleanupErr
error
failedAttachmentIDs
:=
make
([]
int32
,
0
)
for
_
,
attachment
:=
range
attachments
{
if
err
:=
s
.
Store
.
DeleteAttachmentStorage
(
ctx
,
attachment
);
err
!=
nil
{
slog
.
Warn
(
"failed to delete attachment storage after deleting user"
,
"user_id"
,
userID
,
"attachment_id"
,
attachment
.
ID
,
"error"
,
err
)
failedAttachmentIDs
=
append
(
failedAttachmentIDs
,
attachment
.
ID
)
if
attachmentCleanupErr
==
nil
{
attachmentCleanupErr
=
err
}
}
}
if
isSelfDelete
{
if
isSelfDelete
{
if
err
:=
s
.
clearAuthCookies
(
ctx
);
err
!=
nil
{
if
err
:=
s
.
clearAuthCookies
(
ctx
);
err
!=
nil
{
slog
.
Warn
(
"failed to clear auth cookies after self delete"
,
"user_id"
,
userID
,
"error"
,
err
)
slog
.
Warn
(
"failed to clear auth cookies after self delete"
,
"user_id"
,
userID
,
"error"
,
err
)
}
}
}
}
if
attachmentCleanupErr
!=
nil
{
return
nil
,
status
.
Errorf
(
codes
.
Internal
,
"user was deleted but attachment storage cleanup failed for %d attachment(s), first attachment_id=%d: %v"
,
len
(
failedAttachmentIDs
),
failedAttachmentIDs
[
0
],
attachmentCleanupErr
,
)
}
return
&
emptypb
.
Empty
{},
nil
return
&
emptypb
.
Empty
{},
nil
}
}
...
...
store/attachment.go
View file @
ee179985
...
@@ -76,6 +76,16 @@ const (
...
@@ -76,6 +76,16 @@ const (
motionCacheFolder
=
".motion_cache"
motionCacheFolder
=
".motion_cache"
)
)
type
deleteAttachmentStorageFailpointKey
struct
{}
// ErrDeleteAttachmentStorageFailpoint is returned by the test-only attachment storage failpoint.
var
ErrDeleteAttachmentStorageFailpoint
=
errors
.
New
(
"delete attachment storage failpoint"
)
// WithDeleteAttachmentStorageFailpoint forces DeleteAttachmentStorage to return a failpoint error.
func
WithDeleteAttachmentStorageFailpoint
(
ctx
context
.
Context
)
context
.
Context
{
return
context
.
WithValue
(
ctx
,
deleteAttachmentStorageFailpointKey
{},
true
)
}
func
(
s
*
Store
)
CreateAttachment
(
ctx
context
.
Context
,
create
*
Attachment
)
(
*
Attachment
,
error
)
{
func
(
s
*
Store
)
CreateAttachment
(
ctx
context
.
Context
,
create
*
Attachment
)
(
*
Attachment
,
error
)
{
if
!
base
.
UIDMatcher
.
MatchString
(
create
.
UID
)
{
if
!
base
.
UIDMatcher
.
MatchString
(
create
.
UID
)
{
return
nil
,
errors
.
New
(
"invalid uid"
)
return
nil
,
errors
.
New
(
"invalid uid"
)
...
@@ -177,6 +187,9 @@ func (s *Store) DeleteAttachmentStorage(ctx context.Context, attachment *Attachm
...
@@ -177,6 +187,9 @@ func (s *Store) DeleteAttachmentStorage(ctx context.Context, attachment *Attachm
if
attachment
==
nil
{
if
attachment
==
nil
{
return
nil
return
nil
}
}
if
shouldFailDeleteAttachmentStorage
(
ctx
)
{
return
ErrDeleteAttachmentStorageFailpoint
}
if
attachment
.
StorageType
==
storepb
.
AttachmentStorageType_LOCAL
{
if
attachment
.
StorageType
==
storepb
.
AttachmentStorageType_LOCAL
{
if
err
:=
func
()
error
{
if
err
:=
func
()
error
{
...
@@ -237,3 +250,8 @@ func (s *Store) deleteAttachmentDerivedCaches(attachment *Attachment) {
...
@@ -237,3 +250,8 @@ func (s *Store) deleteAttachmentDerivedCaches(attachment *Attachment) {
}
}
}
}
}
}
func
shouldFailDeleteAttachmentStorage
(
ctx
context
.
Context
)
bool
{
failpoint
,
ok
:=
ctx
.
Value
(
deleteAttachmentStorageFailpointKey
{})
.
(
bool
)
return
ok
&&
failpoint
}
store/db/mysql/memo_share.go
View file @
ee179985
...
@@ -53,6 +53,9 @@ func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]*
...
@@ -53,6 +53,9 @@ func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]*
if
find
.
MemoID
!=
nil
{
if
find
.
MemoID
!=
nil
{
where
,
args
=
append
(
where
,
"`memo_id` = ?"
),
append
(
args
,
*
find
.
MemoID
)
where
,
args
=
append
(
where
,
"`memo_id` = ?"
),
append
(
args
,
*
find
.
MemoID
)
}
}
if
find
.
CreatorID
!=
nil
{
where
,
args
=
append
(
where
,
"`creator_id` = ?"
),
append
(
args
,
*
find
.
CreatorID
)
}
rows
,
err
:=
d
.
db
.
QueryContext
(
ctx
,
`
rows
,
err
:=
d
.
db
.
QueryContext
(
ctx
,
`
SELECT
SELECT
...
...
store/db/postgres/memo_share.go
View file @
ee179985
...
@@ -40,6 +40,9 @@ func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]*
...
@@ -40,6 +40,9 @@ func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]*
if
find
.
MemoID
!=
nil
{
if
find
.
MemoID
!=
nil
{
where
,
args
=
append
(
where
,
"memo_id = "
+
placeholder
(
len
(
args
)
+
1
)),
append
(
args
,
*
find
.
MemoID
)
where
,
args
=
append
(
where
,
"memo_id = "
+
placeholder
(
len
(
args
)
+
1
)),
append
(
args
,
*
find
.
MemoID
)
}
}
if
find
.
CreatorID
!=
nil
{
where
,
args
=
append
(
where
,
"creator_id = "
+
placeholder
(
len
(
args
)
+
1
)),
append
(
args
,
*
find
.
CreatorID
)
}
rows
,
err
:=
d
.
db
.
QueryContext
(
ctx
,
`
rows
,
err
:=
d
.
db
.
QueryContext
(
ctx
,
`
SELECT
SELECT
...
@@ -93,6 +96,9 @@ func (d *DB) GetMemoShare(ctx context.Context, find *store.FindMemoShare) (*stor
...
@@ -93,6 +96,9 @@ func (d *DB) GetMemoShare(ctx context.Context, find *store.FindMemoShare) (*stor
if
find
.
MemoID
!=
nil
{
if
find
.
MemoID
!=
nil
{
where
,
args
=
append
(
where
,
"memo_id = "
+
placeholder
(
len
(
args
)
+
1
)),
append
(
args
,
*
find
.
MemoID
)
where
,
args
=
append
(
where
,
"memo_id = "
+
placeholder
(
len
(
args
)
+
1
)),
append
(
args
,
*
find
.
MemoID
)
}
}
if
find
.
CreatorID
!=
nil
{
where
,
args
=
append
(
where
,
"creator_id = "
+
placeholder
(
len
(
args
)
+
1
)),
append
(
args
,
*
find
.
CreatorID
)
}
ms
:=
&
store
.
MemoShare
{}
ms
:=
&
store
.
MemoShare
{}
if
err
:=
d
.
db
.
QueryRowContext
(
ctx
,
`
if
err
:=
d
.
db
.
QueryRowContext
(
ctx
,
`
...
...
store/db/sqlite/memo_share.go
View file @
ee179985
...
@@ -42,6 +42,9 @@ func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]*
...
@@ -42,6 +42,9 @@ func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]*
if
find
.
MemoID
!=
nil
{
if
find
.
MemoID
!=
nil
{
where
,
args
=
append
(
where
,
"`memo_id` = ?"
),
append
(
args
,
*
find
.
MemoID
)
where
,
args
=
append
(
where
,
"`memo_id` = ?"
),
append
(
args
,
*
find
.
MemoID
)
}
}
if
find
.
CreatorID
!=
nil
{
where
,
args
=
append
(
where
,
"`creator_id` = ?"
),
append
(
args
,
*
find
.
CreatorID
)
}
rows
,
err
:=
d
.
db
.
QueryContext
(
ctx
,
`
rows
,
err
:=
d
.
db
.
QueryContext
(
ctx
,
`
SELECT
SELECT
...
@@ -95,6 +98,9 @@ func (d *DB) GetMemoShare(ctx context.Context, find *store.FindMemoShare) (*stor
...
@@ -95,6 +98,9 @@ func (d *DB) GetMemoShare(ctx context.Context, find *store.FindMemoShare) (*stor
if
find
.
MemoID
!=
nil
{
if
find
.
MemoID
!=
nil
{
where
,
args
=
append
(
where
,
"`memo_id` = ?"
),
append
(
args
,
*
find
.
MemoID
)
where
,
args
=
append
(
where
,
"`memo_id` = ?"
),
append
(
args
,
*
find
.
MemoID
)
}
}
if
find
.
CreatorID
!=
nil
{
where
,
args
=
append
(
where
,
"`creator_id` = ?"
),
append
(
args
,
*
find
.
CreatorID
)
}
ms
:=
&
store
.
MemoShare
{}
ms
:=
&
store
.
MemoShare
{}
if
err
:=
d
.
db
.
QueryRowContext
(
ctx
,
`
if
err
:=
d
.
db
.
QueryRowContext
(
ctx
,
`
...
...
store/memo_share.go
View file @
ee179985
...
@@ -17,6 +17,7 @@ type FindMemoShare struct {
...
@@ -17,6 +17,7 @@ type FindMemoShare struct {
ID
*
int32
ID
*
int32
UID
*
string
UID
*
string
MemoID
*
int32
MemoID
*
int32
CreatorID
*
int32
}
}
// DeleteMemoShare identifies a share grant to remove.
// DeleteMemoShare identifies a share grant to remove.
...
...
store/store.go
View file @
ee179985
...
@@ -47,6 +47,11 @@ func (s *Store) GetDriver() Driver {
...
@@ -47,6 +47,11 @@ func (s *Store) GetDriver() Driver {
return
s
.
driver
return
s
.
driver
}
}
// GetDataDir returns the store data directory.
func
(
s
*
Store
)
GetDataDir
()
string
{
return
s
.
profile
.
Data
}
func
(
s
*
Store
)
Close
()
error
{
func
(
s
*
Store
)
Close
()
error
{
// Stop all cache cleanup goroutines
// Stop all cache cleanup goroutines
s
.
instanceSettingCache
.
Close
()
s
.
instanceSettingCache
.
Close
()
...
...
store/user.go
View file @
ee179985
...
@@ -2,6 +2,7 @@ package store
...
@@ -2,6 +2,7 @@ package store
import
(
import
(
"context"
"context"
"strconv"
)
)
// Role is the type of a role.
// Role is the type of a role.
...
@@ -80,13 +81,17 @@ type DeleteUser struct {
...
@@ -80,13 +81,17 @@ type DeleteUser struct {
ID
int32
ID
int32
}
}
func
userCacheKey
(
userID
int32
)
string
{
return
strconv
.
Itoa
(
int
(
userID
))
}
func
(
s
*
Store
)
CreateUser
(
ctx
context
.
Context
,
create
*
User
)
(
*
User
,
error
)
{
func
(
s
*
Store
)
CreateUser
(
ctx
context
.
Context
,
create
*
User
)
(
*
User
,
error
)
{
user
,
err
:=
s
.
driver
.
CreateUser
(
ctx
,
create
)
user
,
err
:=
s
.
driver
.
CreateUser
(
ctx
,
create
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
err
return
nil
,
err
}
}
s
.
userCache
.
Set
(
ctx
,
string
(
user
.
ID
),
user
)
s
.
userCache
.
Set
(
ctx
,
userCacheKey
(
user
.
ID
),
user
)
return
user
,
nil
return
user
,
nil
}
}
...
@@ -96,7 +101,7 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
...
@@ -96,7 +101,7 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
return
nil
,
err
return
nil
,
err
}
}
s
.
userCache
.
Set
(
ctx
,
string
(
user
.
ID
),
user
)
s
.
userCache
.
Set
(
ctx
,
userCacheKey
(
user
.
ID
),
user
)
return
user
,
nil
return
user
,
nil
}
}
...
@@ -107,14 +112,14 @@ func (s *Store) ListUsers(ctx context.Context, find *FindUser) ([]*User, error)
...
@@ -107,14 +112,14 @@ func (s *Store) ListUsers(ctx context.Context, find *FindUser) ([]*User, error)
}
}
for
_
,
user
:=
range
list
{
for
_
,
user
:=
range
list
{
s
.
userCache
.
Set
(
ctx
,
string
(
user
.
ID
),
user
)
s
.
userCache
.
Set
(
ctx
,
userCacheKey
(
user
.
ID
),
user
)
}
}
return
list
,
nil
return
list
,
nil
}
}
func
(
s
*
Store
)
GetUser
(
ctx
context
.
Context
,
find
*
FindUser
)
(
*
User
,
error
)
{
func
(
s
*
Store
)
GetUser
(
ctx
context
.
Context
,
find
*
FindUser
)
(
*
User
,
error
)
{
if
find
.
ID
!=
nil
{
if
find
.
ID
!=
nil
{
if
cache
,
ok
:=
s
.
userCache
.
Get
(
ctx
,
string
(
*
find
.
ID
));
ok
{
if
cache
,
ok
:=
s
.
userCache
.
Get
(
ctx
,
userCacheKey
(
*
find
.
ID
));
ok
{
user
,
ok
:=
cache
.
(
*
User
)
user
,
ok
:=
cache
.
(
*
User
)
if
ok
{
if
ok
{
return
user
,
nil
return
user
,
nil
...
@@ -131,7 +136,7 @@ func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) {
...
@@ -131,7 +136,7 @@ func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) {
}
}
user
:=
list
[
0
]
user
:=
list
[
0
]
s
.
userCache
.
Set
(
ctx
,
string
(
user
.
ID
),
user
)
s
.
userCache
.
Set
(
ctx
,
userCacheKey
(
user
.
ID
),
user
)
return
user
,
nil
return
user
,
nil
}
}
...
@@ -140,6 +145,6 @@ func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
...
@@ -140,6 +145,6 @@ func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
if
err
!=
nil
{
if
err
!=
nil
{
return
err
return
err
}
}
s
.
userCache
.
Delete
(
ctx
,
string
(
delete
.
ID
))
s
.
userCache
.
Delete
(
ctx
,
userCacheKey
(
delete
.
ID
))
return
nil
return
nil
}
}
store/user_delete.go
0 → 100644
View file @
ee179985
This diff is collapsed.
Click to expand it.
web/src/components/CreateIdentityProviderDialog.tsx
View file @
ee179985
This diff is collapsed.
Click to expand it.
web/src/components/Settings/AccessTokenSection.tsx
View file @
ee179985
...
@@ -79,7 +79,16 @@ const AccessTokenSection = () => {
...
@@ -79,7 +79,16 @@ const AccessTokenSection = () => {
};
};
return
(
return
(
<
SettingGroup
title=
{
t
(
"setting.access-token.title"
)
}
description=
{
t
(
"setting.access-token.description"
)
}
>
<
SettingGroup
title=
{
t
(
"setting.access-token.title"
)
}
description=
{
t
(
"setting.access-token.description"
)
}
actions=
{
<
Button
onClick=
{
createTokenDialog
.
open
}
size=
"sm"
>
<
PlusIcon
className=
"w-4 h-4 mr-1.5"
/>
{
t
(
"common.create"
)
}
</
Button
>
}
>
<
SettingTable
<
SettingTable
columns=
{
[
columns=
{
[
{
{
...
@@ -115,13 +124,6 @@ const AccessTokenSection = () => {
...
@@ -115,13 +124,6 @@ const AccessTokenSection = () => {
getRowKey=
{
(
token
)
=>
token
.
name
}
getRowKey=
{
(
token
)
=>
token
.
name
}
/>
/>
<
div
className=
"flex justify-end"
>
<
Button
onClick=
{
createTokenDialog
.
open
}
size=
"sm"
>
<
PlusIcon
className=
"w-4 h-4 mr-1.5"
/>
{
t
(
"common.create"
)
}
</
Button
>
</
div
>
{
/* Create Access Token Dialog */
}
{
/* Create Access Token Dialog */
}
<
CreateAccessTokenDialog
<
CreateAccessTokenDialog
open=
{
createTokenDialog
.
isOpen
}
open=
{
createTokenDialog
.
isOpen
}
...
...
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
{
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
{
toast
}
from
"react-hot-toast"
;
import
{
toast
}
from
"react-hot-toast"
;
import
InfoChip
from
"@/components/Settings/InfoChip"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
identityProviderServiceClient
,
userServiceClient
}
from
"@/connect"
;
import
{
identityProviderServiceClient
,
userServiceClient
}
from
"@/connect"
;
import
{
getIdentityProviderTypeLabel
,
getSSOProviderUid
}
from
"@/helpers/sso-display"
;
import
{
absolutifyLink
}
from
"@/helpers/utils"
;
import
{
absolutifyLink
}
from
"@/helpers/utils"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
import
{
handleError
}
from
"@/lib/error"
;
import
{
handleError
}
from
"@/lib/error"
;
...
@@ -14,8 +17,11 @@ import SettingTable from "./SettingTable";
...
@@ -14,8 +17,11 @@ import SettingTable from "./SettingTable";
interface
LinkedIdentityRow
extends
Record
<
string
,
unknown
>
{
interface
LinkedIdentityRow
extends
Record
<
string
,
unknown
>
{
name
:
string
;
name
:
string
;
providerUid
:
string
;
title
:
string
;
title
:
string
;
typeLabel
:
string
;
externUid
:
string
;
externUid
:
string
;
isLinked
:
boolean
;
linkedIdentity
?:
LinkedIdentity
;
linkedIdentity
?:
LinkedIdentity
;
identityProvider
:
IdentityProvider
;
identityProvider
:
IdentityProvider
;
}
}
...
@@ -70,8 +76,11 @@ const LinkedIdentitySection = () => {
...
@@ -70,8 +76,11 @@ const LinkedIdentitySection = () => {
const
linkedIdentity
=
linkedIdentityByProviderName
.
get
(
identityProvider
.
name
);
const
linkedIdentity
=
linkedIdentityByProviderName
.
get
(
identityProvider
.
name
);
return
{
return
{
name
:
identityProvider
.
name
,
name
:
identityProvider
.
name
,
providerUid
:
getSSOProviderUid
(
identityProvider
.
name
),
title
:
identityProvider
.
title
,
title
:
identityProvider
.
title
,
typeLabel
:
getIdentityProviderTypeLabel
(
identityProvider
.
type
),
externUid
:
linkedIdentity
?.
externUid
??
""
,
externUid
:
linkedIdentity
?.
externUid
??
""
,
isLinked
:
!!
linkedIdentity
,
linkedIdentity
,
linkedIdentity
,
identityProvider
,
identityProvider
,
};
};
...
@@ -122,7 +131,7 @@ const LinkedIdentitySection = () => {
...
@@ -122,7 +131,7 @@ const LinkedIdentitySection = () => {
name
:
row
.
linkedIdentity
.
name
,
name
:
row
.
linkedIdentity
.
name
,
});
});
await
fetchData
();
await
fetchData
();
toast
.
success
(
`Unlinked
${
row
.
title
}
.`
);
toast
.
success
(
t
(
"setting.sso.unlink-success"
,
{
name
:
row
.
title
})
);
}
catch
(
error
)
{
}
catch
(
error
)
{
handleError
(
error
,
toast
.
error
,
{
handleError
(
error
,
toast
.
error
,
{
context
:
"Delete linked identity"
,
context
:
"Delete linked identity"
,
...
@@ -131,40 +140,55 @@ const LinkedIdentitySection = () => {
...
@@ -131,40 +140,55 @@ const LinkedIdentitySection = () => {
}
}
};
};
if
(
oauthIdentityProviders
.
length
===
0
)
{
return
null
;
}
return
(
return
(
<
SettingGroup
<
SettingGroup
showSeparator
title=
{
t
(
"setting.sso.accounts-title"
)
}
description=
{
t
(
"setting.sso.accounts-description"
)
}
>
showSeparator
title=
"SSO accounts"
description=
"Each provider can be linked to this account at most once. A linked row shows the current extern_uid and can be unlinked."
>
<
SettingTable
<
LinkedIdentityRow
>
<
SettingTable
<
LinkedIdentityRow
>
variant="info-flow"
columns=
{
[
columns=
{
[
{
{
key
:
"title"
,
key
:
"title"
,
header
:
"SSO provider"
,
header
:
t
(
"setting.sso.provider"
),
render
:
(
_
,
row
:
LinkedIdentityRow
)
=>
<
span
className=
"text-foreground"
>
{
row
.
title
}
</
span
>,
render
:
(
_
,
row
:
LinkedIdentityRow
)
=>
(
<
div
className=
"flex min-w-[16rem] flex-col gap-2"
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
span
className=
"text-sm font-medium text-foreground"
>
{
row
.
title
}
</
span
>
<
Badge
variant=
"secondary"
className=
"rounded-full px-2.5 py-0.5"
>
{
row
.
typeLabel
}
</
Badge
>
</
div
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
InfoChip
label=
{
t
(
"setting.sso.provider-uid"
)
}
value=
{
row
.
providerUid
}
/>
</
div
>
</
div
>
),
},
},
{
{
key
:
"externUid"
,
key
:
"externUid"
,
header
:
"extern_uid"
,
header
:
t
(
"setting.sso.account"
)
,
render
:
(
_
,
row
:
LinkedIdentityRow
)
=>
(
render
:
(
_
,
row
:
LinkedIdentityRow
)
=>
(
<
span
className=
{
row
.
externUid
?
"text-foreground"
:
"text-muted-foreground"
}
>
<
div
className=
"flex min-w-[22rem] flex-col gap-2"
>
{
row
.
externUid
||
t
(
"attachment-library.labels.not-linked"
)
}
<
div
className=
"flex flex-wrap items-center gap-2"
>
</
span
>
<
Badge
variant=
{
row
.
isLinked
?
"default"
:
"outline"
}
className=
"rounded-full px-2.5 py-0.5"
>
{
row
.
isLinked
?
t
(
"setting.sso.linked"
)
:
t
(
"setting.sso.not-linked"
)
}
</
Badge
>
{
row
.
isLinked
&&
row
.
externUid
?
(
<
InfoChip
label=
{
t
(
"setting.sso.extern-uid"
)
}
value=
{
row
.
externUid
}
tooltip=
{
row
.
externUid
}
/>
)
:
null
}
</
div
>
<
p
className=
"text-xs text-muted-foreground"
>
{
row
.
isLinked
?
t
(
"setting.sso.extern-uid-description"
)
:
t
(
"setting.sso.not-linked-description"
)
}
</
p
>
</
div
>
),
),
},
},
{
{
key
:
"actions"
,
key
:
"actions"
,
header
:
""
,
header
:
""
,
className
:
"text-right"
,
className
:
"
w-px
text-right"
,
render
:
(
_
,
row
:
LinkedIdentityRow
)
=>
render
:
(
_
,
row
:
LinkedIdentityRow
)
=>
row
.
linkedIdentity
?
(
row
.
linkedIdentity
?
(
<
Button
variant=
"outline"
size=
"sm"
onClick=
{
()
=>
handleUnlinkIdentityProvider
(
row
)
}
>
<
Button
variant=
"outline"
size=
"sm"
onClick=
{
()
=>
handleUnlinkIdentityProvider
(
row
)
}
>
Unlink
{
t
(
"common.unlink"
)
}
</
Button
>
</
Button
>
)
:
(
)
:
(
<
Button
variant=
"outline"
size=
"sm"
onClick=
{
()
=>
handleLinkIdentityProvider
(
row
.
identityProvider
)
}
>
<
Button
variant=
"outline"
size=
"sm"
onClick=
{
()
=>
handleLinkIdentityProvider
(
row
.
identityProvider
)
}
>
...
@@ -174,7 +198,7 @@ const LinkedIdentitySection = () => {
...
@@ -174,7 +198,7 @@ const LinkedIdentitySection = () => {
},
},
]
}
]
}
data=
{
rows
}
data=
{
rows
}
emptyMessage=
"No SSO providers found."
emptyMessage=
{
t
(
"setting.sso.no-sso-found"
)
}
getRowKey=
{
(
row
)
=>
row
.
name
}
getRowKey=
{
(
row
)
=>
row
.
name
}
/
>
/
>
</
SettingGroup
>
</
SettingGroup
>
...
...
web/src/components/Settings/MemberSection.tsx
View file @
ee179985
...
@@ -5,6 +5,9 @@ import { MoreVerticalIcon, PlusIcon } from "lucide-react";
...
@@ -5,6 +5,9 @@ import { MoreVerticalIcon, PlusIcon } from "lucide-react";
import
{
useMemo
,
useState
}
from
"react"
;
import
{
useMemo
,
useState
}
from
"react"
;
import
toast
from
"react-hot-toast"
;
import
toast
from
"react-hot-toast"
;
import
ConfirmDialog
from
"@/components/ConfirmDialog"
;
import
ConfirmDialog
from
"@/components/ConfirmDialog"
;
import
InfoChip
from
"@/components/Settings/InfoChip"
;
import
UserAvatar
from
"@/components/UserAvatar"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
userServiceClient
}
from
"@/connect"
;
import
{
userServiceClient
}
from
"@/connect"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
...
@@ -113,40 +116,57 @@ const MemberSection = () => {
...
@@ -113,40 +116,57 @@ const MemberSection = () => {
}
}
>
>
<
SettingTable
<
SettingTable
variant=
"info-flow"
columns=
{
[
columns=
{
[
{
{
key
:
"
username
"
,
key
:
"
member
"
,
header
:
t
(
"
common.username
"
),
header
:
t
(
"
setting.member.member-column
"
),
render
:
(
_
,
user
:
User
)
=>
(
render
:
(
_
,
user
:
User
)
=>
(
<
span
className=
"text-foreground"
>
<
div
className=
"flex min-w-[18rem] items-start gap-3"
>
{
user
.
username
}
<
UserAvatar
className=
"h-10 w-10 shrink-0 rounded-xl"
avatarUrl=
{
user
.
avatarUrl
}
/>
{
user
.
state
===
State
.
ARCHIVED
&&
<
span
className=
"ml-2 italic text-muted-foreground"
>
(
{
t
(
"common.archived"
)
}
)
</
span
>
}
<
div
className=
"flex min-w-0 flex-1 flex-col"
>
<
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
>
</
span
>
{
currentUser
?.
name
===
user
.
name
?
<
span
className=
"text-xs text-muted-foreground"
>
{
t
(
"common.yourself"
)
}
</
span
>
:
null
}
</
div
>
<
span
className=
"truncate text-xs text-muted-foreground"
>
@
{
user
.
username
}
</
span
>
</
div
>
</
div
>
),
),
},
},
{
{
key
:
"role"
,
key
:
"summary"
,
header
:
t
(
"common.role"
),
header
:
t
(
"setting.member.summary-column"
),
render
:
(
_
,
user
:
User
)
=>
stringifyUserRole
(
user
.
role
),
render
:
(
_
,
user
:
User
)
=>
(
},
<
div
className=
"flex min-w-[18rem] flex-col gap-2"
>
{
<
div
className=
"flex flex-wrap items-center gap-2"
>
key
:
"displayName"
,
<
Badge
variant=
"secondary"
className=
"rounded-full px-2.5 py-0.5"
>
header
:
t
(
"common.nickname"
),
{
stringifyUserRole
(
user
.
role
)
}
render
:
(
_
,
user
:
User
)
=>
user
.
displayName
,
</
Badge
>
},
<
Badge
variant=
{
user
.
state
===
State
.
ARCHIVED
?
"outline"
:
"default"
}
className=
"rounded-full px-2.5 py-0.5"
>
{
{
user
.
state
===
State
.
ARCHIVED
?
t
(
"setting.member.archived"
)
:
t
(
"setting.member.active"
)
}
key
:
"email"
,
</
Badge
>
header
:
t
(
"common.email"
),
</
div
>
render
:
(
_
,
user
:
User
)
=>
user
.
email
,
{
user
.
email
?
(
<
div
className=
"flex flex-wrap gap-2"
>
<
InfoChip
label=
{
t
(
"common.email"
)
}
value=
{
user
.
email
}
tooltip=
{
user
.
email
}
/>
</
div
>
)
:
null
}
</
div
>
),
},
},
{
{
key
:
"actions"
,
key
:
"actions"
,
header
:
""
,
header
:
""
,
className
:
"text-right"
,
className
:
"
w-px
text-right"
,
render
:
(
_
,
user
:
User
)
=>
render
:
(
_
,
user
:
User
)
=>
currentUser
?.
name
===
user
.
name
?
(
currentUser
?.
name
===
user
.
name
?
null
:
(
<
span
className=
"text-muted-foreground"
>
{
t
(
"common.yourself"
)
}
</
span
>
)
:
(
<
DropdownMenu
>
<
DropdownMenu
>
<
DropdownMenuTrigger
asChild
>
<
DropdownMenuTrigger
asChild
>
<
Button
variant=
"outline"
size=
"sm"
>
<
Button
variant=
"outline"
size=
"sm"
>
...
...
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
{
useState
}
from
"react"
;
import
toast
from
"react-hot-toast"
;
import
toast
from
"react-hot-toast"
;
import
ConfirmDialog
from
"@/components/ConfirmDialog"
;
import
ConfirmDialog
from
"@/components/ConfirmDialog"
;
...
@@ -14,7 +14,6 @@ import { useTranslate } from "@/utils/i18n";
...
@@ -14,7 +14,6 @@ import { useTranslate } from "@/utils/i18n";
import
ChangeMemberPasswordDialog
from
"../ChangeMemberPasswordDialog"
;
import
ChangeMemberPasswordDialog
from
"../ChangeMemberPasswordDialog"
;
import
UpdateAccountDialog
from
"../UpdateAccountDialog"
;
import
UpdateAccountDialog
from
"../UpdateAccountDialog"
;
import
UserAvatar
from
"../UserAvatar"
;
import
UserAvatar
from
"../UserAvatar"
;
import
{
DropdownMenu
,
DropdownMenuContent
,
DropdownMenuItem
,
DropdownMenuTrigger
}
from
"../ui/dropdown-menu"
;
import
AccessTokenSection
from
"./AccessTokenSection"
;
import
AccessTokenSection
from
"./AccessTokenSection"
;
import
LinkedIdentitySection
from
"./LinkedIdentitySection"
;
import
LinkedIdentitySection
from
"./LinkedIdentitySection"
;
import
SettingGroup
from
"./SettingGroup"
;
import
SettingGroup
from
"./SettingGroup"
;
...
@@ -61,19 +60,10 @@ const MyAccountSection = () => {
...
@@ -61,19 +60,10 @@ const MyAccountSection = () => {
<
PenLineIcon
className=
"w-4 h-4 mr-1.5"
/>
<
PenLineIcon
className=
"w-4 h-4 mr-1.5"
/>
{
t
(
"common.edit"
)
}
{
t
(
"common.edit"
)
}
</
Button
>
</
Button
>
<
DropdownMenu
>
<
Button
variant=
"outline"
size=
"sm"
onClick=
{
passwordDialog
.
open
}
>
<
DropdownMenuTrigger
asChild
>
<
KeyRoundIcon
className=
"w-4 h-4 mr-1.5"
/>
<
Button
variant=
"outline"
size=
"sm"
>
{
t
(
"setting.account.change-password"
)
}
<
MoreVerticalIcon
className=
"w-4 h-4"
/>
</
Button
>
</
Button
>
</
DropdownMenuTrigger
>
<
DropdownMenuContent
align=
"end"
>
<
DropdownMenuItem
onClick=
{
passwordDialog
.
open
}
>
{
t
(
"setting.account.change-password"
)
}
</
DropdownMenuItem
>
<
DropdownMenuItem
onClick=
{
()
=>
setDeleteOpen
(
true
)
}
className=
"text-destructive focus:text-destructive"
>
{
t
(
"setting.account.delete-account"
)
}
</
DropdownMenuItem
>
</
DropdownMenuContent
>
</
DropdownMenu
>
</
div
>
</
div
>
</
div
>
</
div
>
</
SettingGroup
>
</
SettingGroup
>
...
@@ -82,6 +72,25 @@ const MyAccountSection = () => {
...
@@ -82,6 +72,25 @@ const MyAccountSection = () => {
<
AccessTokenSection
/>
<
AccessTokenSection
/>
<
SettingGroup
showSeparator
title=
{
t
(
"setting.account.danger-area"
)
}
description=
{
t
(
"setting.account.danger-area-description"
)
}
>
<
div
className=
"flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4"
>
<
div
className=
"flex items-start gap-3"
>
<
div
className=
"rounded-full bg-destructive/10 p-2 text-destructive"
>
<
AlertTriangleIcon
className=
"h-4 w-4"
/>
</
div
>
<
div
className=
"flex-1 space-y-1"
>
<
p
className=
"text-sm font-medium text-foreground"
>
{
t
(
"setting.account.delete-account"
)
}
</
p
>
<
p
className=
"text-sm text-muted-foreground"
>
{
t
(
"setting.account.delete-account-description"
)
}
</
p
>
</
div
>
</
div
>
<
div
className=
"flex justify-end"
>
<
Button
variant=
"destructive"
size=
"sm"
onClick=
{
()
=>
setDeleteOpen
(
true
)
}
>
{
t
(
"setting.account.delete-account"
)
}
</
Button
>
</
div
>
</
div
>
</
SettingGroup
>
{
/* Update Account Dialog */
}
{
/* Update Account Dialog */
}
<
UpdateAccountDialog
open=
{
accountDialog
.
isOpen
}
onOpenChange=
{
accountDialog
.
setOpen
}
/>
<
UpdateAccountDialog
open=
{
accountDialog
.
isOpen
}
onOpenChange=
{
accountDialog
.
setOpen
}
/>
...
...
web/src/components/Settings/SSOSection.tsx
View file @
ee179985
...
@@ -2,12 +2,15 @@ import { MoreVerticalIcon, PlusIcon } from "lucide-react";
...
@@ -2,12 +2,15 @@ import { MoreVerticalIcon, PlusIcon } from "lucide-react";
import
{
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
{
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
{
toast
}
from
"react-hot-toast"
;
import
{
toast
}
from
"react-hot-toast"
;
import
ConfirmDialog
from
"@/components/ConfirmDialog"
;
import
ConfirmDialog
from
"@/components/ConfirmDialog"
;
import
InfoChip
from
"@/components/Settings/InfoChip"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
DropdownMenu
,
DropdownMenuContent
,
DropdownMenuItem
,
DropdownMenuTrigger
}
from
"@/components/ui/dropdown-menu"
;
import
{
DropdownMenu
,
DropdownMenuContent
,
DropdownMenuItem
,
DropdownMenuTrigger
}
from
"@/components/ui/dropdown-menu"
;
import
{
identityProviderServiceClient
}
from
"@/connect"
;
import
{
identityProviderServiceClient
}
from
"@/connect"
;
import
{
getIdentityProviderTypeLabel
,
getOAuth2SummaryItems
,
getSSOProviderUid
,
type
SummaryItem
}
from
"@/helpers/sso-display"
;
import
{
useDialog
}
from
"@/hooks/useDialog"
;
import
{
useDialog
}
from
"@/hooks/useDialog"
;
import
{
handleError
}
from
"@/lib/error"
;
import
{
handleError
}
from
"@/lib/error"
;
import
{
IdentityProvider
,
IdentityProvider_Type
}
from
"@/types/proto/api/v1/idp_service_pb"
;
import
{
IdentityProvider
}
from
"@/types/proto/api/v1/idp_service_pb"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
CreateIdentityProviderDialog
from
"../CreateIdentityProviderDialog"
;
import
CreateIdentityProviderDialog
from
"../CreateIdentityProviderDialog"
;
import
LearnMore
from
"../LearnMore"
;
import
LearnMore
from
"../LearnMore"
;
...
@@ -19,11 +22,10 @@ interface IdentityProviderRow extends Record<string, unknown> {
...
@@ -19,11 +22,10 @@ interface IdentityProviderRow extends Record<string, unknown> {
providerUid
:
string
;
providerUid
:
string
;
title
:
string
;
title
:
string
;
typeLabel
:
string
;
typeLabel
:
string
;
summaryItems
:
SummaryItem
[];
provider
:
IdentityProvider
;
provider
:
IdentityProvider
;
}
}
const
getIdentityProviderUID
=
(
name
:
string
)
=>
name
.
replace
(
/^identity-providers
\/
/
,
""
);
const
SSOSection
=
()
=>
{
const
SSOSection
=
()
=>
{
const
t
=
useTranslate
();
const
t
=
useTranslate
();
const
[
identityProviderList
,
setIdentityProviderList
]
=
useState
<
IdentityProvider
[]
>
([]);
const
[
identityProviderList
,
setIdentityProviderList
]
=
useState
<
IdentityProvider
[]
>
([]);
...
@@ -50,12 +52,13 @@ const SSOSection = () => {
...
@@ -50,12 +52,13 @@ const SSOSection = () => {
()
=>
()
=>
identityProviderList
.
map
((
provider
)
=>
({
identityProviderList
.
map
((
provider
)
=>
({
name
:
provider
.
name
,
name
:
provider
.
name
,
providerUid
:
get
IdentityProviderUID
(
provider
.
name
),
providerUid
:
get
SSOProviderUid
(
provider
.
name
),
title
:
provider
.
title
,
title
:
provider
.
title
,
typeLabel
:
IdentityProvider_Type
[
provider
.
type
]
??
"TYPE_UNSPECIFIED"
,
typeLabel
:
getIdentityProviderTypeLabel
(
provider
.
type
),
summaryItems
:
getOAuth2SummaryItems
(
provider
,
t
),
provider
,
provider
,
})),
})),
[
identityProviderList
],
[
identityProviderList
,
t
],
);
);
const
handleDeleteIdentityProvider
=
(
identityProvider
:
IdentityProvider
)
=>
{
const
handleDeleteIdentityProvider
=
(
identityProvider
:
IdentityProvider
)
=>
{
...
@@ -114,26 +117,43 @@ const SSOSection = () => {
...
@@ -114,26 +117,43 @@ const SSOSection = () => {
}
}
>
>
<
SettingTable
<
SettingTable
variant=
"info-flow"
columns=
{
[
columns=
{
[
{
{
key
:
"
providerUid
"
,
key
:
"
title
"
,
header
:
"provider_uid"
,
header
:
t
(
"setting.sso.provider"
)
,
render
:
(
_
,
row
:
IdentityProviderRow
)
=>
(
render
:
(
_
,
row
:
IdentityProviderRow
)
=>
(
<
div
className=
"flex flex-col"
>
<
div
className=
"flex min-w-[16rem] flex-col gap-2"
>
<
span
className=
"text-foreground"
>
{
row
.
providerUid
}
</
span
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
{
row
.
title
?
<
span
className=
"text-sm text-muted-foreground"
>
{
row
.
title
}
</
span
>
:
null
}
<
span
className=
"text-sm font-medium text-foreground"
>
{
row
.
title
}
</
span
>
<
Badge
variant=
"secondary"
className=
"rounded-full px-2.5 py-0.5"
>
{
row
.
typeLabel
}
</
Badge
>
</
div
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
InfoChip
label=
{
t
(
"setting.sso.provider-uid"
)
}
value=
{
row
.
providerUid
}
/>
</
div
>
</
div
>
</
div
>
),
),
},
},
{
{
key
:
"typeLabel"
,
key
:
"summaryItems"
,
header
:
t
(
"common.type"
),
header
:
t
(
"setting.sso.configuration"
),
render
:
(
_
,
row
:
IdentityProviderRow
)
=>
<
span
className=
"text-muted-foreground"
>
{
row
.
typeLabel
}
</
span
>,
render
:
(
_
,
row
:
IdentityProviderRow
)
=>
(
<
div
className=
"flex min-w-[24rem] flex-col gap-2"
>
<
p
className=
"text-xs text-muted-foreground"
>
{
t
(
"setting.sso.configuration-summary-description"
)
}
</
p
>
<
div
className=
"flex flex-wrap gap-2"
>
{
row
.
summaryItems
.
map
((
item
)
=>
(
<
InfoChip
key=
{
item
.
key
}
label=
{
item
.
label
}
value=
{
item
.
value
}
tooltip=
{
item
.
tooltip
}
/>
))
}
</
div
>
</
div
>
),
},
},
{
{
key
:
"actions"
,
key
:
"actions"
,
header
:
""
,
header
:
""
,
className
:
"text-right"
,
className
:
"
w-px
text-right"
,
render
:
(
_
,
row
:
IdentityProviderRow
)
=>
(
render
:
(
_
,
row
:
IdentityProviderRow
)
=>
(
<
DropdownMenu
>
<
DropdownMenu
>
<
DropdownMenuTrigger
asChild
>
<
DropdownMenuTrigger
asChild
>
...
...
web/src/components/Settings/SettingGroup.tsx
View file @
ee179985
...
@@ -8,19 +8,25 @@ interface SettingGroupProps {
...
@@ -8,19 +8,25 @@ interface SettingGroupProps {
children
:
React
.
ReactNode
;
children
:
React
.
ReactNode
;
className
?:
string
;
className
?:
string
;
showSeparator
?:
boolean
;
showSeparator
?:
boolean
;
actions
?:
React
.
ReactNode
;
}
}
const
SettingGroup
:
React
.
FC
<
SettingGroupProps
>
=
({
title
,
description
,
children
,
className
,
showSeparator
=
false
})
=>
{
const
SettingGroup
:
React
.
FC
<
SettingGroupProps
>
=
({
title
,
description
,
children
,
className
,
showSeparator
=
false
,
actions
})
=>
{
return
(
return
(
<>
<>
{
showSeparator
&&
<
Separator
className=
"my-2"
/>
}
{
showSeparator
&&
<
Separator
className=
"my-2"
/>
}
<
div
className=
{
cn
(
"flex flex-col gap-3"
,
className
)
}
>
<
div
className=
{
cn
(
"flex flex-col gap-3"
,
className
)
}
>
{
(
title
||
description
||
actions
)
&&
(
<
div
className=
"flex items-start justify-between gap-3"
>
{
(
title
||
description
)
&&
(
{
(
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
>
}
{
title
&&
<
h4
className=
"text-sm font-medium text-muted-foreground"
>
{
title
}
</
h4
>
}
{
description
&&
<
p
className=
"text-xs text-muted-foreground"
>
{
description
}
</
p
>
}
{
description
&&
<
p
className=
"text-xs text-muted-foreground"
>
{
description
}
</
p
>
}
</
div
>
</
div
>
)
}
)
}
{
actions
?
<
div
className=
"ml-auto shrink-0"
>
{
actions
}
</
div
>
:
null
}
</
div
>
)
}
<
div
className=
"flex flex-col gap-3"
>
{
children
}
</
div
>
<
div
className=
"flex flex-col gap-3"
>
{
children
}
</
div
>
</
div
>
</
div
>
</>
</>
...
...
web/src/components/Settings/SettingTable.tsx
View file @
ee179985
...
@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils";
...
@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils";
interface
SettingTableColumn
<
T
=
Record
<
string
,
unknown
>>
{
interface
SettingTableColumn
<
T
=
Record
<
string
,
unknown
>>
{
key
:
string
;
key
:
string
;
header
:
string
;
header
:
React
.
ReactNode
;
className
?:
string
;
className
?:
string
;
render
?:
(
value
:
T
[
keyof
T
],
row
:
T
)
=>
React
.
ReactNode
;
render
?:
(
value
:
T
[
keyof
T
],
row
:
T
)
=>
React
.
ReactNode
;
}
}
...
@@ -14,6 +14,7 @@ interface SettingTableProps<T = Record<string, unknown>> {
...
@@ -14,6 +14,7 @@ interface SettingTableProps<T = Record<string, unknown>> {
emptyMessage
?:
string
;
emptyMessage
?:
string
;
className
?:
string
;
className
?:
string
;
getRowKey
?:
(
row
:
T
,
index
:
number
)
=>
string
;
getRowKey
?:
(
row
:
T
,
index
:
number
)
=>
string
;
variant
?:
"default"
|
"info-flow"
;
}
}
const
SettingTable
=
<
T
extends
Record
<
string
,
unknown
>
>
(
{
const
SettingTable
=
<
T
extends
Record
<
string
,
unknown
>
>
(
{
...
@@ -22,6 +23,7 @@ const SettingTable = <T extends Record<string, unknown>>({
...
@@ -22,6 +23,7 @@ const SettingTable = <T extends Record<string, unknown>>({
emptyMessage
=
"No data"
,
emptyMessage
=
"No data"
,
className
,
className
,
getRowKey
,
getRowKey
,
variant
=
"default"
,
}
: SettingTableProps
<
T
>
) =
>
{
}
: SettingTableProps
<
T
>
) =
>
{
return
(
return
(
<
div
className=
{
cn
(
"w-full overflow-x-auto"
,
className
)
}
>
<
div
className=
{
cn
(
"w-full overflow-x-auto"
,
className
)
}
>
...
@@ -52,7 +54,14 @@ const SettingTable = <T extends Record<string, unknown>>({
...
@@ -52,7 +54,14 @@ const SettingTable = <T extends Record<string, unknown>>({
const
value
=
row
[
column
.
key
as
keyof
T
]
as
T
[
keyof
T
];
const
value
=
row
[
column
.
key
as
keyof
T
]
as
T
[
keyof
T
];
const
content
=
column
.
render
?
column
.
render
(
value
,
row
)
:
(
value
as
React
.
ReactNode
);
const
content
=
column
.
render
?
column
.
render
(
value
,
row
)
:
(
value
as
React
.
ReactNode
);
return
(
return
(
<
td
key=
{
column
.
key
}
className=
{
cn
(
"whitespace-nowrap px-3 py-2 text-sm text-muted-foreground"
,
column
.
className
)
}
>
<
td
key=
{
column
.
key
}
className=
{
cn
(
"px-3 text-sm text-muted-foreground"
,
variant
===
"default"
?
"whitespace-nowrap py-2"
:
"py-3 align-top whitespace-normal"
,
column
.
className
,
)
}
>
{
content
}
{
content
}
</
td
>
</
td
>
);
);
...
...
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 @@
...
@@ -89,6 +89,7 @@
"delete"
:
"Delete"
,
"delete"
:
"Delete"
,
"description"
:
"Description"
,
"description"
:
"Description"
,
"edit"
:
"Edit"
,
"edit"
:
"Edit"
,
"empty-placeholder"
:
"Empty"
,
"email"
:
"Email"
,
"email"
:
"Email"
,
"expand"
:
"Expand"
,
"expand"
:
"Expand"
,
"explore"
:
"Explore"
,
"explore"
:
"Explore"
,
...
@@ -145,6 +146,7 @@
...
@@ -145,6 +146,7 @@
"today"
:
"Today"
,
"today"
:
"Today"
,
"tree-mode"
:
"Tree mode"
,
"tree-mode"
:
"Tree mode"
,
"type"
:
"Type"
,
"type"
:
"Type"
,
"unlink"
:
"Unlink"
,
"unpin"
:
"Unpin"
,
"unpin"
:
"Unpin"
,
"update"
:
"Update"
,
"update"
:
"Update"
,
"upload"
:
"Upload"
,
"upload"
:
"Upload"
,
...
@@ -386,7 +388,10 @@
...
@@ -386,7 +388,10 @@
},
},
"account"
:
{
"account"
:
{
"change-password"
:
"Change password"
,
"change-password"
:
"Change password"
,
"danger-area"
:
"Danger area"
,
"danger-area-description"
:
"Irreversible account actions live here. Review them carefully before continuing."
,
"delete-account"
:
"Delete account"
,
"delete-account"
:
"Delete account"
,
"delete-account-description"
:
"Permanently remove this account and all associated access from this instance. This action cannot be undone."
,
"email-note"
:
"Optional"
,
"email-note"
:
"Optional"
,
"export-memos"
:
"Export Memos"
,
"export-memos"
:
"Export Memos"
,
"nickname-note"
:
"Displayed in the banner"
,
"nickname-note"
:
"Displayed in the banner"
,
...
@@ -436,8 +441,10 @@
...
@@ -436,8 +441,10 @@
"week-start-day"
:
"Week start day"
"week-start-day"
:
"Week start day"
},
},
"member"
:
{
"member"
:
{
"active"
:
"Active"
,
"admin"
:
"Admin"
,
"admin"
:
"Admin"
,
"archive-member"
:
"Archive member"
,
"archive-member"
:
"Archive member"
,
"archived"
:
"Archived"
,
"archive-success"
:
"{{username}} archived successfully"
,
"archive-success"
:
"{{username}} archived successfully"
,
"archive-warning"
:
"Are you sure you want to archive {{username}}?"
,
"archive-warning"
:
"Are you sure you want to archive {{username}}?"
,
"archive-warning-description"
:
"Archiving disables the account. You can restore or delete it later."
,
"archive-warning-description"
:
"Archiving disables the account. You can restore or delete it later."
,
...
@@ -448,7 +455,9 @@
...
@@ -448,7 +455,9 @@
"delete-warning-description"
:
"THIS ACTION IS IRREVERSIBLE"
,
"delete-warning-description"
:
"THIS ACTION IS IRREVERSIBLE"
,
"label"
:
"Member"
,
"label"
:
"Member"
,
"list-title"
:
"Member list"
,
"list-title"
:
"Member list"
,
"member-column"
:
"Member"
,
"restore-success"
:
"{{username}} restored successfully"
,
"restore-success"
:
"{{username}} restored successfully"
,
"summary-column"
:
"Summary"
,
"user"
:
"User"
,
"user"
:
"User"
,
"no-members-found"
:
"No members found"
"no-members-found"
:
"No members found"
},
},
...
@@ -476,28 +485,68 @@
...
@@ -476,28 +485,68 @@
"delete-success"
:
"Shortcut `{{title}}` deleted successfully"
"delete-success"
:
"Shortcut `{{title}}` deleted successfully"
},
},
"sso"
:
{
"sso"
:
{
"account"
:
"Account"
,
"accounts-description"
:
"Review each identity provider, see the current link state, and connect or disconnect external identities from this account."
,
"accounts-title"
:
"SSO Accounts"
,
"authorization-endpoint"
:
"Authorization endpoint"
,
"authorization-endpoint"
:
"Authorization endpoint"
,
"avatar-url"
:
"Avatar URL"
,
"basic-settings"
:
"Basic settings"
,
"basic-settings-description"
:
"Set the provider identity, display name, and optional identifier rules before filling in the OAuth details."
,
"client-id"
:
"Client ID"
,
"client-id"
:
"Client ID"
,
"client-secret"
:
"Client secret"
,
"client-secret"
:
"Client secret"
,
"client-secret-optional-description"
:
"Leave blank to keep the existing client secret unchanged."
,
"configuration"
:
"Configuration"
,
"configuration-summary-description"
:
"Show the essentials that help identify and audit a provider without exposing the full configuration inline."
,
"confirm-delete"
:
"Are you sure you want to delete `{{name}}` SSO configuration? THIS ACTION IS IRREVERSIBLE"
,
"confirm-delete"
:
"Are you sure you want to delete `{{name}}` SSO configuration? THIS ACTION IS IRREVERSIBLE"
,
"create-sso"
:
"Create SSO"
,
"create-sso"
:
"Create SSO"
,
"create-sso-description"
:
"Create a new identity provider for administrator-managed single sign-on."
,
"custom"
:
"Custom"
,
"custom"
:
"Custom"
,
"delete-sso"
:
"Confirm delete"
,
"delete-sso"
:
"Confirm delete"
,
"disabled-password-login-warning"
:
"Password-login is disabled, be extra careful when removing identity providers"
,
"disabled-password-login-warning"
:
"Password-login is disabled, be extra careful when removing identity providers"
,
"endpoints"
:
"Endpoints"
,
"display-name"
:
"Display Name"
,
"display-name"
:
"Display Name"
,
"extern-uid"
:
"External ID"
,
"extern-uid-description"
:
"This is the provider-side identity currently linked to your account."
,
"filter-disabled"
:
"Disabled"
,
"identifier"
:
"Identifier"
,
"identifier"
:
"Identifier"
,
"identifier-filter"
:
"Identifier Filter"
,
"identifier-filter"
:
"Identifier Filter"
,
"identifier-filter-description"
:
"Optional regex used to allow or restrict which external identifiers may sign in."
,
"field-mapping"
:
"Claims mapping"
,
"field-mapping-description"
:
"Map the upstream profile fields used to identify the user and prefill profile data."
,
"field-mapping-identifier-description"
:
"Used as the stable external identifier when signing in or linking an account."
,
"linked"
:
"Linked"
,
"label"
:
"SSO"
,
"label"
:
"SSO"
,
"mapping"
:
"Mapping"
,
"mapping-avatar-short"
:
"avatar"
,
"mapping-display-name-short"
:
"name"
,
"mapping-email-short"
:
"email"
,
"mapping-identifier-short"
:
"id"
,
"mapping-none"
:
"Not configured"
,
"no-sso-found"
:
"No SSO found."
,
"no-sso-found"
:
"No SSO found."
,
"not-linked"
:
"Not linked"
,
"not-linked-description"
:
"No external identity is linked yet. You can connect this provider to sign in with it later."
,
"oauth-configuration"
:
"OAuth configuration"
,
"oauth-configuration-description"
:
"Fill in the OAuth client credentials and the provider endpoints used during sign-in."
,
"provider"
:
"Provider"
,
"provider-id"
:
"Provider ID"
,
"provider-id-description"
:
"Lowercase letters, numbers, and hyphens only. This value becomes part of the provider resource name."
,
"provider-uid"
:
"UID"
,
"redirect-url"
:
"Redirect URL"
,
"redirect-url"
:
"Redirect URL"
,
"redirect-url-description"
:
"Register this callback URL with your identity provider so the authorization code flow can complete."
,
"scope-count_one"
:
"{{count}} scope"
,
"scope-count_other"
:
"{{count}} scopes"
,
"scopes"
:
"Scopes"
,
"scopes"
:
"Scopes"
,
"scopes-description"
:
"Separate scopes with spaces. Most providers only need a small set such as profile or email access."
,
"single-sign-on"
:
"Configuring Single Sign-On (SSO) for Authentication"
,
"single-sign-on"
:
"Configuring Single Sign-On (SSO) for Authentication"
,
"sso-created"
:
"SSO {{name}} created"
,
"sso-created"
:
"SSO {{name}} created"
,
"sso-list"
:
"SSO List"
,
"sso-list"
:
"SSO List"
,
"sso-updated"
:
"SSO {{name}} updated"
,
"sso-updated"
:
"SSO {{name}} updated"
,
"template"
:
"Template"
,
"template"
:
"Template"
,
"template-description"
:
"Start from a provider preset, then adjust the credentials and endpoints for your tenant."
,
"unlink-success"
:
"Unlinked {{name}}."
,
"token-endpoint"
:
"Token endpoint"
,
"token-endpoint"
:
"Token endpoint"
,
"update-sso"
:
"Update SSO"
,
"update-sso"
:
"Update SSO"
,
"update-sso-description"
:
"Review the provider configuration, then save the fields that should change."
,
"user-endpoint"
:
"User endpoint"
"user-endpoint"
:
"User endpoint"
},
},
"storage"
:
{
"storage"
:
{
...
...
web/src/locales/zh-Hans.json
View file @
ee179985
...
@@ -56,6 +56,7 @@
...
@@ -56,6 +56,7 @@
"delete"
:
"删除"
,
"delete"
:
"删除"
,
"description"
:
"说明"
,
"description"
:
"说明"
,
"edit"
:
"编辑"
,
"edit"
:
"编辑"
,
"empty-placeholder"
:
"空"
,
"email"
:
"邮箱"
,
"email"
:
"邮箱"
,
"expand"
:
"展开"
,
"expand"
:
"展开"
,
"explore"
:
"发现"
,
"explore"
:
"发现"
,
...
@@ -112,6 +113,7 @@
...
@@ -112,6 +113,7 @@
"today"
:
"今天"
,
"today"
:
"今天"
,
"tree-mode"
:
"树模式"
,
"tree-mode"
:
"树模式"
,
"type"
:
"类型"
,
"type"
:
"类型"
,
"unlink"
:
"解绑"
,
"unpin"
:
"取消置顶"
,
"unpin"
:
"取消置顶"
,
"update"
:
"更新"
,
"update"
:
"更新"
,
"upload"
:
"上传"
,
"upload"
:
"上传"
,
...
@@ -325,8 +327,10 @@
...
@@ -325,8 +327,10 @@
},
},
"setting"
:
{
"setting"
:
{
"member"
:
{
"member"
:
{
"active"
:
"启用中"
,
"admin"
:
"管理员"
,
"admin"
:
"管理员"
,
"archive-member"
:
"归档成员"
,
"archive-member"
:
"归档成员"
,
"archived"
:
"已归档"
,
"archive-success"
:
"{{username}} 归档成功"
,
"archive-success"
:
"{{username}} 归档成功"
,
"archive-warning"
:
"您确定要归档 {{username}} 吗?"
,
"archive-warning"
:
"您确定要归档 {{username}} 吗?"
,
"archive-warning-description"
:
"归档会禁用用户。您可以稍后恢复或删除它。"
,
"archive-warning-description"
:
"归档会禁用用户。您可以稍后恢复或删除它。"
,
...
@@ -339,6 +343,8 @@
...
@@ -339,6 +343,8 @@
"user"
:
"普通用户"
,
"user"
:
"普通用户"
,
"label"
:
"成员"
,
"label"
:
"成员"
,
"list-title"
:
"成员列表"
,
"list-title"
:
"成员列表"
,
"member-column"
:
"成员"
,
"summary-column"
:
"摘要"
,
"no-members-found"
:
"没有找到会员"
"no-members-found"
:
"没有找到会员"
},
},
"my-account"
:
{
"my-account"
:
{
...
@@ -382,29 +388,70 @@
...
@@ -382,29 +388,70 @@
"delete-success"
:
"捷径 `{{title}}` 删除成功"
"delete-success"
:
"捷径 `{{title}}` 删除成功"
},
},
"sso"
:
{
"sso"
:
{
"account"
:
"账户"
,
"accounts-description"
:
"查看每个身份提供程序的当前绑定状态,并为当前账户连接或解绑外部身份。"
,
"accounts-title"
:
"SSO 账户"
,
"authorization-endpoint"
:
"授权端点(Authorization Endpoint)"
,
"authorization-endpoint"
:
"授权端点(Authorization Endpoint)"
,
"avatar-url"
:
"头像链接(Avatar URL)"
,
"basic-settings"
:
"基础信息"
,
"basic-settings-description"
:
"先设置 provider 的标识、展示名称和可选的标识符规则,再补充 OAuth 配置。"
,
"client-id"
:
"客户端ID(Client ID)"
,
"client-id"
:
"客户端ID(Client ID)"
,
"client-secret"
:
"客户端密钥(Client Secret)"
,
"client-secret"
:
"客户端密钥(Client Secret)"
,
"client-secret-optional-description"
:
"留空则保留现有的客户端密钥,不会覆盖。"
,
"configuration"
:
"配置摘要"
,
"configuration-summary-description"
:
"这里只展示便于识别和审查 provider 的关键信息,完整配置仍然通过编辑入口查看。"
,
"confirm-delete"
:
"您确定要删除“{{name}}”单点登录配置吗?(此操作不可逆)"
,
"confirm-delete"
:
"您确定要删除“{{name}}”单点登录配置吗?(此操作不可逆)"
,
"create-sso"
:
"创建单点登录"
,
"create-sso"
:
"创建单点登录"
,
"create-sso-description"
:
"为管理员管理的单点登录创建新的身份提供程序。"
,
"custom"
:
"自定义"
,
"custom"
:
"自定义"
,
"delete-sso"
:
"确认删除"
,
"delete-sso"
:
"确认删除"
,
"disabled-password-login-warning"
:
"密码登录已被禁用,删除身份提供程序时要格外小心"
,
"disabled-password-login-warning"
:
"密码登录已被禁用,删除身份提供程序时要格外小心"
,
"endpoints"
:
"端点"
,
"display-name"
:
"显示名称"
,
"display-name"
:
"显示名称"
,
"extern-uid"
:
"外部 ID"
,
"extern-uid-description"
:
"这是当前绑定到您账户上的身份提供程序侧标识。"
,
"filter-disabled"
:
"未启用"
,
"field-mapping"
:
"字段映射"
,
"field-mapping-description"
:
"映射上游用户信息字段,用于识别用户并预填展示资料。"
,
"field-mapping-identifier-description"
:
"这是登录或绑定账户时使用的稳定外部标识字段。"
,
"identifier"
:
"标识符(Identifier)"
,
"identifier"
:
"标识符(Identifier)"
,
"identifier-filter"
:
"标识符过滤器(Identifier Filter)"
,
"identifier-filter"
:
"标识符过滤器(Identifier Filter)"
,
"identifier-filter-description"
:
"可选正则表达式,用来限制或允许哪些外部标识符可以登录。"
,
"linked"
:
"已绑定"
,
"no-sso-found"
:
"没有 SSO 配置"
,
"no-sso-found"
:
"没有 SSO 配置"
,
"no-scopes"
:
"无 Scopes"
,
"not-linked"
:
"未绑定"
,
"oauth-configuration"
:
"OAuth 配置"
,
"oauth-configuration-description"
:
"填写 OAuth 客户端凭据,以及登录流程中使用的 provider 端点。"
,
"provider"
:
"提供程序"
,
"provider-id"
:
"Provider ID"
,
"provider-id-description"
:
"仅支持小写字母、数字和连字符。该值会成为 provider 资源名的一部分。"
,
"provider-uid"
:
"UID"
,
"redirect-url"
:
"重定向链接"
,
"redirect-url"
:
"重定向链接"
,
"redirect-url-description"
:
"将这个回调地址注册到身份提供程序中,授权码流程才能正确返回。"
,
"scopes"
:
"范围"
,
"scopes"
:
"范围"
,
"scopes-description"
:
"使用空格分隔多个 scope。大多数 provider 只需要 profile、email 这类基础 scope。"
,
"single-sign-on"
:
"配置单点登录(SSO)进行身份验证"
,
"single-sign-on"
:
"配置单点登录(SSO)进行身份验证"
,
"sso-created"
:
"单点登录 {{name}} 已创建"
,
"sso-created"
:
"单点登录 {{name}} 已创建"
,
"sso-list"
:
"单点登录列表"
,
"sso-list"
:
"单点登录列表"
,
"sso-updated"
:
"单点登录 {{name}} 已更新"
,
"sso-updated"
:
"单点登录 {{name}} 已更新"
,
"template"
:
"模板"
,
"template"
:
"模板"
,
"template-description"
:
"先选择一个 provider 预设,再按你的租户信息调整凭据和端点。"
,
"mapping"
:
"映射"
,
"mapping-avatar-short"
:
"avatar"
,
"mapping-display-name-short"
:
"name"
,
"mapping-email-short"
:
"email"
,
"mapping-identifier-short"
:
"id"
,
"mapping-none"
:
"未配置"
,
"unlink-success"
:
"已解绑 {{name}}。"
,
"label"
:
"单点登录"
,
"not-linked-description"
:
"当前还没有绑定外部身份。绑定后即可使用这个提供程序登录。"
,
"scope-count_one"
:
"{{count}} 个 scope"
,
"scope-count_other"
:
"{{count}} 个 scopes"
,
"token-endpoint"
:
"令牌端点(Token Endpoint)"
,
"token-endpoint"
:
"令牌端点(Token Endpoint)"
,
"update-sso"
:
"更新单点登录"
,
"update-sso"
:
"更新单点登录"
,
"u
ser-endpoint"
:
"用户端点(User Endpoint)
"
,
"u
pdate-sso-description"
:
"检查当前 provider 配置,只保存你需要变更的字段。
"
,
"
label"
:
"单点登录
"
"
user-endpoint"
:
"用户端点(User Endpoint)
"
},
},
"storage"
:
{
"storage"
:
{
"accesskey"
:
"访问密钥(Access key)"
,
"accesskey"
:
"访问密钥(Access key)"
,
...
@@ -493,7 +540,10 @@
...
@@ -493,7 +540,10 @@
},
},
"account"
:
{
"account"
:
{
"change-password"
:
"修改密码"
,
"change-password"
:
"修改密码"
,
"danger-area"
:
"危险操作区"
,
"danger-area-description"
:
"不可逆的账号操作统一放在这里,执行前请再次确认影响。"
,
"delete-account"
:
"删除账号"
,
"delete-account"
:
"删除账号"
,
"delete-account-description"
:
"永久删除当前账号,并移除它在这个实例中的全部访问权限。此操作无法撤销。"
,
"email-note"
:
"可选"
,
"email-note"
:
"可选"
,
"export-memos"
:
"导出备忘录"
,
"export-memos"
:
"导出备忘录"
,
"nickname-note"
:
"显示在横幅中"
,
"nickname-note"
:
"显示在横幅中"
,
...
...
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