@svadmin/ui 0.47.1 → 0.49.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 (28) hide show
  1. package/dist/components/AdminApp.svelte +20 -0
  2. package/dist/components/AdminApp.svelte.d.ts +5 -1
  3. package/dist/components/ApiSettings.svelte +137 -50
  4. package/dist/components/AutoTable.svelte +7 -3
  5. package/dist/components/AutoTable.svelte.d.ts +1 -0
  6. package/dist/components/Layout.svelte +15 -1
  7. package/dist/components/PasswordInput.svelte +4 -0
  8. package/dist/components/PasswordInput.svelte.d.ts +1 -0
  9. package/dist/components/SecuritySettings.svelte +156 -91
  10. package/dist/components/SecuritySettings.svelte.d.ts +1 -2
  11. package/dist/components/account/SettingsEnterprisePage.svelte +227 -23
  12. package/dist/components/account/SettingsSidebarPage.svelte +12 -1
  13. package/dist/components/content/WorkspaceActionBar.svelte +33 -0
  14. package/dist/components/content/WorkspaceActionBar.svelte.d.ts +12 -0
  15. package/dist/components/content/WorkspaceInspector.svelte +57 -0
  16. package/dist/components/content/WorkspaceInspector.svelte.d.ts +15 -0
  17. package/dist/components/content/WorkspaceLayout.svelte +12 -1
  18. package/dist/components/content/WorkspaceLayout.svelte.d.ts +2 -0
  19. package/dist/components/content/WorkspaceSplitPane.svelte +34 -0
  20. package/dist/components/content/WorkspaceSplitPane.svelte.d.ts +11 -0
  21. package/dist/components/content/WorkspaceStageStepper.svelte +71 -0
  22. package/dist/components/content/WorkspaceStageStepper.svelte.d.ts +17 -0
  23. package/dist/components/content/index.d.ts +4 -0
  24. package/dist/components/content/index.js +4 -0
  25. package/dist/components/ui/sheet/sheet.svelte +122 -10
  26. package/dist/index.d.ts +5 -0
  27. package/dist/index.js +4 -0
  28. package/package.json +2 -2
@@ -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>
@@ -75,6 +75,7 @@
75
75
  interface Props {
76
76
  resourceName: string;
77
77
  selectable?: boolean;
78
+ density?: 'compact' | 'comfortable';
78
79
  /** Custom header actions (right side) */
79
80
  headerActions?: Snippet;
80
81
  /** Custom cell renderers by field key */
@@ -98,6 +99,7 @@
98
99
  let {
99
100
  resourceName,
100
101
  selectable = true,
102
+ density = 'comfortable',
101
103
  headerActions,
102
104
  columns: customColumns,
103
105
  defaultCellRenderer,
@@ -109,6 +111,8 @@
109
111
  sorters: externalSorters,
110
112
  }: Props = $props();
111
113
  const adminContext = captureAdminContext();
114
+ const headerDensityClass = $derived(density === 'compact' ? 'h-8 py-1.5 text-[0.6875rem]' : 'h-9 py-2.5 text-xs');
115
+ const cellDensityClass = $derived(density === 'compact' ? 'h-8 px-2 py-1 text-xs' : 'h-10 p-2 text-sm');
112
116
 
113
117
  const resource = $derived(getResource(resourceName));
114
118
  const primaryKey = $derived(resource.primaryKey ?? 'id');
@@ -682,7 +686,7 @@
682
686
  </div>
683
687
 
684
688
  <!-- Table (TanStack-powered) -->
685
- <div class="rounded-[24px] bg-card/80 backdrop-blur-3xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] overflow-hidden ring-1 ring-border/30" role="region" aria-label="{resource.label} {i18n.t('common.list')}">
689
+ <div class="overflow-hidden rounded-lg border border-border bg-card shadow-sm" role="region" aria-label="{resource.label} {i18n.t('common.list')}" data-table-density={density}>
686
690
  {#if query.isLoading}
687
691
  <div class="p-4 space-y-3">
688
692
  <div class="flex gap-4 mb-2">
@@ -719,7 +723,7 @@
719
723
  {@const header = col.header as typeof headerGroup.headers[0]}
720
724
  <Table.Head
721
725
  {...dragProps}
722
- class={cn("bg-transparent hover:bg-muted/10 border-b border-border/20 uppercase tracking-[0.12em] text-[10px] text-muted-foreground/70 font-semibold py-4", dragProps.class)}
726
+ class={cn('border-b border-border bg-muted/25 font-medium tracking-normal text-muted-foreground hover:bg-muted/40', headerDensityClass, dragProps.class)}
723
727
  style={header_getSize(header) != null && header_getSize(header) !== 150 ? `width:${header_getSize(header)}px` : undefined}
724
728
  >
725
729
  {#if header.id === '_select'}
@@ -764,7 +768,7 @@
764
768
  {#snippet child({ props })}
765
769
  <Table.Row {...props} class="transition-all duration-300 border-b border-border/10 {row_getIsSelected(row) ? 'bg-primary/5' : 'hover:bg-muted/20'}">
766
770
  {#each visibleCells as cell, _i (_i)}
767
- <Table.Cell>
771
+ <Table.Cell class={cellDensityClass}>
768
772
  {#if cell.column.id === '_select'}
769
773
  <Checkbox
770
774
  checked={row_getIsSelected(row)}
@@ -3,6 +3,7 @@ import type { Snippet } from 'svelte';
3
3
  interface Props {
4
4
  resourceName: string;
5
5
  selectable?: boolean;
6
+ density?: 'compact' | 'comfortable';
6
7
  /** Custom header actions (right side) */
7
8
  headerActions?: Snippet;
8
9
  /** Custom cell renderers by field key */
@@ -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">;