@svadmin/ui 0.47.1 → 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.
@@ -25,14 +25,18 @@
25
25
  AuditLogProvider,
26
26
  AuthProvider,
27
27
  ChatProvider,
28
+ CredentialProvider,
28
29
  DataProviderInput,
30
+ IdentityGovernanceProvider,
29
31
  I18nProvider,
30
32
  LiveProvider,
31
33
  MenuItem,
32
34
  NotificationProvider,
35
+ OrganizationProvider,
33
36
  ProviderBundle,
34
37
  ResourceDefinition,
35
38
  RouterProvider,
39
+ SessionProvider,
36
40
  TaskProvider,
37
41
  TenantAdapter,
38
42
  TenantContext,
@@ -93,6 +97,10 @@
93
97
  taskProvider?: TaskProvider;
94
98
  routerProvider?: RouterProvider;
95
99
  tenantAdapter?: TenantAdapter;
100
+ organizationProvider?: OrganizationProvider;
101
+ identityGovernanceProvider?: IdentityGovernanceProvider;
102
+ sessionProvider?: SessionProvider;
103
+ credentialProvider?: CredentialProvider;
96
104
  /** Request/tree-scoped tenant. Tenant identifiers are never rendered by DevTools. */
97
105
  tenant?: TenantContext;
98
106
  resources: ResourceDefinition[];
@@ -148,6 +156,10 @@
148
156
  taskProvider,
149
157
  routerProvider,
150
158
  tenantAdapter,
159
+ organizationProvider,
160
+ identityGovernanceProvider,
161
+ sessionProvider,
162
+ credentialProvider,
151
163
  tenant,
152
164
  resources,
153
165
  locale = $bindable(),
@@ -190,6 +202,10 @@
190
202
  const resolvedTaskProvider = $derived(taskProvider ?? resolvedProviderBundle?.taskProvider);
191
203
  const resolvedRouterInput = $derived(routerProvider ?? resolvedProviderBundle?.routerProvider);
192
204
  const resolvedTenantAdapter = $derived(tenantAdapter ?? resolvedProviderBundle?.tenantAdapter);
205
+ const resolvedOrganizationProvider = $derived(organizationProvider ?? resolvedProviderBundle?.organizationProvider ?? undefined);
206
+ const resolvedIdentityGovernanceProvider = $derived(identityGovernanceProvider ?? resolvedProviderBundle?.identityGovernanceProvider ?? undefined);
207
+ const resolvedSessionProvider = $derived(sessionProvider ?? resolvedProviderBundle?.sessionProvider ?? undefined);
208
+ const resolvedCredentialProvider = $derived(credentialProvider ?? resolvedProviderBundle?.credentialProvider ?? undefined);
193
209
  const resolvedI18nProvider = $derived(i18nProvider ?? resolvedProviderBundle?.i18nProvider);
194
210
 
195
211
  // Resolve router provider (default to hash)
@@ -280,6 +296,10 @@
280
296
  get chatProvider() { return resolvedChatProvider ?? null; },
281
297
  get agentProvider() { return resolvedAgentProvider ?? null; },
282
298
  get taskProvider() { return resolvedTaskProvider; },
299
+ get organizationProvider() { return resolvedOrganizationProvider ?? null; },
300
+ get identityGovernanceProvider() { return resolvedIdentityGovernanceProvider ?? null; },
301
+ get sessionProvider() { return resolvedSessionProvider ?? null; },
302
+ get credentialProvider() { return resolvedCredentialProvider ?? null; },
283
303
  get resources() { return resources; },
284
304
  get routerProvider() { return scopedRouterProvider; },
285
305
  get tenantAdapter() { return resolvedTenantAdapter; },
@@ -1,6 +1,6 @@
1
1
  import '../app.css';
2
2
  import { type Component, type Snippet } from 'svelte';
3
- import type { AccessControlProvider, AgentProvider, AuditLogProvider, AuthProvider, ChatProvider, DataProviderInput, I18nProvider, LiveProvider, MenuItem, NotificationProvider, ResourceDefinition, RouterProvider, TaskProvider, TenantAdapter, TenantContext, ThemeConfig, ThemeMode } from '@svadmin/core';
3
+ import type { AccessControlProvider, AgentProvider, AuditLogProvider, AuthProvider, ChatProvider, CredentialProvider, DataProviderInput, IdentityGovernanceProvider, I18nProvider, LiveProvider, MenuItem, NotificationProvider, OrganizationProvider, ResourceDefinition, RouterProvider, SessionProvider, TaskProvider, TenantAdapter, TenantContext, ThemeConfig, ThemeMode } from '@svadmin/core';
4
4
  import { QueryClient, type DefaultOptions } from '@tanstack/svelte-query';
5
5
  import { type ComponentRegistry } from '../component-registry.svelte.js';
6
6
  import type { AdminProviderBundle } from '../types.js';
