@svadmin/ui 0.46.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/components/AboutSettings.svelte +21 -96
  2. package/dist/components/AdminApp.svelte +20 -0
  3. package/dist/components/AdminApp.svelte.d.ts +5 -1
  4. package/dist/components/ApiSettings.svelte +153 -246
  5. package/dist/components/AppearanceSettings.svelte +36 -165
  6. package/dist/components/AutoForm.svelte +21 -3
  7. package/dist/components/DetailDrawer.svelte +74 -0
  8. package/dist/components/DetailDrawer.svelte.d.ts +16 -0
  9. package/dist/components/FieldRenderer.svelte +70 -2
  10. package/dist/components/FieldRenderer.svelte.d.ts +2 -0
  11. package/dist/components/IntegrationsSettings.svelte +22 -61
  12. package/dist/components/Layout.svelte +15 -1
  13. package/dist/components/NotificationsSettings.svelte +22 -103
  14. package/dist/components/PasswordInput.svelte +4 -0
  15. package/dist/components/PasswordInput.svelte.d.ts +1 -0
  16. package/dist/components/PermissionMatrix.svelte +17 -8
  17. package/dist/components/RowActions.svelte +154 -0
  18. package/dist/components/RowActions.svelte.d.ts +26 -0
  19. package/dist/components/SecuritySettings.svelte +165 -140
  20. package/dist/components/SecuritySettings.svelte.d.ts +1 -2
  21. package/dist/components/SettingsPage.svelte +12 -2
  22. package/dist/components/account/CompanyProfilePage.svelte +10 -4
  23. package/dist/components/account/SettingsEnterprisePage.svelte +227 -23
  24. package/dist/components/account/SettingsSidebarPage.svelte +12 -1
  25. package/dist/components/account/UserProfilePage.svelte +5 -4
  26. package/dist/components/content/ApiKeyList.svelte +30 -5
  27. package/dist/components/content/ApiKeyList.svelte.d.ts +3 -0
  28. package/dist/components/content/WorkspaceLayout.svelte +7 -4
  29. package/dist/components/content/WorkspaceLayout.svelte.d.ts +1 -0
  30. package/dist/components/ui/sheet/sheet.svelte +122 -10
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.js +2 -0
  33. package/package.json +5 -2
@@ -1,279 +1,186 @@
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 { AlertCircle, Calendar, Check, Copy, Key, Plus, ShieldCheck, Trash2, Webhook } from '@lucide/svelte';
5
- import * as Card from './ui/card/index.js';
6
- import { Badge } from './ui/badge/index.js';
4
+ import { Check, Copy, Plus, ShieldCheck, Trash2 } from '@lucide/svelte';
7
5
  import { Button } from './ui/button/index.js';
8
6
  import { Checkbox } from './ui/checkbox/index.js';
9
7
  import { Input } from './ui/input/index.js';
10
8
  import { Label } from './ui/label/index.js';
9
+ import ApiKeyList from './content/ApiKeyList.svelte';
10
+ import ContentPageShell from './content/ContentPageShell.svelte';
11
+ import DataState from './content/DataState.svelte';
12
+ import FeedbackNotice from './content/FeedbackNotice.svelte';
13
+ import SettingsGroup from './content/SettingsGroup.svelte';
14
+ import WorkspaceLayout from './content/WorkspaceLayout.svelte';
11
15
 
12
16
  const i18n = useTranslation();
17
+ const adminContext = captureAdminContext();
18
+ const credentialProvider = $derived(adminContext.credentialProvider);
19
+ const isZh = $derived(i18n.locale === 'zh-CN');
13
20
 
14
- interface ApiKey {
15
- id: string;
16
- name: string;
17
- maskedToken: string;
18
- created: string;
19
- permissions: string[];
20
- }
21
-
22
- let keys = $state<ApiKey[]>([
23
- { id: 'prod-sync', name: 'Production Sync Service', maskedToken: 'sv_demo_prod_••••_4d3e', created: '2026-03-10', permissions: ['Read', 'Write'] },
24
- { id: 'reporting', name: 'Internal Reporting Tool', maskedToken: 'sv_demo_report_••••_9e8f', created: '2026-05-18', permissions: ['Read'] },
25
- ]);
26
-
21
+ let keys = $state.raw<ApiCredentialSummary[]>([]);
22
+ let webhooks = $state.raw<WebhookSummary[]>([]);
27
23
  let newKeyName = $state('');
28
24
  let newKeyPermissions = $state({ Read: true, Write: false, Delete: false, Admin: false });
