@svadmin/ui 0.46.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/components/AboutSettings.svelte +21 -96
  2. package/dist/components/AdminApp.svelte +20 -0
  3. package/dist/components/AdminApp.svelte.d.ts +5 -1
  4. package/dist/components/ApiSettings.svelte +153 -246
  5. package/dist/components/AppearanceSettings.svelte +36 -165
  6. package/dist/components/AutoForm.svelte +21 -3
  7. package/dist/components/DetailDrawer.svelte +74 -0
  8. package/dist/components/DetailDrawer.svelte.d.ts +16 -0
  9. package/dist/components/FieldRenderer.svelte +70 -2
  10. package/dist/components/FieldRenderer.svelte.d.ts +2 -0
  11. package/dist/components/IntegrationsSettings.svelte +22 -61
  12. package/dist/components/Layout.svelte +15 -1
  13. package/dist/components/NotificationsSettings.svelte +22 -103
  14. package/dist/components/PasswordInput.svelte +4 -0
  15. package/dist/components/PasswordInput.svelte.d.ts +1 -0
  16. package/dist/components/PermissionMatrix.svelte +17 -8
  17. package/dist/components/RowActions.svelte +154 -0
  18. package/dist/components/RowActions.svelte.d.ts +26 -0
  19. package/dist/components/SecuritySettings.svelte +165 -140
  20. package/dist/components/SecuritySettings.svelte.d.ts +1 -2
  21. package/dist/components/SettingsPage.svelte +12 -2
  22. package/dist/components/account/CompanyProfilePage.svelte +10 -4
  23. package/dist/components/account/SettingsEnterprisePage.svelte +227 -23
  24. package/dist/components/account/SettingsSidebarPage.svelte +12 -1
  25. package/dist/components/account/UserProfilePage.svelte +5 -4
  26. package/dist/components/content/ApiKeyList.svelte +30 -5
  27. package/dist/components/content/ApiKeyList.svelte.d.ts +3 -0
  28. package/dist/components/content/WorkspaceLayout.svelte +7 -4
  29. package/dist/components/content/WorkspaceLayout.svelte.d.ts +1 -0
  30. package/dist/components/ui/sheet/sheet.svelte +122 -10
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.js +2 -0
  33. package/package.json +5 -2
@@ -56,7 +56,7 @@
56
56
 
57
57
  <div class="h-full flex flex-col pt-2">
58
58
  <!-- Header Title -->
59
- <div class="mb-6 flex items-center justify-between">
59
+ <div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
60
60
  <div>
61
61
  <h1 class="text-2xl font-bold text-foreground flex items-center gap-2">
62
62
  <Shield class="w-6 h-6 text-primary" />
@@ -78,10 +78,19 @@
78
78
  {/if}
79
79
  </div>
80
80
 