@@ -21,6 +21,10 @@ interface Props {
21
21
  taskProvider?: TaskProvider;
22
22
  routerProvider?: RouterProvider;
23
23
  tenantAdapter?: TenantAdapter;
24
+ organizationProvider?: OrganizationProvider;
25
+ identityGovernanceProvider?: IdentityGovernanceProvider;
26
+ sessionProvider?: SessionProvider;
27
+ credentialProvider?: CredentialProvider;
24
28
  /** Request/tree-scoped tenant. Tenant identifiers are never rendered by DevTools. */
25
29
  tenant?: TenantContext;
26
30
  resources: ResourceDefinition[];
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { captureAdminContext, notifyWithProvider, type ApiCredentialSummary, type WebhookSummary } from '@svadmin/core';
2
3
  import { useTranslation } from '@svadmin/core/i18n';
3
4
  import { Check, Copy, Plus, ShieldCheck, Trash2 } from '@lucide/svelte';
4
5
  import { Button } from './ui/button/index.js';
@@ -8,91 +9,177 @@
8
9
  import ApiKeyList from './content/ApiKeyList.svelte';
9
10
  import ContentPageShell from './content/ContentPageShell.svelte';
10
11
  import DataState from './content/DataState.svelte';
12
+ import FeedbackNotice from './content/FeedbackNotice.svelte';
11
13
  import SettingsGroup from './content/SettingsGroup.svelte';
12
14
  import WorkspaceLayout from './content/WorkspaceLayout.svelte';
13
15
 
14
16
  const i18n = useTranslation();
15
- interface ApiKey { id: string; name: string; maskedToken: string; created: string; permissions: string[]; }
16
- interface WebhookEntry { id: string; name: string; url: string; eventType: string; }
17
- let keys = $state<ApiKey[]>([
18
- { id: 'prod-sync', name: 'Production Sync Service', maskedToken: 'sv_demo_prod_••••_4d3e', created: '2026-03-10', permissions: ['Read', 'Write'] },
19
- { id: 'reporting', name: 'Internal Reporting Tool', maskedToken: 'sv_demo_report_••••_9e8f', created: '2026-05-18', permissions: ['Read'] },
20
- ]);
17
+ const adminContext = captureAdminContext();
18
+ const credentialProvider = $derived(adminContext.credentialProvider);
19
+ const isZh = $derived(i18n.locale === 'zh-CN');
20
+
21
+ let keys = $state.raw<ApiCredentialSummary[]>([]);
22
+ let webhooks = $state.raw<WebhookSummary[]>([]);
21
23
  let newKeyName = $state('');
22
24
  let newKeyPermissions = $state({ Read: true, Write: false, Delete: false, Admin: false });
23
- let generatedToken = $state('');
24
- let showCreatedToken = $state(false);
25
- let copiedKeyId = $state<string | null>(null);
26
- let webhooks = $state<WebhookEntry[]>([{ id: 'wh-1', name: 'Order Events', url: 'https://api.acme.com/hooks/orders', eventType: 'resource.updated' }]);
25
+ let generatedSecret = $state('');
26
+ let copied = $state(false);
27
27
  let newWebhookName = $state('');
28
28
  let newWebhookUrl = $state('');
29
29
  let newWebhookEvent = $state('resource.created');
30
+ let loading = $state(false);
31
+ let error = $state<string | null>(null);
32
+ let submitting = $state(false);
33
+ let requestId = 0;
34
+
35
+ function message(error: unknown): string {
36
+ return error instanceof Error ? error.message : String(error);
37
+ }
30
38
 
31
- function handleAddWebhook(event: SubmitEvent) {
39
+ async function loadCredentials(): Promise<void> {
40
+ const currentRequest = ++requestId;
41
+ const provider = credentialProvider;
42
+ const context = adminContext.enterpriseRequestContext;
43
+ keys = [];
44
+ webhooks = [];
45
+ generatedSecret = '';
46
+ copied = false;
47
+ error = null;
48
+ loading = false;
49
+ submitting = false;
50
+ if (!provider) return;
51
+ loading = true;
52
+ try {
53
+ const [nextKeys, nextWebhooks] = await Promise.all([provider.listApiCredentials(context), provider.listWebhooks(context)]);
54
+ if (currentRequest !== requestId) return;
55
+ keys = nextKeys;
56
+ webhooks = nextWebhooks;
57
+ } catch (caught) {
58
+ if (currentRequest === requestId) error = message(caught);
59
+ } finally {
60
+ if (currentRequest === requestId) loading = false;
61
+ }
62
+ }
63
+
64
+ $effect(() => {
65
+ void credentialProvider;
66
+ void adminContext.tenantCacheKey?.__svadminTenant;
67
+ void loadCredentials();
68
+ return () => { requestId += 1; };
69
+ });
70
+
71
+ async function handleAddWebhook(event: SubmitEvent): Promise<void> {
32
72
  event.preventDefault();
33
- if (!newWebhookName.trim() || !newWebhookUrl.trim()) return;
34
- webhooks = [...webhooks, { id: Date.now().toString(), name: newWebhookName.trim(), url: newWebhookUrl.trim(), eventType: newWebhookEvent }];
35
- newWebhookName = '';
36
- newWebhookUrl = '';
73
+ const provider = credentialProvider;
74
+ const currentRequest = requestId;
75
+ const context = adminContext.enterpriseRequestContext;
76
+ if (!provider || !newWebhookName.trim() || !newWebhookUrl.trim()) return;
77
+ submitting = true;
78
+ try {
79
+ const webhook = await provider.createWebhook({ name: newWebhookName.trim(), url: newWebhookUrl.trim(), eventType: newWebhookEvent }, context);
80
+ if (currentRequest !== requestId) return;
81
+ webhooks = [...webhooks, webhook];
82
+ newWebhookName = '';
83
+ newWebhookUrl = '';
84
+ } catch (caught) {
85
+ if (currentRequest !== requestId) return;
86
+ notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
87
+ } finally {
88
+ if (currentRequest === requestId) submitting = false;
89
+ }
37
90
  }
38
- function generateDemoToken() { return `sv_demo_${Array.from({ length: 24 }, () => Math.floor(Math.random() * 16).toString(16)).join('')}`; }
39
- function handleGenerateKey(event: SubmitEvent) {
91
+
92
+ async function handleGenerateKey(event: SubmitEvent): Promise<void> {
40
93
  event.preventDefault();
41
- if (!newKeyName.trim()) return;
42
- const token = generateDemoToken();
43
- const permissions = Object.entries(newKeyPermissions).filter(([, enabled]) => enabled).map(([name]) => name);
44
- keys = [...keys, { id: Date.now().toString(), name: newKeyName.trim(), maskedToken: `${token.slice(0, 14)}••••${token.slice(-4)}`, created: new Date().toISOString().split('T')[0], permissions }];
45
- generatedToken = token;
46
- showCreatedToken = true;
47
- newKeyName = '';
48
- newKeyPermissions = { Read: true, Write: false, Delete: false, Admin: false };
94
+ const provider = credentialProvider;
95
+ const currentRequest = requestId;
96
+ const context = adminContext.enterpriseRequestContext;
97
+ if (!provider || !newKeyName.trim()) return;
98
+ submitting = true;
99
+ try {
100
+ const permissions = Object.entries(newKeyPermissions).filter(([, enabled]) => enabled).map(([name]) => name);
101
+ const created = await provider.createApiCredential({ name: newKeyName.trim(), permissions }, context);
102
+ if (currentRequest !== requestId) return;
103
+ keys = [...keys, created.credential];
104
+ generatedSecret = created.secret;
105
+ copied = false;
106
+ newKeyName = '';
107
+ newKeyPermissions = { Read: true, Write: false, Delete: false, Admin: false };
108
+ } catch (caught) {
109
+ if (currentRequest !== requestId) return;
110
+ notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
111
+ } finally {
112
+ if (currentRequest === requestId) submitting = false;
113
+ }
114
+ }
115
+
116
+ async function revokeKey(id: string): Promise<void> {
117
+ const provider = credentialProvider;
118
+ const currentRequest = requestId;
119
+ const context = adminContext.enterpriseRequestContext;
120
+ if (!provider) return;
121
+ try {
122
+ const result = await provider.revokeApiCredential(id, context);
123
+ if (!result.success) throw new Error(result.error?.message ?? (isZh ? '密钥撤销失败' : 'Credential revocation failed'));
124
+ if (currentRequest !== requestId) return;
125
+ keys = keys.filter((key) => key.id !== id);
126
+ } catch (caught) {
127
+ if (currentRequest !== requestId) return;
128
+ notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
129
+ }
49
130
  }
50
- function deleteKey(id: string) { keys = keys.filter((key) => key.id !== id); }
51
- async function copyToken(token: string, id: string) {
131
+
132
+ async function deleteWebhook(id: string): Promise<void> {
133
+ const provider = credentialProvider;
134
+ const currentRequest = requestId;
135
+ const context = adminContext.enterpriseRequestContext;
136
+ if (!provider) return;
137
+ try {
138
+ const result = await provider.deleteWebhook(id, context);
139
+ if (!result.success) throw new Error(result.error?.message ?? (isZh ? 'Webhook 删除失败' : 'Webhook deletion failed'));
140
+ if (currentRequest !== requestId) return;
141
+ webhooks = webhooks.filter((webhook) => webhook.id !== id);
142
+ } catch (caught) {
143
+ if (currentRequest !== requestId) return;
144
+ notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
145
+ }
146
+ }
147
+
148
+ async function copySecret(): Promise<void> {
52
149
  try {
53
- await navigator.clipboard.writeText(token);
54
- copiedKeyId = id;
55
- setTimeout(() => { if (copiedKeyId === id) copiedKeyId = null; }, 2000);
56
- } catch { copiedKeyId = null; }
150
+ await navigator.clipboard.writeText(generatedSecret);
151
+ copied = true;
152
+ } catch {
153
+ copied = false;
154
+ }
57
155
  }
58
156
  </script>
59
157
 
60
158
  <ContentPageShell pageId="api-settings" width="wide" title={i18n.t('settings.api')} description={i18n.t('settings.apiDescription')}>
159
+ {#if !credentialProvider}<FeedbackNotice tone="warning" message={isZh ? '未配置 CredentialProvider。页面不会在浏览器内生成假密钥或假 Webhook。' : 'CredentialProvider is not configured. This page does not generate simulated browser credentials or webhooks.'} />{/if}
61
160
  <WorkspaceLayout secondaryWidth="21rem">
62
161
  {#snippet summary()}
63
- {#if showCreatedToken}
162
+ {#if generatedSecret}
64
163
  <section class="rounded-lg border border-primary/20 bg-primary/5 p-4" role="status" aria-live="polite">
65
- <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
66
- <div class="flex min-w-0 items-start gap-3"><ShieldCheck class="mt-0.5 size-5 shrink-0 text-primary" /><div class="min-w-0"><h2 class="text-sm font-semibold text-foreground">{i18n.t('api.successTitle')}</h2><p class="mt-1 text-sm text-muted-foreground">{i18n.t('api.successHint')}</p></div></div>
67
- <Button variant="ghost" size="sm" onclick={() => { showCreatedToken = false; generatedToken = ''; }}>{i18n.t('common.close')}</Button>
68
- </div>
69
- <div class="mt-4 flex min-w-0 items-center gap-2"><Input readonly value={generatedToken} class="min-w-0 font-mono text-xs" aria-label={i18n.t('api.successTitle')} /><Button size="icon" onclick={() => copyToken(generatedToken, 'new-token')} aria-label={i18n.t('common.copy')}>{#if copiedKeyId === 'new-token'}<Check class="size-4" />{:else}<Copy class="size-4" />{/if}</Button></div>
164
+ <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"><div class="flex min-w-0 items-start gap-3"><ShieldCheck class="mt-0.5 size-5 shrink-0 text-primary" /><div class="min-w-0"><h2 class="text-sm font-semibold text-foreground">{i18n.t('api.successTitle')}</h2><p class="mt-1 text-sm text-muted-foreground">{i18n.t('api.successHint')}</p></div></div><Button variant="ghost" size="sm" onclick={() => { generatedSecret = ''; copied = false; }}>{i18n.t('common.close')}</Button></div>
165
+ <div class="mt-4 flex min-w-0 items-center gap-2"><Input readonly value={generatedSecret} class="min-w-0 font-mono text-xs" aria-label={i18n.t('api.successTitle')} /><Button size="icon" onclick={copySecret} aria-label={i18n.t('common.copy')}>{#if copied}<Check class="size-4" />{:else}<Copy class="size-4" />{/if}</Button></div>
70
166
  </section>
71
167
  {/if}
72
168
  {/snippet}
73
169
  {#snippet primary()}
74
170
  <div class="space-y-6">
75
171
  <SettingsGroup title={i18n.t('api.activeKeysTitle')} description={i18n.t('api.activeKeysDesc')} bodyClass="p-0">
76
- <ApiKeyList class="rounded-none border-0" keys={keys.map((key) => ({ id: key.id, name: key.name, prefix: key.maskedToken, maskedToken: key.maskedToken, createdAt: key.created, permissions: key.permissions }))} stateTitle={i18n.t('api.noKeys')} stateDescription={i18n.t('api.generateDesc')} {copiedKeyId} oncopy={(key) => copyToken(key.maskedToken ?? key.prefix, key.id)} onrevoke={(key) => deleteKey(key.id)} />
172
+ {#if loading}<DataState state="loading" />{:else if error}<DataState state="error" description={error} retry={loadCredentials} />{:else}<ApiKeyList class="rounded-none border-0" keys={keys.map((key) => ({ id: key.id, name: key.name, prefix: key.prefix, createdAt: new Date(key.createdAt).toLocaleString(), lastUsedAt: key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : undefined, permissions: key.permissions }))} stateTitle={i18n.t('api.noKeys')} stateDescription={credentialProvider ? i18n.t('api.generateDesc') : (isZh ? '配置 CredentialProvider 后可管理真实凭据。' : 'Configure CredentialProvider to manage real credentials.')} onrevoke={credentialProvider ? (key) => revokeKey(key.id) : undefined} />{/if}
77
173
  </SettingsGroup>
78
174
  <SettingsGroup title={i18n.t('api.webhooks')} description={i18n.t('api.webhooksDesc')} bodyClass="space-y-5">
79
- <form class="grid min-w-0 gap-4 md:grid-cols-2" onsubmit={handleAddWebhook}>
80
- <div class="space-y-2"><Label for="webhook-name">{i18n.t('api.webhookName')}</Label><Input id="webhook-name" bind:value={newWebhookName} placeholder="Order Events" /></div>
81
- <div class="space-y-2"><Label for="webhook-url">{i18n.t('api.webhookUrl')}</Label><Input id="webhook-url" type="url" bind:value={newWebhookUrl} placeholder="https://api.example.com/hooks" /></div>
82
- <div class="space-y-2"><Label for="webhook-event">{i18n.t('api.eventType')}</Label><select id="webhook-event" bind:value={newWebhookEvent} class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"><option value="resource.created">resource.created</option><option value="resource.updated">resource.updated</option><option value="resource.deleted">resource.deleted</option><option value="auth.login">auth.login</option></select></div>
83
- <div class="flex items-end"><Button type="submit" class="w-full" disabled={!newWebhookName.trim() || !newWebhookUrl.trim()}><Plus class="size-4" />{i18n.t('api.addWebhook')}</Button></div>
84
- </form>
85
- {#if webhooks.length === 0}
86
- <DataState state="empty" title={i18n.t('api.noWebhooks')} description={i18n.t('api.webhooksDesc')} />
87
- {:else}
88
- <div class="divide-y divide-border border-t border-border">{#each webhooks as hook (hook.id)}<div class="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between"><div class="min-w-0"><p class="text-sm font-medium text-foreground">{hook.name}</p><p class="truncate font-mono text-xs text-muted-foreground">{hook.url}</p></div><div class="flex items-center justify-between gap-3 sm:justify-end"><span class="rounded bg-muted px-2 py-1 font-mono text-xs text-muted-foreground">{hook.eventType}</span><Button variant="ghost" size="icon-sm" onclick={() => { webhooks = webhooks.filter((entry) => entry.id !== hook.id); }} aria-label={i18n.t('common.delete') + ' ' + hook.name}><Trash2 class="size-4" /></Button></div></div>{/each}</div>
89
- {/if}
175
+ <form class="grid min-w-0 gap-4 md:grid-cols-2" onsubmit={handleAddWebhook}><div class="space-y-2"><Label for="webhook-name">{i18n.t('api.webhookName')}</Label><Input id="webhook-name" bind:value={newWebhookName} placeholder="Order Events" disabled={!credentialProvider || submitting} /></div><div class="space-y-2"><Label for="webhook-url">{i18n.t('api.webhookUrl')}</Label><Input id="webhook-url" type="url" bind:value={newWebhookUrl} placeholder="https://api.example.com/hooks" disabled={!credentialProvider || submitting} /></div><div class="space-y-2"><Label for="webhook-event">{i18n.t('api.eventType')}</Label><select id="webhook-event" bind:value={newWebhookEvent} disabled={!credentialProvider || submitting} class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50"><option value="resource.created">resource.created</option><option value="resource.updated">resource.updated</option><option value="resource.deleted">resource.deleted</option><option value="auth.login">auth.login</option></select></div><div class="flex items-end"><Button type="submit" class="w-full" disabled={!credentialProvider || submitting || !newWebhookName.trim() || !newWebhookUrl.trim()}><Plus class="size-4" />{i18n.t('api.addWebhook')}</Button></div></form>
176
+ {#if webhooks.length === 0}<DataState state="empty" title={i18n.t('api.noWebhooks')} description={credentialProvider ? i18n.t('api.webhooksDesc') : (isZh ? '配置 CredentialProvider 后可管理真实 Webhook。' : 'Configure CredentialProvider to manage real webhooks.')} />{:else}<div class="divide-y divide-border border-t border-border">{#each webhooks as hook (hook.id)}<div class="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between"><div class="min-w-0"><p class="text-sm font-medium text-foreground">{hook.name}</p><p class="truncate font-mono text-xs text-muted-foreground">{hook.url}</p></div><div class="flex items-center justify-between gap-3 sm:justify-end"><span class="rounded bg-muted px-2 py-1 font-mono text-xs text-muted-foreground">{hook.eventType}</span><Button variant="ghost" size="icon-sm" onclick={() => deleteWebhook(hook.id)} aria-label={i18n.t('common.delete') + ' ' + hook.name}><Trash2 class="size-4" /></Button></div></div>{/each}</div>{/if}
90
177
  </SettingsGroup>
91
178
  </div>
92
179
  {/snippet}
93
180
  {#snippet secondary()}
94
181
  <SettingsGroup title={i18n.t('api.generateTitle')} description={i18n.t('api.generateDesc')}>
95
- <form onsubmit={handleGenerateKey} class="space-y-5"><div class="space-y-2"><Label for="api-key-name">{i18n.t('api.keyName')}</Label><Input id="api-key-name" placeholder="CI deployment token" bind:value={newKeyName} required /></div><fieldset class="space-y-3"><legend class="text-sm font-medium text-foreground">{i18n.t('api.permissions')}</legend>{#each Object.keys(newKeyPermissions) as permission (permission)}<div class="flex items-center gap-2"><Checkbox id={`perm-${permission}`} bind:checked={newKeyPermissions[permission as keyof typeof newKeyPermissions]} /><Label for={`perm-${permission}`} class="cursor-pointer text-sm font-normal">{permission}</Label></div>{/each}</fieldset><Button type="submit" class="w-full">{i18n.t('api.generateButton')}</Button></form>
182
+ <form onsubmit={handleGenerateKey} class="space-y-5"><div class="space-y-2"><Label for="api-key-name">{i18n.t('api.keyName')}</Label><Input id="api-key-name" placeholder="CI deployment token" bind:value={newKeyName} required disabled={!credentialProvider || submitting} /></div><fieldset class="space-y-3" disabled={!credentialProvider || submitting}><legend class="text-sm font-medium text-foreground">{i18n.t('api.permissions')}</legend>{#each Object.keys(newKeyPermissions) as permission (permission)}<div class="flex items-center gap-2"><Checkbox id={`perm-${permission}`} bind:checked={newKeyPermissions[permission as keyof typeof newKeyPermissions]} /><Label for={`perm-${permission}`} class="cursor-pointer text-sm font-normal">{permission}</Label></div>{/each}</fieldset><Button type="submit" class="w-full" disabled={!credentialProvider || submitting || !newKeyName.trim()}>{i18n.t('api.generateButton')}</Button></form>
96
183
  </SettingsGroup>
97
184
  {/snippet}
98
185
  </WorkspaceLayout>
@@ -28,6 +28,7 @@
28
28
  let { children, title = 'Admin', menu, siteUrl, routeMode = 'auto' }: { children: Snippet; title?: string; menu?: MenuItem[]; siteUrl?: string; routeMode?: 'hash' | 'path' | 'auto' } = $props();
29
29
  const layoutId = $props.id();
30
30
  const layoutScope = `svadmin-layout-${layoutId}`;
31
+ const mainContentId = `${layoutScope}-main`;
31
32
  const chatScope = `${layoutScope}-chat`;
32
33
  const adminContext = captureAdminContext();
33
34
 
@@ -68,6 +69,10 @@
68
69
  }
69
70
  }
70
71
 
72
+ function focusMainContent() {
73
+ document.getElementById(mainContentId)?.focus();
74
+ }
75
+
71
76
  let collapsed = $state(false);
72
77
 
73
78
  // Swipe gesture for mobile menu
@@ -150,6 +155,15 @@
150
155
  </div>
151
156
  {:else}
152
157
  <div data-svadmin-layout-scope={layoutScope} class="flex h-screen bg-background" in:fade={{ duration: 200, delay: 50 }}>
158
+ <button
159
+ type="button"
160
+ data-svadmin-skip-link={mainContentId}
161
+ class="absolute left-3 top-3 z-[100] -translate-y-16 rounded-md border border-border bg-background px-3 py-2 text-sm font-medium text-foreground shadow-sm transition-transform focus:translate-y-0 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
162
+ onclick={focusMainContent}
163
+ >
164
+ {i18n.t('common.skipToMainContent')}
165
+ </button>
166
+
153
167
  <!-- Desktop sidebar -->
154
168
  <div class="hidden md:block">
155
169
  <Sidebar {collapsed} {identity} {title} {menu} {routeMode} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
@@ -201,7 +215,7 @@
201
215
 
202
216
  <!-- Content area: responsive padding + centered max-width container
203
217
  so wide screens don't stretch content indefinitely (avoids sparse layouts) -->
204
- <main data-svadmin-main class="flex-1 overflow-y-auto bg-muted/30 px-4 py-5 sm:px-5 md:px-7.5 md:py-7">
218
+ <main id={mainContentId} tabindex="-1" data-svadmin-main class="flex-1 overflow-y-auto bg-muted/30 px-4 py-5 sm:px-5 md:px-7.5 md:py-7">
205
219
  <div class="mx-auto w-full max-w-[1600px]">
206
220
  {#key getPath()}
207
221
  <div in:fly={{ x: 20, duration: 150 }} out:fade={{ duration: 80 }}>
@@ -15,6 +15,7 @@
15
15
  placeholder?: string;
16
16
  autocomplete?: string;
17
17
  showStrength?: boolean;
18
+ disabled?: boolean;
18
19
  class?: string;
19
20
  }
20
21
 
@@ -25,6 +26,7 @@
25
26
  placeholder = '••••••••',
26
27
  autocomplete = 'current-password',
27
28
  showStrength = false,
29
+ disabled = false,
28
30
  class: className = '',
29
31
  }: Props = $props();
30
32
 
@@ -66,6 +68,7 @@
66
68
  bind:value
67
69
  class="pl-9 pr-9"
68
70
  autocomplete={autocomplete as any}
71
+ {disabled}
69
72
  />
70
73
  <TooltipButton
71
74
  tooltip={showPassword ? i18n.t('common.hidePassword') : i18n.t('common.showPassword')}
@@ -74,6 +77,7 @@
74
77
  type="button"
75
78
  class="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 z-[1]"
76
79
  onclick={(e) => { e.preventDefault(); showPassword = !showPassword; }}
80
+ {disabled}
77
81
  tabindex={-1}
78
82
  >
79
83
  {#if showPassword}
@@ -5,6 +5,7 @@ interface Props {
5
5
  placeholder?: string;
6
6
  autocomplete?: string;
7
7
  showStrength?: boolean;
8
+ disabled?: boolean;
8
9
  class?: string;
9
10
  }
10
11
  declare const PasswordInput: import("svelte").Component<Props, {}, "value">;
@@ -1,9 +1,7 @@
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
4
  import { AlertTriangle, Monitor, Smartphone, Trash2 } from '@lucide/svelte';
6
- import type { Component } from 'svelte';
7
5
  import * as Alert from './ui/alert/index.js';
8
6
  import { Button } from './ui/button/index.js';
9
7
  import { Switch } from './ui/switch/index.js';
@@ -11,126 +9,193 @@
11
9
  import SettingsGroup from './content/SettingsGroup.svelte';
12
10
  import SettingsFieldRow from './content/SettingsFieldRow.svelte';
13
11
  import DataState from './content/DataState.svelte';
12
+ import FeedbackNotice from './content/FeedbackNotice.svelte';
14
13
 
15
14
  const i18n = useTranslation();
16
- const notification = useNotification();
17
-
18
- interface SessionInfo {
19
- id: string;
20
- os: string;
21
- browser: string;
22
- ip: string;
23
- lastActive: string;
24
- isCurrent: boolean;
25
- Icon: Component;
26
- }
15
+ const adminContext = captureAdminContext();
16
+ const authProvider = $derived(adminContext.authProvider);
17
+ const sessionProvider = $derived(adminContext.sessionProvider);
18
+ const isZh = $derived(i18n.locale === 'zh-CN');
27
19
 
28
20
  let is2faEnabled = $state(false);
29
- let sessions = $state<SessionInfo[]>([
30
- { id: 'current', os: 'macOS', browser: 'Chrome', ip: '192.168.1.102', lastActive: 'Active now', isCurrent: true, Icon: Monitor },
31
- { id: 'mobile', os: 'iOS', browser: 'Safari', ip: '192.168.1.105', lastActive: '2 hours ago', isCurrent: false, Icon: Smartphone },
32
- { id: 'desktop', os: 'Windows', browser: 'Edge', ip: '10.0.0.4', lastActive: '2 days ago', isCurrent: false, Icon: Monitor },
33
- ]);
34
-
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);
35
26
  let currentPassword = $state('');
36
27
  let newPassword = $state('');
37
28
  let confirmPassword = $state('');
38
29
  let passwordErrorMessage = $state('');
39
- let lastChanged = $state('3 months ago');
30
+ let changingPassword = $state(false);
31
+ let requestId = 0;
40
32
 
41
- function handlePasswordChange(event: SubmitEvent) {
42
- event.preventDefault();
43
- passwordErrorMessage = '';
33
+ function message(error: unknown): string {
34
+ return error instanceof Error ? error.message : String(error);
35
+ }
44
36
 
45
- if (!currentPassword) {
46
- passwordErrorMessage = i18n.t('profile.currentPassword');
47
- return;
48
- }
49
- if (newPassword.length < 8) {
50
- passwordErrorMessage = i18n.t('validation.minLength', { min: 8 });
51
- return;
52
- }
53
- if (newPassword !== confirmPassword) {
54
- passwordErrorMessage = i18n.t('auth.passwordMismatch');
55
- 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;
56
59
  }
60
+ }
57
61
 
58
- notification.success(i18n.t('profile.passwordChanged'), 3000);
59
- lastChanged = 'Just now';
62
+ $effect(() => {
63
+ void authProvider;
64
+ void sessionProvider;
65
+ void adminContext.tenantCacheKey?.__svadminTenant;
60
66
  currentPassword = '';
61
67
  newPassword = '';
62
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
+ }
63
93
  }
64
94
 
65
- function revokeSession(id: string) {
66
- sessions = sessions.filter((session) => session.id !== id);
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
+ }
121
+ }
122
+
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
+ }
67
140
  }
68
141
 
69
- function revokeAllOthers() {
70
- 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
+ }
71
159
  }
72
160
  </script>
73
161
 
74
162
  <div class="space-y-6">
75
- <div>
76
- <h2 class="text-xl font-semibold text-foreground">{i18n.t('settings.security')}</h2>
77
- <p class="mt-1 text-sm text-muted-foreground">{i18n.t('settings.securityDescription')}</p>
78
- </div>
79
-
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}
80
167
  <SettingsGroup title={i18n.t('security.twoFactorAuth')} description={i18n.t('security.twoFactorDescription')}>
81
- <SettingsFieldRow label={i18n.t('security.enable2fa')} description={is2faEnabled ? i18n.t('security.twoFactorActive') : i18n.t('security.twoFactorInactive')}>
82
- {#snippet control()}<Switch id="two-factor" bind:checked={is2faEnabled} />{/snippet}
83
- </SettingsFieldRow>
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>
84
171
  </SettingsGroup>
85
-
86
- <SettingsGroup title={i18n.t('profile.changePassword')} description={i18n.t('security.passwordLastChanged', { time: lastChanged })}>
87
- <form onsubmit={handlePasswordChange} class="max-w-lg space-y-4">
88
- {#if passwordErrorMessage}
89
- <Alert.Root variant="destructive">
90
- <AlertTriangle class="h-4 w-4" />
91
- <Alert.Description>{passwordErrorMessage}</Alert.Description>
92
- </Alert.Root>
93
- {/if}
94
-
95
- <PasswordInput id="security-current-password" label={i18n.t('profile.currentPassword')} bind:value={currentPassword} autocomplete="current-password" />
96
- <PasswordInput id="security-new-password" label={i18n.t('profile.newPassword')} bind:value={newPassword} autocomplete="new-password" showStrength />
97
- <PasswordInput id="security-confirm-password" label={i18n.t('auth.confirmPassword')} bind:value={confirmPassword} autocomplete="new-password" />
98
-
99
- <Button type="submit">{i18n.t('profile.updatePassword')}</Button>
100
- </form>
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>
101
181
  </SettingsGroup>
102
-
103
182
  <SettingsGroup title={i18n.t('security.activeSessions')} description={i18n.t('security.sessionsDescription')}>
104
- {#snippet actions()}{#if sessions.length > 1}<Button variant="outline" size="sm" onclick={revokeAllOthers}>{i18n.t('security.revokeOthers')}</Button>{/if}{/snippet}
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}
105
191
  <div class="divide-y">
106
192
  {#each sessions as session (session.id)}
107
193
  <div class="flex items-center justify-between gap-4 py-3">
108
- <div class="flex min-w-0 items-center gap-3">
109
- <div class="rounded-lg bg-muted p-2 text-muted-foreground">
110
- <session.Icon class="h-5 w-5" />
111
- </div>
112
- <div class="min-w-0 space-y-1">
113
- <div class="flex flex-wrap items-center gap-2">
114
- <span class="text-sm font-medium">{session.os} - {session.browser}</span>
115
- {#if session.isCurrent}
116
- <span class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary">{i18n.t('security.currentSession')}</span>
117
- {/if}
118
- </div>
119
- <div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
120
- <span>{session.ip}</span>
121
- <span>{session.lastActive}</span>
122
- </div>
123
- </div>
124
- </div>
125
- {#if !session.isCurrent}
126
- <Button variant="ghost" size="icon-sm" onclick={() => revokeSession(session.id)} title={i18n.t('security.revokeSession')}>
127
- <Trash2 class="h-4 w-4" />
128
- </Button>
129
- {/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}
130
196
  </div>
131
- {:else}
132
- <DataState state="empty" title={i18n.t('security.noSessions')} />
133
197
  {/each}
134
198
  </div>
199
+ {/if}
135
200
  </SettingsGroup>
136
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;
@@ -1,4 +1,12 @@
1
1
  <script lang="ts">
2
+ import {
3
+ captureAdminContext,
4
+ notifyWithProvider,
5
+ type EnterpriseSecurityEvent,
6
+ type EnterpriseSecurityPolicy,
7
+ type IdentityProviderSummary,
8
+ type Organization,
9
+ } from '@svadmin/core';
2
10
  import { useTranslation } from '@svadmin/core/i18n';
3
11
  import { Button } from '../ui/button/index.js';
4
12
  import { Input } from '../ui/input/index.js';
@@ -10,38 +18,234 @@
10
18
  import SettingsGroup from '../content/SettingsGroup.svelte';
11
19
  import SettingsFieldRow from '../content/SettingsFieldRow.svelte';
12
20
  import IntegrationCard from '../content/IntegrationCard.svelte';
13
- import type { IntegrationSummary } from '../content/IntegrationCard.svelte';
14
- import SecurityEventTable from '../content/SecurityEventTable.svelte';
15
- import type { SecurityEvent } from '../content/SecurityEventTable.svelte';
21
+ import SecurityEventTable, { type SecurityEvent } from '../content/SecurityEventTable.svelte';
22
+ import DataState from '../content/DataState.svelte';
23
+ import FeedbackNotice from '../content/FeedbackNotice.svelte';
24
+
16
25
  const i18n = useTranslation();
17
- let ssoEnabled = $state(false);
18
- let auditLogging = $state(true);
19
- const identity: IntegrationSummary[] = [{ id: 'sso', name: 'Enterprise identity provider', description: i18n.t('account.ssoDescription'), account: 'SAML / OIDC', connected: false }];
20
- const events: SecurityEvent[] = [{ id: '1', event: 'SSO policy updated', actor: 'Alex Chen', location: 'Shanghai, CN', createdAt: 'Today, 09:12', severity: 'info' }, { id: '2', event: 'Audit log retention changed', actor: 'Alex Chen', location: 'Shanghai, CN', createdAt: 'Yesterday, 16:40', severity: 'warning' }];
26
+ const adminContext = captureAdminContext();
27
+ const identityProvider = $derived(adminContext.identityGovernanceProvider);
28
+ const organizationProvider = $derived(adminContext.organizationProvider);
29
+ const isZh = $derived(i18n.locale === 'zh-CN');
30
+
31
+ let policy = $state<EnterpriseSecurityPolicy | null>(null);
32
+ let organization = $state<Organization | null>(null);
33
+ let organizationName = $state('');
34
+ let sessionTimeoutValue = $state('');
35
+ let auditRetentionValue = $state('');
36
+ let identityProviders = $state.raw<IdentityProviderSummary[]>([]);
37
+ let events = $state.raw<EnterpriseSecurityEvent[]>([]);
38
+ let metadataUrl = $state('');
39
+ let loading = $state(false);
40
+ let saving = $state(false);
41
+ let savingOrganization = $state(false);
42
+ let testing = $state(false);
43
+ let error = $state<string | null>(null);
44
+ let requestId = 0;
45
+ const organizationChanged = $derived(Boolean(
46
+ organizationProvider?.updateCurrentOrganization
47
+ && organization
48
+ && organizationName.trim()
49
+ && organizationName.trim() !== organization.name,
50
+ ));
51
+ const policyNumbersValid = $derived.by(() => {
52
+ const sessionTimeout = Number(sessionTimeoutValue);
53
+ const auditRetention = Number(auditRetentionValue);
54
+ return /^\d+$/.test(sessionTimeoutValue)
55
+ && /^\d+$/.test(auditRetentionValue)
56
+ && Number.isSafeInteger(sessionTimeout)
57
+ && Number.isSafeInteger(auditRetention)
58
+ && sessionTimeout > 0
59
+ && auditRetention > 0;
60
+ });
61
+
62
+ function message(error: unknown): string {
63
+ return error instanceof Error ? error.message : String(error);
64
+ }
65
+
66
+ async function loadSettings(): Promise<void> {
67
+ const currentRequest = ++requestId;
68
+ const scopedIdentityProvider = identityProvider;
69
+ const scopedOrganizationProvider = organizationProvider;
70
+ const context = adminContext.enterpriseRequestContext;
71
+ policy = null;
72
+ organization = null;
73
+ organizationName = '';
74
+ sessionTimeoutValue = '';
75
+ auditRetentionValue = '';
76
+ identityProviders = [];
77
+ events = [];
78
+ error = null;
79
+ loading = false;
80
+ saving = false;
81
+ savingOrganization = false;
82
+ testing = false;
83
+ if (!scopedIdentityProvider && !scopedOrganizationProvider) return;
84
+ loading = true;
85
+ try {
86
+ const [nextPolicy, nextIdentityProviders, nextEvents, nextOrganization] = await Promise.all([
87
+ scopedIdentityProvider?.getSecurityPolicy(context) ?? null,
88
+ scopedIdentityProvider?.listIdentityProviders(context) ?? [],
89
+ scopedIdentityProvider?.listSecurityEvents?.({ page: 1, pageSize: 20 }, context) ?? { data: [], total: 0 },
90
+ scopedOrganizationProvider?.getCurrentOrganization(context) ?? null,
91
+ ]);
92
+ if (currentRequest !== requestId) return;
93
+ policy = nextPolicy;
94
+ sessionTimeoutValue = nextPolicy ? String(nextPolicy.sessionTimeoutMinutes) : '';
95
+ auditRetentionValue = nextPolicy ? String(nextPolicy.auditRetentionDays) : '';
96
+ identityProviders = nextIdentityProviders;
97
+ events = nextEvents.data;
98
+ organization = nextOrganization;
99
+ organizationName = nextOrganization?.name ?? '';
100
+ metadataUrl = nextIdentityProviders[0]?.metadataUrl ?? '';
101
+ } catch (caught) {
102
+ if (currentRequest === requestId) error = message(caught);
103
+ } finally {
104
+ if (currentRequest === requestId) loading = false;
105
+ }
106
+ }
107
+
108
+ $effect(() => {
109
+ void identityProvider;
110
+ void organizationProvider;
111
+ void adminContext.tenantCacheKey?.__svadminTenant;
112
+ void loadSettings();
113
+ return () => { requestId += 1; };
114
+ });
115
+
116
+ async function saveSettings(): Promise<void> {
117
+ const provider = identityProvider;
118
+ const currentRequest = requestId;
119
+ const context = adminContext.enterpriseRequestContext;
120
+ if (!provider || !policy || !policyNumbersValid) return;
121
+ saving = true;
122
+ try {
123
+ const updatedPolicy = await provider.updateSecurityPolicy({
124
+ ...policy,
125
+ sessionTimeoutMinutes: Number(sessionTimeoutValue),
126
+ auditRetentionDays: Number(auditRetentionValue),
127
+ }, context);
128
+ if (currentRequest !== requestId) return;
129
+ policy = updatedPolicy;
130
+ sessionTimeoutValue = String(updatedPolicy.sessionTimeoutMinutes);
131
+ auditRetentionValue = String(updatedPolicy.auditRetentionDays);
132
+ notifyWithProvider({ type: 'success', message: isZh ? '企业策略已保存' : 'Enterprise policy saved' }, adminContext.notificationProvider);
133
+ } catch (caught) {
134
+ if (currentRequest !== requestId) return;
135
+ notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
136
+ } finally {
137
+ if (currentRequest === requestId) saving = false;
138
+ }
139
+ }
140
+
141
+ async function saveOrganization(): Promise<void> {
142
+ const provider = organizationProvider;
143
+ const currentRequest = requestId;
144
+ const context = adminContext.enterpriseRequestContext;
145
+ const name = organizationName.trim();
146
+ if (!provider?.updateCurrentOrganization || !organization || !name || name === organization.name) return;
147
+ savingOrganization = true;
148
+ try {
149
+ const updatedOrganization = await provider.updateCurrentOrganization({ name }, context);
150
+ if (currentRequest !== requestId) return;
151
+ organization = updatedOrganization;
152
+ organizationName = updatedOrganization.name;
153
+ notifyWithProvider({ type: 'success', message: isZh ? '组织信息已保存' : 'Organization saved' }, adminContext.notificationProvider);
154
+ } catch (caught) {
155
+ if (currentRequest !== requestId) return;
156
+ notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
157
+ } finally {
158
+ if (currentRequest === requestId) savingOrganization = false;
159
+ }
160
+ }
161
+
162
+ async function testConnection(target?: IdentityProviderSummary): Promise<void> {
163
+ const provider = identityProvider;
164
+ const currentRequest = requestId;
165
+ const context = adminContext.enterpriseRequestContext;
166
+ if (!provider) return;
167
+ testing = true;
168
+ try {
169
+ const selectedProvider = target ?? identityProviders[0];
170
+ const result = await provider.testIdentityProvider({
171
+ id: selectedProvider?.id,
172
+ protocol: selectedProvider?.protocol,
173
+ metadataUrl: target
174
+ ? target.metadataUrl
175
+ : (metadataUrl.trim() || selectedProvider?.metadataUrl),
176
+ }, context);
177
+ if (currentRequest !== requestId) return;
178
+ notifyWithProvider({
179
+ type: result.success ? 'success' : 'error',
180
+ message: result.success
181
+ ? (isZh ? '身份源连接测试通过' : 'Identity provider connection succeeded')
182
+ : (result.error?.message ?? (isZh ? '身份源连接测试失败' : 'Identity provider connection failed')),
183
+ }, adminContext.notificationProvider);
184
+ } catch (caught) {
185
+ if (currentRequest !== requestId) return;
186
+ notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
187
+ } finally {
188
+ if (currentRequest === requestId) testing = false;
189
+ }
190
+ }
191
+
192
+ const displayEvents = $derived(events.map((event): SecurityEvent => ({
193
+ id: event.id,
194
+ event: event.event,
195
+ actor: event.actor,
196
+ location: event.location,
197
+ createdAt: event.createdAt instanceof Date ? event.createdAt.toLocaleString() : String(event.createdAt),
198
+ severity: event.severity === 'critical' ? 'danger' : (event.severity ?? 'info'),
199
+ })));
21
200
  </script>
22
201
 
23
202
  {#snippet auditControl()}
24
- <Switch bind:checked={auditLogging} aria-label={i18n.t('account.auditLogging')} />
203
+ <Switch checked={policy?.auditLoggingEnabled ?? false} disabled={!policy || saving} aria-label={i18n.t('account.auditLogging')} onCheckedChange={(checked) => { if (policy) policy = { ...policy, auditLoggingEnabled: checked }; }} />
25
204
  {/snippet}
26
205
 
27
206
  {#snippet ssoControl()}
28
- <Switch bind:checked={ssoEnabled} aria-label={i18n.t('account.enableSso')} />
207
+ <Switch checked={policy?.requireSso ?? false} disabled={!policy || saving} aria-label={i18n.t('account.enableSso')} onCheckedChange={(checked) => { if (policy) policy = { ...policy, requireSso: checked }; }} />
29
208
  {/snippet}
30
209
 
31
210
  <ContentPageShell pageId="account-settings-enterprise" width="wide">
32
211
  <ContentPageHeader title={i18n.t('account.settingsEnterprise')} description={i18n.t('account.settingsEnterpriseDescription')} />
33
- <div class="grid items-start gap-4 lg:grid-cols-2">
34
- <SettingsGroup title={i18n.t('account.securityPolicy')} description="Set organization-wide controls for authentication and sessions." bodyClass="space-y-4">
35
- <div class="grid gap-4 sm:grid-cols-2"><div class="space-y-2"><Label for="session-timeout">{i18n.t('account.sessionTimeout')}</Label><Input id="session-timeout" value="30" /></div><div class="space-y-2"><Label for="retention">{i18n.t('account.dataRetention')}</Label><Input id="retention" value="365" /></div></div>
36
- <SettingsFieldRow label={i18n.t('account.auditLogging')} description="Preserve an auditable record of organization-level changes." control={auditControl} separated />
37
- <Button size="sm">{i18n.t('common.save')}</Button>
38
- </SettingsGroup>
39
- <SettingsGroup title={i18n.t('account.singleSignOn')} description={i18n.t('account.ssoDescription')} bodyClass="space-y-4">
40
- <SettingsFieldRow label={i18n.t('account.enableSso')} description="Require the configured identity provider for organization members." control={ssoControl} />
41
- <Input value="https://idp.example.com/metadata" aria-label={i18n.t('account.ssoProvider')} />
42
- <Button variant="outline" size="sm">{i18n.t('common.test')}</Button>
43
- </SettingsGroup>
44
- </div>
45
- <section class="space-y-3"><SectionHeader title={i18n.t('account.connections')} />{#each identity as integration (integration.id)}<IntegrationCard {integration} />{/each}</section>
46
- <section class="space-y-3"><SectionHeader title={i18n.t('account.securityLog')} /><SecurityEventTable events={events} emptyTitle={i18n.t('common.noData')} emptyDescription={i18n.t('empty.description')} /></section>
212
+ {#if !identityProvider}
213
+ <FeedbackNotice tone="warning" message={isZh ? '未配置 IdentityGovernanceProvider。企业策略、SSO 测试和安全事件保持不可用,不会伪造保存结果。' : 'IdentityGovernanceProvider is not configured. Enterprise policy, SSO tests, and security events remain unavailable instead of simulating persistence.'} />
214
+ {/if}
215
+ {#if loading}
216
+ <DataState state="loading" />
217
+ {:else if error}
218
+ <DataState state="error" title={isZh ? '企业设置加载失败' : 'Enterprise settings failed to load'} description={error} retry={loadSettings} />
219
+ {:else}
220
+ <div class="grid items-start gap-4 lg:grid-cols-2">
221
+ <SettingsGroup title={i18n.t('account.securityPolicy')} description={isZh ? '配置组织级身份、会话与审计策略。' : 'Configure organization-wide identity, session, and audit controls.'} bodyClass="space-y-4">
222
+ {#if organizationProvider}
223
+ <div class="space-y-2"><Label for="organization-name">{isZh ? '组织名称' : 'Organization name'}</Label><div class="flex gap-2"><Input id="organization-name" bind:value={organizationName} disabled={!organizationProvider.updateCurrentOrganization || savingOrganization} /><Button variant="outline" size="sm" disabled={!organizationChanged || savingOrganization} onclick={saveOrganization}>{savingOrganization ? (isZh ? '保存中...' : 'Saving...') : i18n.t('common.save')}</Button></div></div>
224
+ {/if}
225
+ <div class="grid gap-4 sm:grid-cols-2">
226
+ <div class="space-y-2"><Label for="session-timeout">{i18n.t('account.sessionTimeout')}</Label><Input id="session-timeout" type="number" min="1" step="1" bind:value={sessionTimeoutValue} disabled={!policy || saving} /></div>
227
+ <div class="space-y-2"><Label for="retention">{i18n.t('account.dataRetention')}</Label><Input id="retention" type="number" min="1" step="1" bind:value={auditRetentionValue} disabled={!policy || saving} /></div>
228
+ </div>
229
+ {#if policy && !policyNumbersValid}<FeedbackNotice tone="warning" message={isZh ? '会话超时和审计保留天数必须为正整数。' : 'Session timeout and audit retention must be positive integers.'} />{/if}
230
+ <SettingsFieldRow label={i18n.t('account.auditLogging')} description={isZh ? '由后端持久化组织级审计策略。' : 'Persist the organization audit policy through the backend provider.'} control={auditControl} separated />
231
+ <Button size="sm" disabled={!identityProvider || !policy || !policyNumbersValid || saving} onclick={saveSettings}>{saving ? (isZh ? '保存中...' : 'Saving...') : i18n.t('common.save')}</Button>
232
+ </SettingsGroup>
233
+ <SettingsGroup title={i18n.t('account.singleSignOn')} description={i18n.t('account.ssoDescription')} bodyClass="space-y-4">
234
+ <SettingsFieldRow label={i18n.t('account.enableSso')} description={isZh ? '启用后由已配置身份源执行组织登录策略。' : 'Require the configured identity provider for organization members.'} control={ssoControl} />
235
+ <Input bind:value={metadataUrl} placeholder="https://idp.example.com/.well-known/openid-configuration" aria-label={i18n.t('account.ssoProvider')} disabled={!identityProvider || testing} />
236
+ <Button variant="outline" size="sm" disabled={!identityProvider || testing} onclick={() => testConnection()}>{testing ? (isZh ? '测试中...' : 'Testing...') : i18n.t('common.test')}</Button>
237
+ </SettingsGroup>
238
+ </div>
239
+ <section class="space-y-3">
240
+ <SectionHeader title={i18n.t('account.connections')} />
241
+ {#if identityProviders.length === 0}
242
+ <DataState state="empty" title={isZh ? '尚未配置身份源' : 'No identity provider configured'} description={isZh ? '请在后端 IdentityGovernanceProvider 中创建并验证身份源。' : 'Create and verify an identity provider through the backend IdentityGovernanceProvider.'} />
243
+ {:else}
244
+ {#each identityProviders as connection (connection.id)}
245
+ <IntegrationCard integration={{ id: connection.id, name: connection.name, account: connection.protocol.toUpperCase(), connected: connection.status === 'connected', description: connection.domain ?? connection.metadataUrl }} onconnect={() => testConnection(connection)} />
246
+ {/each}
247
+ {/if}
248
+ </section>
249
+ <section class="space-y-3"><SectionHeader title={i18n.t('account.securityLog')} /><SecurityEventTable events={displayEvents} emptyTitle={i18n.t('common.noData')} emptyDescription={isZh ? '当前 Provider 未返回安全事件。' : 'The provider returned no security events.'} /></section>
250
+ {/if}
47
251
  </ContentPageShell>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { captureAdminContext } from '@svadmin/core';
2
3
  import { useTranslation } from '@svadmin/core/i18n';
3
4
  import { Button } from '../ui/button/index.js';
4
5
  import { Input } from '../ui/input/index.js';
@@ -7,7 +8,11 @@
7
8
  import ContentPageHeader from '../content/ContentPageHeader.svelte';
8
9
  import SettingsGroup from '../content/SettingsGroup.svelte';
9
10
  import SettingsFieldRow from '../content/SettingsFieldRow.svelte';
11
+ import FeedbackNotice from '../content/FeedbackNotice.svelte';
10
12
  const i18n = useTranslation();
13
+ const adminContext = captureAdminContext();
14
+ const credentialProvider = $derived(adminContext.credentialProvider);
15
+ const isZh = $derived(i18n.locale === 'zh-CN');
11
16
  const sections = [{ id: 'profile', title: i18n.t('account.basicSettings') }, { id: 'signin', title: i18n.t('account.socialSignIn') }, { id: 'preferences', title: i18n.t('account.preferences') }, { id: 'api', title: i18n.t('account.manageApi') }];
12
17
  let active = $state('profile');
13
18
  let visible = $state(true);
@@ -41,7 +46,13 @@
41
46
  {:else if active === 'preferences'}
42
47
  <SettingsGroup title={i18n.t('account.preferences')} bodyClass="grid gap-4 sm:grid-cols-3"><Input value="English" aria-label={i18n.t('account.language')} /><Input value="Asia/Shanghai" aria-label={i18n.t('account.timezone')} /><Input value="CNY" aria-label={i18n.t('account.currency')} /></SettingsGroup>
43
48
  {:else}
44
- <SettingsGroup title={i18n.t('account.manageApi')} description="Use scoped credentials and rotate them when access changes." bodyClass="flex flex-col gap-3 sm:flex-row"><Input readonly value="sv_demo_pub_9f3e********41ab" aria-label="API key" /><Button variant="outline">{i18n.t('common.copy')}</Button></SettingsGroup>
49
+ <SettingsGroup title={i18n.t('account.manageApi')} description={isZh ? '使用有作用域的真实凭据,并在权限变更时轮换。' : 'Use scoped credentials and rotate them when access changes.'} bodyClass="space-y-3">
50
+ {#if credentialProvider}
51
+ <Button variant="outline" onclick={() => void adminContext.navigate('/settings/api')}>{isZh ? '打开 API 凭据管理' : 'Open API credential management'}</Button>
52
+ {:else}
53
+ <FeedbackNotice tone="warning" message={isZh ? '未配置 CredentialProvider,不会显示或生成演示密钥。' : 'CredentialProvider is not configured. No demo credential is displayed or generated.'} />
54
+ {/if}
55
+ </SettingsGroup>
45
56
  {/if}
46
57
  </div>
47
58
  </div>
@@ -1,8 +1,51 @@
1
+ <script lang="ts" module>
2
+ let bodyScrollLockDepth = 0;
3
+ let bodyOverflowBeforeLock = "";
4
+ const openSheets: HTMLElement[] = [];
5
+
6
+ function lockBodyScroll() {
7
+ if (bodyScrollLockDepth === 0) bodyOverflowBeforeLock = document.body.style.overflow;
8
+ bodyScrollLockDepth += 1;
9
+ document.body.style.overflow = "hidden";
10
+ }
11
+
12
+ function unlockBodyScroll() {
13
+ bodyScrollLockDepth = Math.max(0, bodyScrollLockDepth - 1);
14
+ if (bodyScrollLockDepth > 0) return;
15
+ document.body.style.overflow = bodyOverflowBeforeLock;
16
+ bodyOverflowBeforeLock = "";
17
+ }
18
+
19
+ function registerSheet(panel: HTMLElement) {
20
+ openSheets.push(panel);
21
+ }
22
+
23
+ function unregisterSheet(panel: HTMLElement) {
24
+ const index = openSheets.lastIndexOf(panel);
25
+ if (index >= 0) openSheets.splice(index, 1);
26
+ }
27
+
28
+ function isTopmostSheet(panel: HTMLElement) {
29
+ return openSheets.at(-1) === panel;
30
+ }
31
+ </script>
32
+
1
33
  <script lang="ts">
2
34
  /* eslint-disable svelte/no-unused-svelte-ignore */
35
+ import { tick } from "svelte";
3
36
  import { cn, type WithElementRef } from "../../../utils.js";
4
37
  import type { HTMLAttributes } from "svelte/elements";
5
38
 
39
+ const focusableSelector = [
40
+ 'a[href]',
41
+ 'button',
42
+ 'input',
43
+ 'select',
44
+ 'textarea',
45
+ '[tabindex]:not([tabindex="-1"])',
46
+ '[contenteditable]:not([contenteditable="false"])',
47
+ ].join(",");
48
+
6
49
  type Props = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
7
50
  open?: boolean;
8
51
  side?: "left" | "right";
@@ -15,6 +58,7 @@
15
58
  side = "right",
16
59
  onClose,
17
60
  class: className,
61
+ tabindex = -1,
18
62
  children,
19
63
  ...restProps
20
64
  }: Props = $props();
@@ -24,22 +68,88 @@
24
68
  onClose?.();
25
69
  }
26
70
 
27
- function handleKeydown(e: KeyboardEvent) {
28
- if (e.key === "Escape") close();
71
+ function isContentEditableHost(element: HTMLElement) {
72
+ const contentEditable = element.getAttribute("contenteditable");
73
+ const isEditable = element.isContentEditable || (contentEditable !== null && contentEditable.toLowerCase() !== "false");
74
+ return isEditable && !element.parentElement?.isContentEditable;
29
75
  }
30
76
 
31
- $effect(() => {
32
- if (open) {
33
- document.addEventListener("keydown", handleKeydown);
34
- document.body.style.overflow = "hidden";
35
- return () => {
36
- document.removeEventListener("keydown", handleKeydown);
37
- document.body.style.overflow = "";
38
- };
77
+ function isVisible(element: HTMLElement) {
78
+ for (let current: HTMLElement | null = element; current; current = current.parentElement) {
79
+ const style = getComputedStyle(current);
80
+ if (style.display === "none" || style.visibility === "hidden") return false;
81
+ if (current === ref) break;
39
82
  }
83
+ return true;
84
+ }
85
+
86
+ function isTabbable(element: HTMLElement) {
87
+ if (element.matches(":disabled, [aria-disabled='true']")) return false;
88
+ if (element.closest("[hidden], [inert], [aria-hidden='true']")) return false;
89
+ if (element.hasAttribute("tabindex") && element.tabIndex < 0) return false;
90
+ if (!element.hasAttribute("tabindex") && !isContentEditableHost(element) && element.tabIndex < 0) return false;
91
+ return isVisible(element);
92
+ }
93
+
94
+ function tabbableElements() {
95
+ return ref ? [...ref.querySelectorAll<HTMLElement>(focusableSelector)].filter(isTabbable) : [];
96
+ }
97
+
98
+ function handleKeydown(event: KeyboardEvent) {
99
+ if (event.defaultPrevented || !open || !ref || !isTopmostSheet(ref)) return;
100
+ if (event.key === "Escape") {
101
+ event.preventDefault();
102
+ close();
103
+ return;
104
+ }
105
+ if (event.key !== "Tab") return;
106
+
107
+ const tabbable = tabbableElements();
108
+ if (tabbable.length === 0) {
109
+ event.preventDefault();
110
+ ref.focus();
111
+ return;
112
+ }
113
+
114
+ const first = tabbable[0];
115
+ const last = tabbable.at(-1);
116
+ const activeIndex = tabbable.indexOf(document.activeElement as HTMLElement);
117
+ if (!ref.contains(document.activeElement) || activeIndex === -1) {
118
+ event.preventDefault();
119
+ (event.shiftKey ? last : first)?.focus();
120
+ } else if (event.shiftKey && activeIndex === 0) {
121
+ event.preventDefault();
122
+ last?.focus();
123
+ } else if (!event.shiftKey && activeIndex === tabbable.length - 1) {
124
+ event.preventDefault();
125
+ first.focus();
126
+ }
127
+ }
128
+
129
+ $effect(() => {
130
+ if (!open || !ref) return;
131
+ const panel = ref;
132
+ const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
133
+ registerSheet(panel);
134
+ lockBodyScroll();
135
+ void tick().then(() => {
136
+ if (open && ref === panel && isTopmostSheet(panel)) {
137
+ (tabbableElements()[0] ?? panel).focus();
138
+ }
139
+ });
140
+ return () => {
141
+ unregisterSheet(panel);
142
+ unlockBodyScroll();
143
+ const remainingSheet = openSheets.at(-1);
144
+ if (!remainingSheet || (previousFocus && remainingSheet.contains(previousFocus))) {
145
+ previousFocus?.focus();
146
+ }
147
+ };
40
148
  });
41
149
  </script>
42
150
 
151
+ <svelte:document onkeydown={handleKeydown} />
152
+
43
153
  {#if open}
44
154
  <!-- Overlay -->
45
155
  <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
@@ -50,9 +160,11 @@
50
160
  ></div>
51
161
 
52
162
  <!-- Sheet panel -->
163
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
53
164
  <div
54
165
  bind:this={ref}
55
166
  data-slot="sheet"
167
+ {tabindex}
56
168
  class={cn(
57
169
  "fixed z-50 flex translate-x-0 flex-col gap-4 bg-background p-6 shadow-lg",
58
170
  side === "right"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.47.1",
3
+ "version": "0.48.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -39,7 +39,7 @@
39
39
  "./components/*": "./dist/components/*"
40
40
  },
41
41
  "peerDependencies": {
42
- "@svadmin/core": "^0.38.0",
42
+ "@svadmin/core": "^0.39.0",
43
43
  "@tanstack/svelte-query": "^6.1.38",
44
44
  "highlight.js": "^11.11.1",
45
45
  "isomorphic-dompurify": "^3.22.0",