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

feat: redesign account and SSO management (#5886)

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