29
- let generatedToken = $state('');
30
- let showCreatedToken = $state(false);
31
- let copiedKeyId = $state<string | null>(null);
32
-
33
- interface WebhookEntry {
34
- id: string;
35
- name: string;
36
- url: string;
37
- eventType: string;
38
- }
39
-
40
- let webhooks = $state<WebhookEntry[]>([
41
- { id: 'wh-1', name: 'Order Events', url: 'https://api.acme.com/hooks/orders', eventType: 'resource.updated' },
42
- ]);
25
+ let generatedSecret = $state('');
26
+ let copied = $state(false);
43
27
  let newWebhookName = $state('');
44
28
  let newWebhookUrl = $state('');
45
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;
46
34
 
47
- function handleAddWebhook(event: SubmitEvent) {
48
- event.preventDefault();
49
- if (!newWebhookName.trim() || !newWebhookUrl.trim()) return;
50
- webhooks = [
51
- ...webhooks,
52
- { id: Date.now().toString(), name: newWebhookName.trim(), url: newWebhookUrl.trim(), eventType: newWebhookEvent },
53
- ];
54
- newWebhookName = '';
55
- newWebhookUrl = '';
35
+ function message(error: unknown): string {
36
+ return error instanceof Error ? error.message : String(error);
56
37
  }
57
38
 
58
- function deleteWebhook(id: string) {
59
- webhooks = webhooks.filter(w => w.id !== id);
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
+ }
60
62
  }
61
63
 
62
- function generateDemoToken() {
63
- const randomHex = Array.from({ length: 24 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
64
- return `sv_demo_${randomHex}`;
65
- }
64
+ $effect(() => {
65
+ void credentialProvider;
66
+ void adminContext.tenantCacheKey?.__svadminTenant;
67
+ void loadCredentials();
68
+ return () => { requestId += 1; };
69
+ });
66
70
 
67
- function handleGenerateKey(event: SubmitEvent) {
71
+ async function handleAddWebhook(event: SubmitEvent): Promise<void> {
68
72
  event.preventDefault();
69
- if (!newKeyName.trim()) return;
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
+ }
90
+ }
70
91
 
71
- const token = generateDemoToken();
72
- const permissions = Object.entries(newKeyPermissions)
73
- .filter(([, enabled]) => enabled)
74
- .map(([name]) => name);
92
+ async function handleGenerateKey(event: SubmitEvent): Promise<void> {
93
+ event.preventDefault();
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
+ }
75
115
 
76
- keys = [
77
- ...keys,
78
- {
79
- id: Date.now().toString(),
80
- name: newKeyName.trim(),
81
- maskedToken: `${token.slice(0, 14)}••••${token.slice(-4)}`,
82
- created: new Date().toISOString().split('T')[0],
83
- permissions,
84
- },
85
- ];
86
- generatedToken = token;
87
- showCreatedToken = true;
88
- newKeyName = '';
89
- newKeyPermissions = { Read: true, Write: false, Delete: false, Admin: false };
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
+ }
90
130
  }
91
131
 
92
- function deleteKey(id: string) {
93
- keys = keys.filter((key) => key.id !== id);
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
+ }
94
146
  }
95
147
 
