@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.
- 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/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/ui/sheet/sheet.svelte +122 -10
- 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>
|
|
@@ -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}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
30
|
+
let changingPassword = $state(false);
|
|
31
|
+
let requestId = 0;
|
|
40
32
|
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
33
|
+
function message(error: unknown): string {
|
|
34
|
+
return error instanceof Error ? error.message : String(error);
|
|
35
|
+
}
|
|
44
36
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
<
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
14
|
-
import
|
|
15
|
-
import
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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=
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
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.
|
|
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",
|