@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.
- package/dist/components/AdminApp.svelte +20 -0
- package/dist/components/AdminApp.svelte.d.ts +5 -1
- package/dist/components/ApiSettings.svelte +137 -50
- package/dist/components/AutoTable.svelte +7 -3
- package/dist/components/AutoTable.svelte.d.ts +1 -0
- package/dist/components/Layout.svelte +15 -1
- package/dist/components/PasswordInput.svelte +4 -0
- package/dist/components/PasswordInput.svelte.d.ts +1 -0
- package/dist/components/SecuritySettings.svelte +156 -91
- package/dist/components/SecuritySettings.svelte.d.ts +1 -2
- package/dist/components/account/SettingsEnterprisePage.svelte +227 -23
- package/dist/components/account/SettingsSidebarPage.svelte +12 -1
- package/dist/components/content/WorkspaceActionBar.svelte +33 -0
- package/dist/components/content/WorkspaceActionBar.svelte.d.ts +12 -0
- package/dist/components/content/WorkspaceInspector.svelte +57 -0
- package/dist/components/content/WorkspaceInspector.svelte.d.ts +15 -0
- package/dist/components/content/WorkspaceLayout.svelte +12 -1
- package/dist/components/content/WorkspaceLayout.svelte.d.ts +2 -0
- package/dist/components/content/WorkspaceSplitPane.svelte +34 -0
- package/dist/components/content/WorkspaceSplitPane.svelte.d.ts +11 -0
- package/dist/components/content/WorkspaceStageStepper.svelte +71 -0
- package/dist/components/content/WorkspaceStageStepper.svelte.d.ts +17 -0
- package/dist/components/content/index.d.ts +4 -0
- package/dist/components/content/index.js +4 -0
- package/dist/components/ui/sheet/sheet.svelte +122 -10
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- 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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
24
|
-
let
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
function handleGenerateKey(event: SubmitEvent) {
|
|
91
|
+
|
|
92
|
+
async function handleGenerateKey(event: SubmitEvent): Promise<void> {
|
|
40
93
|
event.preventDefault();
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
async function
|
|
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(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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="
|
|
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(
|
|
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)}
|
|
@@ -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}
|