96
- async function copyToken(token: string, id: string) {
148
+ async function copySecret(): Promise<void> {
97
149
  try {
98
- await navigator.clipboard.writeText(token);
99
- copiedKeyId = id;
100
- setTimeout(() => {
101
- if (copiedKeyId === id) copiedKeyId = null;
102
- }, 2000);
150
+ await navigator.clipboard.writeText(generatedSecret);
151
+ copied = true;
103
152
  } catch {
104
- copiedKeyId = null;
153
+ copied = false;
105
154
  }
106
155
  }
107
156
  </script>
108
157
 
109
- <div class="space-y-6">
110
- <div>
111
- <h2 class="text-xl font-semibold text-foreground">{i18n.t('settings.api')}</h2>
112
- <p class="mt-1 text-sm text-muted-foreground">{i18n.t('settings.apiDescription')}</p>
113
- </div>
114
-
115
- <div class="grid gap-6 lg:grid-cols-3">
116
- <Card.Card class="lg:col-span-1">
117
- <Card.CardHeader>
118
- <Card.CardTitle class="flex items-center gap-2 text-base">
119
- <Plus class="h-4 w-4" />
120
- {i18n.t('api.generateTitle')}
121
- </Card.CardTitle>
122
- <Card.CardDescription>{i18n.t('api.generateDesc')}</Card.CardDescription>
123
- </Card.CardHeader>
124
- <Card.CardContent>
125
- <form onsubmit={handleGenerateKey} class="space-y-4">
126
- <div class="space-y-2">
127
- <Label for="api-key-name">{i18n.t('api.keyName')}</Label>
128
- <Input id="api-key-name" placeholder="CI deployment token" bind:value={newKeyName} required />
129
- </div>
130
-
131
- <div class="space-y-3">
132
- <Label>{i18n.t('api.permissions')}</Label>
133
- {#each Object.keys(newKeyPermissions) as permission (permission)}
134
- <div class="flex items-center gap-2">
135
- <Checkbox id={`perm-${permission}`} bind:checked={newKeyPermissions[permission as keyof typeof newKeyPermissions]} />
136
- <Label for={`perm-${permission}`} class="cursor-pointer text-sm font-normal">{permission}</Label>
137
- </div>
138
- {/each}
139
- </div>
140
-
141
- <Button type="submit" class="w-full">{i18n.t('api.generateButton')}</Button>
142
- </form>
143
- </Card.CardContent>
144
- </Card.Card>
145
-
146
- <Card.Card class="lg:col-span-2">
147
- <Card.CardHeader>
148
- <Card.CardTitle class="flex items-center gap-2 text-base">
149
- <Key class="h-4 w-4 text-muted-foreground" />
150
- {i18n.t('api.activeKeysTitle')}
151
- </Card.CardTitle>
152
- <Card.CardDescription>{i18n.t('api.activeKeysDesc')}</Card.CardDescription>
153
- </Card.CardHeader>
154
- <Card.CardContent>
155
- {#if showCreatedToken}
156
- <div class="mb-6 space-y-3 rounded-lg border border-green-500/20 bg-green-500/5 p-4">
157
- <div class="flex items-start gap-2 text-sm text-green-700 dark:text-green-300">
158
- <ShieldCheck class="mt-0.5 h-5 w-5 shrink-0" />
159
- <div>
160
- <h4 class="font-semibold">{i18n.t('api.successTitle')}</h4>
161
- <p class="mt-0.5 text-xs">{i18n.t('api.successHint')}</p>
162
- </div>
163
- </div>
164
- <div class="flex items-center gap-2">
165
- <Input readonly value={generatedToken} class="h-9 font-mono text-xs" />
166
- <Button size="sm" onclick={() => copyToken(generatedToken, 'new-token')}>
167
- {#if copiedKeyId === 'new-token'}
168
- <Check class="h-4 w-4" />
169
- {:else}
170
- <Copy class="h-4 w-4" />
171
- {/if}
172
- </Button>
173
- </div>
174
- <div class="flex justify-end">
175
- <Button variant="ghost" size="sm" onclick={() => { showCreatedToken = false; generatedToken = ''; }}>{i18n.t('common.close')}</Button>
176
- </div>
177
- </div>
178
- {/if}
179
-
180
- <div class="divide-y">
181
- {#each keys as key (key.id)}
182
- <div class="space-y-3 py-4 first:pt-0 last:pb-0">
183
- <div class="flex items-start justify-between gap-4">
184
- <div class="min-w-0 space-y-1">
185
- <h4 class="text-sm font-semibold text-foreground">{key.name}</h4>
186
- <div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
187
- <span class="font-mono">{key.maskedToken}</span>
188
- <span class="flex items-center gap-1">
189
- <Calendar class="h-3 w-3" />
190
- {key.created}
191
- </span>
192
- </div>
193
- </div>
194
- <div class="flex shrink-0 items-center gap-1">
195
- <Button variant="ghost" size="icon-sm" onclick={() => copyToken(key.maskedToken, key.id)} title={i18n.t('common.copy')}>
196
- {#if copiedKeyId === key.id}
197
- <Check class="h-4 w-4 text-green-500" />
198
- {:else}
199
- <Copy class="h-4 w-4" />
200
- {/if}
201
- </Button>
202
- <Button variant="ghost" size="icon-sm" onclick={() => deleteKey(key.id)} title={i18n.t('common.delete')}>
203
- <Trash2 class="h-4 w-4" />
204
- </Button>
205
- </div>
206
- </div>
207
- <div class="flex flex-wrap gap-1">
208
- {#each key.permissions as permission (permission)}
209
- <Badge variant="secondary" class="px-2 py-0 text-[10px]">{permission}</Badge>
210
- {/each}
211
- </div>
212
- </div>
213
- {:else}
214
- <div class="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted-foreground">
215
- <AlertCircle class="h-8 w-8 opacity-60" />
216
- <span>{i18n.t('api.noKeys')}</span>
217
- </div>
218
- {/each}
219
- </div>
220
- </Card.CardContent>
221
- </Card.Card>
222
-
223
- <!-- Webhooks -->
224
- <Card.Card>
225
- <Card.CardHeader>
226
- <Card.CardTitle class="flex items-center gap-2">
227
- <Webhook class="h-4 w-4 text-muted-foreground" />
228
- {i18n.t('api.webhooks')}
229
- </Card.CardTitle>
230
- <Card.CardDescription>{i18n.t('api.webhooksDesc')}</Card.CardDescription>
231
- </Card.CardHeader>
232
- <Card.CardContent class="space-y-4">
233
- <form class="grid gap-3 sm:grid-cols-[1fr_1fr_180px_auto]" onsubmit={handleAddWebhook}>
234
- <div class="space-y-1.5">
235
- <Label for="webhook-name">{i18n.t('api.webhookName')}</Label>
236
- <Input id="webhook-name" bind:value={newWebhookName} placeholder="Order Events" />
237
- </div>
238
- <div class="space-y-1.5">
239
- <Label for="webhook-url">{i18n.t('api.webhookUrl')}</Label>
240
- <Input id="webhook-url" type="url" bind:value={newWebhookUrl} placeholder="https://api.example.com/hooks" />
241
- </div>
242
- <div class="space-y-1.5">
243
- <Label for="webhook-event">{i18n.t('api.eventType')}</Label>
244
- <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">
245
- <option value="resource.created">resource.created</option>
246
- <option value="resource.updated">resource.updated</option>
247
- <option value="resource.deleted">resource.deleted</option>
248
- <option value="auth.login">auth.login</option>
249
- </select>
250
- </div>
251
- <div class="flex items-end">
252
- <Button type="submit" size="sm" disabled={!newWebhookName.trim() || !newWebhookUrl.trim()}>
253
- <Plus class="h-3.5 w-3.5 mr-1" />{i18n.t('api.addWebhook')}
254
- </Button>
255
- </div>
256
- </form>
257
-
258
- <div class="divide-y border-t">
259
- {#each webhooks as hook (hook.id)}
260
- <div class="flex items-center justify-between gap-4 py-3">
261
- <div class="min-w-0">
262
- <p class="truncate text-sm font-medium text-foreground">{hook.name}</p>
263
- <p class="truncate font-mono text-xs text-muted-foreground">{hook.url}</p>
264
- </div>
265
- <div class="flex shrink-0 items-center gap-2">
266
- <Badge variant="secondary" class="px-2 py-0 text-[10px]">{hook.eventType}</Badge>
267
- <Button variant="ghost" size="icon-sm" onclick={() => deleteWebhook(hook.id)} title={i18n.t('common.delete')}>
268
- <Trash2 class="h-4 w-4" />
269
- </Button>
270
- </div>
271
- </div>
272
- {:else}
273
- <p class="py-4 text-center text-sm text-muted-foreground">{i18n.t('api.noWebhooks')}</p>
274
- {/each}
275
- </div>
276
- </Card.CardContent>
277
- </Card.Card>
278
- </div>
279
- </div>
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}
160
+ <WorkspaceLayout secondaryWidth="21rem">
161
+ {#snippet summary()}
162
+ {#if generatedSecret}
163
+ <section class="rounded-lg border border-primary/20 bg-primary/5 p-4" role="status" aria-live="polite">
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>
166
+ </section>
167
+ {/if}
168
+ {/snippet}
169
+ {#snippet primary()}
170
+ <div class="space-y-6">
171
+ <SettingsGroup title={i18n.t('api.activeKeysTitle')} description={i18n.t('api.activeKeysDesc')} bodyClass="p-0">
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}
173
+ </SettingsGroup>
174
+ <SettingsGroup title={i18n.t('api.webhooks')} description={i18n.t('api.webhooksDesc')} bodyClass="space-y-5">
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}
177
+ </SettingsGroup>
178
+ </div>
179
+ {/snippet}
180
+ {#snippet secondary()}
181
+ <SettingsGroup title={i18n.t('api.generateTitle')} description={i18n.t('api.generateDesc')}>
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>
183
+ </SettingsGroup>
184
+ {/snippet}
185
+ </WorkspaceLayout>
186
+ </ContentPageShell>