81
- <div class="flex flex-col md:flex-row gap-6 flex-1 min-h-[500px]">
81
+ <div class="flex flex-col lg:flex-row gap-6 flex-1 min-h-[500px]">
82
+ <div class="w-full rounded-lg border border-border bg-card p-4 shadow-sm lg:hidden">
83
+ <label for="permission-role-select" class="mb-2 block text-sm font-medium text-foreground">{i18n.t('permissions.roles') || 'Roles'}</label>
84
+ <select id="permission-role-select" bind:value={selectedRole} class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" disabled={roles.length === 0}>
85
+ {#each roles as role (role.code)}<option value={role.code}>{role.name}</option>{/each}
86
+ </select>
87
+ {#if roles.length === 0}<p class="mt-2 text-sm text-muted-foreground">{i18n.t('permissions.noRoles') || 'No roles available'}</p>{/if}
88
+ {#if sidebarExtra}<div class="mt-4 border-t border-border pt-4">{@render sidebarExtra()}</div>{/if}
89
+ </div>
90
+
82
91
  <!-- Left: Role Selector Sidebar -->
83
- <div class="w-full md:w-64 flex-shrink-0 bg-card rounded-lg shadow-sm border border-border p-4 flex flex-col">
84
- <h2 class="text-sm font-semibold text-muted-foregroundr mb-4 px-2">{i18n.t('permissions.roles') || 'Roles'}</h2>
92
+ <div class="hidden w-full flex-shrink-0 flex-col rounded-lg border border-border bg-card p-4 shadow-sm lg:flex lg:w-64">
93
+ <h2 class="mb-4 px-2 text-sm font-semibold text-muted-foreground">{i18n.t('permissions.roles') || 'Roles'}</h2>
85
94
  <div class="space-y-1 flex-1 overflow-y-auto">
86
95
  {#each roles as role, _i (_i)}
87
96
  <button
@@ -89,7 +98,7 @@
89
98
  class="w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-200 {selectedRole === role.code ? 'bg-primary/10 text-primary font-medium shadow-sm ring-1 ring-primary/20' : 'text-foreground hover:bg-muted'}"
90
99
  >
91
100
  {role.name}
92
- <div class="text-[10px] mt-0.5 opacity-60 font-monoer">{role.code}</div>
101
+ <div class="mt-0.5 font-mono text-[10px] opacity-60">{role.code}</div>
93
102
  </button>
94
103
  {/each}
95
104
  {#if roles.length === 0}
@@ -108,8 +117,8 @@
108
117
  <!-- Right: Matrix Grid -->
109
118
  <div class="flex-1 bg-card rounded-lg shadow-sm border border-border overflow-hidden flex flex-col">
110
119
  <!-- Matrix Header -->
111
- <div class="p-5 border-b border-border bg-muted/30 flex items-center justify-between">
112
- <div class="flex items-center gap-4">
120
+ <div class="p-5 border-b border-border bg-muted/30 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
121
+ <div class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
113
122
  <div>
114
123
  <h2 class="text-lg font-bold text-foreground">{i18n.t('permissions.currentRole') || 'Current role'}:
115
124
  <span class="text-primary">{roles.find(r => r.code === selectedRole)?.name || (i18n.t('permissions.noneSelected') || 'None selected')}</span>
@@ -156,7 +165,7 @@
156
165
  <!-- Section Grouping Header -->
157
166
  {#if resource.section && (i === 0 || resource.section !== resources[i-1].section)}
158
167
  <tr class="bg-primary/5">
159
- <td colspan="{actions.length + 1}" class="px-4 py-2 text-xs font-bold text-primaryr">{resource.section}</td>
168
+ <td colspan="{actions.length + 1}" class="px-4 py-2 text-xs font-bold text-primary">{resource.section}</td>
160
169
  </tr>
161
170
  {/if}
162
171
 
@@ -0,0 +1,154 @@
1
+ <script lang="ts">
2
+ import { Ellipsis } from '@lucide/svelte';
3
+ import type { Component, Snippet } from 'svelte';
4
+ import type { HTMLAttributes } from 'svelte/elements';
5
+ import { cn, type WithElementRef } from '../utils.js';
6
+ import * as DropdownMenu from './ui/dropdown-menu/index.js';
7
+ import { Button } from './ui/button/index.js';
8
+
9
+ export type RowActionItem = {
10
+ label: string;
11
+ icon?: Component<{ class?: string }>;
12
+ href?: string;
13
+ onclick?: (e: MouseEvent) => void;
14
+ variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary';
15
+ disabled?: boolean;
16
+ title?: string;
17
+ danger?: boolean;
18
+ hidden?: boolean;
19
+ };
20
+
21
+ type Props = WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
22
+ actions?: RowActionItem[];
23
+ maxVisible?: number;
24
+ moreLabel?: string;
25
+ children?: Snippet;
26
+ moreContent?: Snippet;
27
+ };
28
+
29
+ let {
30
+ actions = [],
31
+ maxVisible = 2,
32
+ moreLabel = 'More actions',
33
+ ref = $bindable(null),
34
+ class: className,
35
+ children,
36
+ moreContent,
37
+ ...restProps
38
+ }: Props = $props();
39
+
40
+ let overflowOpen = $state(false);
41
+ const availableActions = $derived(actions.filter((action) => !action.hidden));
42
+ const visibleLimit = $derived(
43
+ Number.isFinite(maxVisible)
44
+ ? Math.max(0, Math.floor(maxVisible))
45
+ : availableActions.length
46
+ );
47
+ const visibleActions = $derived(availableActions.slice(0, visibleLimit));
48
+ const overflowActions = $derived(availableActions.slice(visibleLimit));
49
+
50
+ function resolvedVariant(action: RowActionItem): RowActionItem['variant'] {
51
+ return action.variant ?? (action.danger ? 'destructive' : 'ghost');
52
+ }
53
+
54
+ function handleOverflowAction(action: RowActionItem, event: MouseEvent): void {
55
+ if (action.disabled) {
56
+ event.preventDefault();
57
+ return;
58
+ }
59
+ overflowOpen = false;
60
+ action.onclick?.(event);
61
+ }
62
+ </script>
63
+
64
+ <div
65
+ bind:this={ref}
66
+ data-slot="row-actions"
67
+ class={cn("inline-flex items-center gap-1.5", className)}
68
+ {...restProps}
69
+ >
70
+ {#if children}
71
+ {@render children()}
72
+ {/if}
73
+
74
+ {#each visibleActions as action (action)}
75
+ {#if action.href}
76
+ <Button
77
+ variant={resolvedVariant(action)}
78
+ size="sm"
79
+ href={action.href}
80
+ disabled={action.disabled}
81
+ title={action.title || action.label}
82
+ class="h-8 px-2 text-xs"
83
+ >
84
+ {#if action.icon}
85
+ {@const Icon = action.icon}
86
+ <Icon class="mr-1 size-3.5" />
87
+ {/if}
88
+ {action.label}
89
+ </Button>
90
+ {:else}
91
+ <Button
92
+ variant={resolvedVariant(action)}
93
+ size="sm"
94
+ onclick={action.onclick}
95
+ disabled={action.disabled}
96
+ title={action.title || action.label}
97
+ class="h-8 px-2 text-xs"
98
+ >
99
+ {#if action.icon}
100
+ {@const Icon = action.icon}
101
+ <Icon class="mr-1 size-3.5" />
102
+ {/if}
103
+ {action.label}
104
+ </Button>
105
+ {/if}
106
+ {/each}
107
+
108
+ {#if overflowActions.length > 0 || moreContent}
109
+ <DropdownMenu.Root bind:open={overflowOpen}>
110
+ <DropdownMenu.Trigger>
111
+ {#snippet child({ props })}
112
+ <Button {...props} variant="ghost" size="icon-sm" title={moreLabel} aria-label={moreLabel}>
113
+ <Ellipsis class="size-4" />
114
+ </Button>
115
+ {/snippet}
116
+ </DropdownMenu.Trigger>
117
+ <DropdownMenu.Content align="end" class="min-w-[8rem]">
118
+ {#each overflowActions as action (action)}
119
+ {#if action.href}
120
+ <Button
121
+ href={action.href}
122
+ variant={resolvedVariant(action)}
123
+ size="sm"
124
+ disabled={action.disabled}
125
+ onclick={(event) => handleOverflowAction(action, event)}
126
+ class="h-auto w-full justify-start rounded-md px-2 py-1.5 text-sm font-normal"
127
+ >
128
+ {#if action.icon}
129
+ {@const Icon = action.icon}
130
+ <Icon class="size-3.5" />
131
+ {/if}
132
+ {action.label}
133
+ </Button>
134
+ {:else}
135
+ <DropdownMenu.Item
136
+ onclick={(event) => handleOverflowAction(action, event)}
137
+ disabled={action.disabled}
138
+ destructive={action.danger || action.variant === 'destructive'}
139
+ >
140
+ {#if action.icon}
141
+ {@const Icon = action.icon}
142
+ <Icon class="size-3.5" />
143
+ {/if}
144
+ {action.label}
145
+ </DropdownMenu.Item>
146
+ {/if}
147
+ {/each}
148
+ {#if moreContent}
149
+ {@render moreContent()}
150
+ {/if}
151
+ </DropdownMenu.Content>
152
+ </DropdownMenu.Root>
153
+ {/if}
154
+ </div>
@@ -0,0 +1,26 @@
1
+ import type { Component, Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import { type WithElementRef } from '../utils.js';
4
+ export type RowActionItem = {
5
+ label: string;
6
+ icon?: Component<{
7
+ class?: string;
8
+ }>;
9
+ href?: string;
10
+ onclick?: (e: MouseEvent) => void;
11
+ variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary';
12
+ disabled?: boolean;
13
+ title?: string;
14
+ danger?: boolean;
15
+ hidden?: boolean;
16
+ };
17
+ type Props = WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
18
+ actions?: RowActionItem[];
19
+ maxVisible?: number;
20
+ moreLabel?: string;
21
+ children?: Snippet;
22
+ moreContent?: Snippet;
23
+ };
24
+ declare const RowActions: Component<Props, {}, "ref">;
25
+ type RowActions = ReturnType<typeof RowActions>;
26
+ export default RowActions;
@@ -1,176 +1,201 @@
1
1
  <script lang="ts">
2
- import { useNotification } from '@svadmin/core';
2
+ import { captureAdminContext, notifyWithProvider, type SessionInfo } from '@svadmin/core';
3
3
  import { useTranslation } from '@svadmin/core/i18n';
4
-
5
- import { AlertTriangle, KeyRound, Monitor, Shield, ShieldAlert, ShieldCheck, Smartphone, Trash2 } from '@lucide/svelte';
6
- import type { Component } from 'svelte';
4
+ import { AlertTriangle, Monitor, Smartphone, Trash2 } from '@lucide/svelte';
7
5
  import * as Alert from './ui/alert/index.js';
8
- import * as Card from './ui/card/index.js';
9
6
  import { Button } from './ui/button/index.js';
10
- import { Label } from './ui/label/index.js';
11
7
  import { Switch } from './ui/switch/index.js';
12
8
  import PasswordInput from './PasswordInput.svelte';
9
+ import SettingsGroup from './content/SettingsGroup.svelte';
10
+ import SettingsFieldRow from './content/SettingsFieldRow.svelte';
11
+ import DataState from './content/DataState.svelte';
12
+ import FeedbackNotice from './content/FeedbackNotice.svelte';
13
13
 
14
14
  const i18n = useTranslation();
15
- const notification = useNotification();
16
-
17
- interface SessionInfo {
18
- id: string;
19
- os: string;
20
- browser: string;
21
- ip: string;
22
- lastActive: string;
23
- isCurrent: boolean;
24
- Icon: Component;
25
- }
15
+ const adminContext = captureAdminContext();
16
+ const authProvider = $derived(adminContext.authProvider);
17
+ const sessionProvider = $derived(adminContext.sessionProvider);
18
+ const isZh = $derived(i18n.locale === 'zh-CN');
26
19
 
27
20
  let is2faEnabled = $state(false);
28
- let sessions = $state<SessionInfo[]>([
29
- { id: 'current', os: 'macOS', browser: 'Chrome', ip: '192.168.1.102', lastActive: 'Active now', isCurrent: true, Icon: Monitor },
30
- { id: 'mobile', os: 'iOS', browser: 'Safari', ip: '192.168.1.105', lastActive: '2 hours ago', isCurrent: false, Icon: Smartphone },
31
- { id: 'desktop', os: 'Windows', browser: 'Edge', ip: '10.0.0.4', lastActive: '2 days ago', isCurrent: false, Icon: Monitor },
32
- ]);
33
-
21
+ let sessions = $state.raw<SessionInfo[]>([]);
22
+ let loadingSessions = $state(false);
23
+ let sessionError = $state<string | null>(null);
24
+ let updatingMfa = $state(false);
25
+ let revoking = $state<string | null>(null);
34
26
  let currentPassword = $state('');
35
27
  let newPassword = $state('');
36
28
  let confirmPassword = $state('');
37
29
  let passwordErrorMessage = $state('');
38
- let lastChanged = $state('3 months ago');
30
+ let changingPassword = $state(false);
31
+ let requestId = 0;
39
32
 
40
- function handlePasswordChange(event: SubmitEvent) {
41
- event.preventDefault();
42
- passwordErrorMessage = '';
33
+ function message(error: unknown): string {
34
+ return error instanceof Error ? error.message : String(error);
35
+ }
43
36
 
44
- if (!currentPassword) {
45
- passwordErrorMessage = i18n.t('profile.currentPassword');
46
- return;
47
- }
48
- if (newPassword.length < 8) {
49
- passwordErrorMessage = i18n.t('validation.minLength', { min: 8 });
50
- return;
51
- }
52
- if (newPassword !== confirmPassword) {
53
- passwordErrorMessage = i18n.t('auth.passwordMismatch');
54
- return;
37
+ async function loadSecurity(): Promise<void> {
38
+ const currentRequest = ++requestId;
39
+ const provider = sessionProvider;
40
+ const context = adminContext.enterpriseRequestContext;
41
+ sessions = [];
42
+ is2faEnabled = false;
43
+ sessionError = null;
44
+ loadingSessions = false;
45
+ if (!provider) return;
46
+ loadingSessions = true;
47
+ try {
48
+ const [nextSessions, mfa] = await Promise.all([
49
+ provider.listSessions(context),
50
+ provider.getMfaState?.(context) ?? { enabled: false },
51
+ ]);
52
+ if (currentRequest !== requestId) return;
53
+ sessions = nextSessions;
54
+ is2faEnabled = mfa.enabled;
55
+ } catch (caught) {
56
+ if (currentRequest === requestId) sessionError = message(caught);
57
+ } finally {
58
+ if (currentRequest === requestId) loadingSessions = false;
55
59
  }
60
+ }
56
61
 
57
- notification.success(i18n.t('profile.passwordChanged'), 3000);
58
- lastChanged = 'Just now';
62
+ $effect(() => {
63
+ void authProvider;
64
+ void sessionProvider;
65
+ void adminContext.tenantCacheKey?.__svadminTenant;
59
66
  currentPassword = '';
60
67
  newPassword = '';
61
68
  confirmPassword = '';
69
+ passwordErrorMessage = '';
70
+ changingPassword = false;
71
+ updatingMfa = false;
72
+ revoking = null;
73
+ void loadSecurity();
74
+ return () => { requestId += 1; };
75
+ });
76
+
77
+ async function handleMfaChange(enabled: boolean): Promise<void> {
78
+ const provider = sessionProvider;
79
+ const currentRequest = requestId;
80
+ const context = adminContext.enterpriseRequestContext;
81
+ if (!provider?.setMfaEnabled) return;
82
+ updatingMfa = true;
83
+ try {
84
+ const result = await provider.setMfaEnabled(enabled, context);
85
+ if (currentRequest !== requestId) return;
86
+ is2faEnabled = result.enabled;
87
+ } catch (caught) {
88
+ if (currentRequest !== requestId) return;
89
+ notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
90
+ } finally {
91
+ if (currentRequest === requestId) updatingMfa = false;
92
+ }
93
+ }
94
+
95
+ async function handlePasswordChange(event: SubmitEvent): Promise<void> {
96
+ event.preventDefault();
97
+ passwordErrorMessage = '';
98
+ const provider = authProvider;
99
+ const currentRequest = requestId;
100
+ if (!provider?.updatePassword) return;
101
+ if (!currentPassword) passwordErrorMessage = i18n.t('profile.currentPassword');
102
+ else if (newPassword.length < 8) passwordErrorMessage = i18n.t('validation.minLength', { min: 8 });
103
+ else if (newPassword !== confirmPassword) passwordErrorMessage = i18n.t('auth.passwordMismatch');
104
+ if (passwordErrorMessage) return;
105
+
106
+ changingPassword = true;
107
+ try {
108
+ const result = await provider.updatePassword({ currentPassword, password: newPassword, confirmPassword });
109
+ if (!result.success) throw new Error(result.error?.message ?? (isZh ? '密码修改失败' : 'Password update failed'));
110
+ if (currentRequest !== requestId) return;
111
+ notifyWithProvider({ type: 'success', message: i18n.t('profile.passwordChanged') }, adminContext.notificationProvider);
112
+ currentPassword = '';
113
+ newPassword = '';
114
+ confirmPassword = '';
115
+ } catch (caught) {
116
+ if (currentRequest !== requestId) return;
117
+ passwordErrorMessage = message(caught);
118
+ } finally {
119
+ if (currentRequest === requestId) changingPassword = false;
120
+ }
62
121
  }
63
122
 
64
- function revokeSession(id: string) {
65
- sessions = sessions.filter((session) => session.id !== id);
123
+ async function revokeSession(id: string): Promise<void> {
124
+ const provider = sessionProvider;
125
+ const currentRequest = requestId;
126
+ const context = adminContext.enterpriseRequestContext;
127
+ if (!provider) return;
128
+ revoking = id;
129
+ try {
130
+ const result = await provider.revokeSession(id, context);
131
+ if (!result.success) throw new Error(result.error?.message ?? (isZh ? '会话撤销失败' : 'Session revocation failed'));
132
+ if (currentRequest !== requestId) return;
133
+ sessions = sessions.filter((session) => session.id !== id);
134
+ } catch (caught) {
135
+ if (currentRequest !== requestId) return;
136
+ notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
137
+ } finally {
138
+ if (currentRequest === requestId) revoking = null;
139
+ }
66
140
  }
67
141
 
68
- function revokeAllOthers() {
69
- sessions = sessions.filter((session) => session.isCurrent);
142
+ async function revokeAllOthers(): Promise<void> {
143
+ const provider = sessionProvider;
144
+ const currentRequest = requestId;
145
+ const context = adminContext.enterpriseRequestContext;
146
+ if (!provider) return;
147
+ revoking = '*';
148
+ try {
149
+ const result = await provider.revokeOtherSessions(context);
150
+ if (!result.success) throw new Error(result.error?.message ?? (isZh ? '会话撤销失败' : 'Session revocation failed'));
151
+ if (currentRequest !== requestId) return;
152
+ sessions = sessions.filter((session) => session.current);
153
+ } catch (caught) {
154
+ if (currentRequest !== requestId) return;
155
+ notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
156
+ } finally {
157
+ if (currentRequest === requestId) revoking = null;
158
+ }
70
159
  }
71
160
  </script>
72
161
 
73
162
  <div class="space-y-6">
74
- <div>
75
- <h2 class="text-xl font-semibold text-foreground">{i18n.t('settings.security')}</h2>
76
- <p class="mt-1 text-sm text-muted-foreground">{i18n.t('settings.securityDescription')}</p>
77
- </div>
78
-
79
- <Card.Card>
80
- <Card.CardHeader>
81
- <Card.CardTitle class="flex items-center gap-2 text-base">
82
- {#if is2faEnabled}
83
- <ShieldCheck class="h-5 w-5 text-green-500" />
84
- {:else}
85
- <ShieldAlert class="h-5 w-5 text-amber-500" />
86
- {/if}
87
- {i18n.t('security.twoFactorAuth')}
88
- </Card.CardTitle>
89
- <Card.CardDescription>{i18n.t('security.twoFactorDescription')}</Card.CardDescription>
90
- </Card.CardHeader>
91
- <Card.CardContent>
92
- <div class="flex items-center justify-between gap-4 border-t pt-4">
93
- <div class="space-y-1">
94
- <Label for="two-factor" class="text-sm font-medium">{i18n.t('security.enable2fa')}</Label>
95
- <p class="text-xs text-muted-foreground">
96
- {is2faEnabled ? i18n.t('security.twoFactorActive') : i18n.t('security.twoFactorInactive')}
97
- </p>
98
- </div>
99
- <Switch id="two-factor" bind:checked={is2faEnabled} />
100
- </div>
101
- </Card.CardContent>
102
- </Card.Card>
103
-
104
- <Card.Card>
105
- <Card.CardHeader>
106
- <Card.CardTitle class="flex items-center gap-2 text-base">
107
- <KeyRound class="h-4 w-4 text-muted-foreground" />
108
- {i18n.t('profile.changePassword')}
109
- </Card.CardTitle>
110
- <Card.CardDescription>{i18n.t('security.passwordLastChanged', { time: lastChanged })}</Card.CardDescription>
111
- </Card.CardHeader>
112
- <Card.CardContent>
113
- <form onsubmit={handlePasswordChange} class="max-w-sm space-y-4">
114
- {#if passwordErrorMessage}
115
- <Alert.Root variant="destructive">
116
- <AlertTriangle class="h-4 w-4" />
117
- <Alert.Description>{passwordErrorMessage}</Alert.Description>
118
- </Alert.Root>
119
- {/if}
120
-
121
- <PasswordInput id="security-current-password" label={i18n.t('profile.currentPassword')} bind:value={currentPassword} autocomplete="current-password" />
122
- <PasswordInput id="security-new-password" label={i18n.t('profile.newPassword')} bind:value={newPassword} autocomplete="new-password" showStrength />
123
- <PasswordInput id="security-confirm-password" label={i18n.t('auth.confirmPassword')} bind:value={confirmPassword} autocomplete="new-password" />
124
-
125
- <Button type="submit">{i18n.t('profile.updatePassword')}</Button>
126
- </form>
127
- </Card.CardContent>
128
- </Card.Card>
129
-
130
- <Card.Card>
131
- <Card.CardHeader class="flex flex-row items-start justify-between gap-4">
132
- <div class="space-y-1">
133
- <Card.CardTitle class="flex items-center gap-2 text-base">
134
- <Shield class="h-4 w-4 text-muted-foreground" />
135
- {i18n.t('security.activeSessions')}
136
- </Card.CardTitle>
137
- <Card.CardDescription>{i18n.t('security.sessionsDescription')}</Card.CardDescription>
138
- </div>
139
- {#if sessions.length > 1}
140
- <Button variant="outline" size="sm" onclick={revokeAllOthers}>{i18n.t('security.revokeOthers')}</Button>
141
- {/if}
142
- </Card.CardHeader>
143
- <Card.CardContent>
163
+ <div><h2 class="text-xl font-semibold text-foreground">{i18n.t('settings.security')}</h2><p class="mt-1 text-sm text-muted-foreground">{i18n.t('settings.securityDescription')}</p></div>
164
+ {#if !sessionProvider}
165
+ <FeedbackNotice tone="warning" message={isZh ? '未配置 SessionProvider。会话和 MFA 设置不会使用模拟数据。' : 'SessionProvider is not configured. Session and MFA controls do not use simulated data.'} />
166
+ {/if}
167
+ <SettingsGroup title={i18n.t('security.twoFactorAuth')} description={i18n.t('security.twoFactorDescription')}>
168
+ <SettingsFieldRow label={i18n.t('security.enable2fa')} description={is2faEnabled ? i18n.t('security.twoFactorActive') : i18n.t('security.twoFactorInactive')}>
169
+ {#snippet control()}<Switch checked={is2faEnabled} disabled={!sessionProvider?.setMfaEnabled || updatingMfa} onCheckedChange={handleMfaChange} />{/snippet}
170
+ </SettingsFieldRow>
171
+ </SettingsGroup>
172
+ <SettingsGroup title={i18n.t('profile.changePassword')} description={isZh ? '密码修改由 AuthProvider 持久化。' : 'Password changes are persisted by AuthProvider.'}>
173
+ {#if !authProvider?.updatePassword}<FeedbackNotice tone="warning" message={isZh ? '当前 AuthProvider 不支持修改密码。' : 'The current AuthProvider does not support password updates.'} />{/if}
174
+ <form onsubmit={handlePasswordChange} class="max-w-lg space-y-4">
175
+ {#if passwordErrorMessage}<Alert.Root variant="destructive"><AlertTriangle class="h-4 w-4" /><Alert.Description>{passwordErrorMessage}</Alert.Description></Alert.Root>{/if}
176
+ <PasswordInput id="security-current-password" label={i18n.t('profile.currentPassword')} bind:value={currentPassword} autocomplete="current-password" disabled={!authProvider?.updatePassword || changingPassword} />
177
+ <PasswordInput id="security-new-password" label={i18n.t('profile.newPassword')} bind:value={newPassword} autocomplete="new-password" showStrength disabled={!authProvider?.updatePassword || changingPassword} />
178
+ <PasswordInput id="security-confirm-password" label={i18n.t('auth.confirmPassword')} bind:value={confirmPassword} autocomplete="new-password" disabled={!authProvider?.updatePassword || changingPassword} />
179
+ <Button type="submit" disabled={!authProvider?.updatePassword || changingPassword}>{changingPassword ? (isZh ? '更新中...' : 'Updating...') : i18n.t('profile.updatePassword')}</Button>
180
+ </form>
181
+ </SettingsGroup>
182
+ <SettingsGroup title={i18n.t('security.activeSessions')} description={i18n.t('security.sessionsDescription')}>
183
+ {#snippet actions()}{#if sessions.some((session) => !session.current)}<Button variant="outline" size="sm" disabled={revoking !== null} onclick={revokeAllOthers}>{i18n.t('security.revokeOthers')}</Button>{/if}{/snippet}
184
+ {#if loadingSessions}
185
+ <DataState state="loading" />
186
+ {:else if sessionError}
187
+ <DataState state="error" description={sessionError} retry={loadSecurity} />
188
+ {:else if sessions.length === 0}
189
+ <DataState state="empty" title={i18n.t('security.noSessions')} description={sessionProvider ? (isZh ? 'Provider 未返回活跃会话。' : 'The provider returned no active sessions.') : (isZh ? '配置 SessionProvider 后可管理真实会话。' : 'Configure SessionProvider to manage real sessions.')} />
190
+ {:else}
144
191
  <div class="divide-y">
145
192
  {#each sessions as session (session.id)}
146
193
  <div class="flex items-center justify-between gap-4 py-3">
147
- <div class="flex min-w-0 items-center gap-3">
148
- <div class="rounded-lg bg-muted p-2 text-muted-foreground">
149
- <session.Icon class="h-5 w-5" />
150
- </div>
151
- <div class="min-w-0 space-y-1">
152
- <div class="flex flex-wrap items-center gap-2">
153
- <span class="text-sm font-medium">{session.os} - {session.browser}</span>
154
- {#if session.isCurrent}
155
- <span class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary">{i18n.t('security.currentSession')}</span>
156
- {/if}
157
- </div>
158
- <div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
159
- <span>{session.ip}</span>
160
- <span>{session.lastActive}</span>
161
- </div>
162
- </div>
163
- </div>
164
- {#if !session.isCurrent}
165
- <Button variant="ghost" size="icon-sm" onclick={() => revokeSession(session.id)} title={i18n.t('security.revokeSession')}>
166
- <Trash2 class="h-4 w-4" />
167
- </Button>
168
- {/if}
194
+ <div class="flex min-w-0 items-center gap-3"><div class="rounded-lg bg-muted p-2 text-muted-foreground">{#if (session.os ?? '').toLowerCase().includes('ios') || (session.os ?? '').toLowerCase().includes('android')}<Smartphone class="h-5 w-5" />{:else}<Monitor class="h-5 w-5" />{/if}</div><div class="min-w-0 space-y-1"><div class="flex flex-wrap items-center gap-2"><span class="text-sm font-medium">{session.os ?? (isZh ? '未知系统' : 'Unknown OS')} - {session.browser ?? (isZh ? '未知浏览器' : 'Unknown browser')}</span>{#if session.current}<span class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary">{i18n.t('security.currentSession')}</span>{/if}</div><div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"><span>{session.ipAddress ?? '-'}</span><span>{session.lastActiveAt ? new Date(session.lastActiveAt).toLocaleString() : '-'}</span></div></div></div>
195
+ {#if !session.current}<Button variant="ghost" size="icon-sm" disabled={revoking !== null} onclick={() => revokeSession(session.id)} title={i18n.t('security.revokeSession')}><Trash2 class="h-4 w-4" /></Button>{/if}
169
196
  </div>
170
- {:else}
171
- <div class="py-6 text-center text-sm text-muted-foreground">{i18n.t('security.noSessions')}</div>
172
197
  {/each}
173
198
  </div>
174
- </Card.CardContent>
175
- </Card.Card>
199
+ {/if}
200
+ </SettingsGroup>
176
201
  </div>
@@ -1,4 +1,3 @@
1
- import type { Component } from 'svelte';
2
- declare const SecuritySettings: Component<Record<string, never>, {}, "">;
1
+ declare const SecuritySettings: import("svelte").Component<Record<string, never>, {}, "">;
3
2
  type SecuritySettings = ReturnType<typeof SecuritySettings>;
4
3
  export default SecuritySettings;
@@ -115,6 +115,16 @@
115
115
  return content ? alias : "profile";
116
116
  });
117
117
 
118
+ const contentWidth = $derived(
119
+ activeKey === 'roles' || activeKey === 'audit'
120
+ ? 'max-w-[92rem]'
121
+ : activeKey === 'api'
122
+ ? 'max-w-[78rem]'
123
+ : activeKey === 'integrations'
124
+ ? 'max-w-[64rem]'
125
+ : 'max-w-3xl',
126
+ );
127
+
118
128
  // Default redirect to profile
119
129
  $effect(() => {
120
130
  if (getRoute() === "/settings") {
@@ -151,7 +161,7 @@
151
161
  </div>
152
162
  {#each resolvedSections as section, _i (_i)}
153
163
  <div>
154
- <h3 class="px-3 mb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
164
+ <h3 class="px-3 mb-1 text-xs font-medium text-muted-foreground">
155
165
  {i18n.t(section.group)}
156
166
  </h3>
157
167
  <div class="space-y-0.5">
@@ -175,7 +185,7 @@
175
185
  </nav>
176
186
 
177
187
  <!-- Right content area -->
178
- <div class="flex-1 p-6 lg:p-8 {activeKey === 'roles' || activeKey === 'audit' || activeKey === 'integrations' || activeKey === 'api' ? 'max-w-6xl' : 'max-w-3xl'}">
188
+ <div class="min-w-0 w-full flex-1 p-6 lg:p-8 {contentWidth}">
179
189
  {#if activeKey === "profile"}
180
190
  {#if profile}{@render profile()}{:else}<ProfilePage />{/if}
181
191
  {:else if activeKey === "appearance"}