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

feat: redesign account and SSO management (#5886)

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