@svadmin/ui 0.46.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/components/AboutSettings.svelte +21 -96
  2. package/dist/components/AdminApp.svelte +20 -0
  3. package/dist/components/AdminApp.svelte.d.ts +5 -1
  4. package/dist/components/ApiSettings.svelte +153 -246
  5. package/dist/components/AppearanceSettings.svelte +36 -165
  6. package/dist/components/AutoForm.svelte +21 -3
  7. package/dist/components/DetailDrawer.svelte +74 -0
  8. package/dist/components/DetailDrawer.svelte.d.ts +16 -0
  9. package/dist/components/FieldRenderer.svelte +70 -2
  10. package/dist/components/FieldRenderer.svelte.d.ts +2 -0
  11. package/dist/components/IntegrationsSettings.svelte +22 -61
  12. package/dist/components/Layout.svelte +15 -1
  13. package/dist/components/NotificationsSettings.svelte +22 -103
  14. package/dist/components/PasswordInput.svelte +4 -0
  15. package/dist/components/PasswordInput.svelte.d.ts +1 -0
  16. package/dist/components/PermissionMatrix.svelte +17 -8
  17. package/dist/components/RowActions.svelte +154 -0
  18. package/dist/components/RowActions.svelte.d.ts +26 -0
  19. package/dist/components/SecuritySettings.svelte +165 -140
  20. package/dist/components/SecuritySettings.svelte.d.ts +1 -2
  21. package/dist/components/SettingsPage.svelte +12 -2
  22. package/dist/components/account/CompanyProfilePage.svelte +10 -4
  23. package/dist/components/account/SettingsEnterprisePage.svelte +227 -23
  24. package/dist/components/account/SettingsSidebarPage.svelte +12 -1
  25. package/dist/components/account/UserProfilePage.svelte +5 -4
  26. package/dist/components/content/ApiKeyList.svelte +30 -5
  27. package/dist/components/content/ApiKeyList.svelte.d.ts +3 -0
  28. package/dist/components/content/WorkspaceLayout.svelte +7 -4
  29. package/dist/components/content/WorkspaceLayout.svelte.d.ts +1 -0
  30. package/dist/components/ui/sheet/sheet.svelte +122 -10
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.js +2 -0
  33. package/package.json +5 -2
@@ -11,6 +11,7 @@
11
11
  import MemberList from '../content/MemberList.svelte';
12
12
  import SectionHeader from '../content/SectionHeader.svelte';
13
13
  import SettingsGroup from '../content/SettingsGroup.svelte';
14
+ import WorkspaceLayout from '../content/WorkspaceLayout.svelte';
14
15
  import type { MemberSummary } from '../content/MemberList.svelte';
15
16
  const i18n = useTranslation();
16
17
  let companyName = $state('Acme Corporation');
@@ -31,11 +32,16 @@
31
32
  <Button onclick={save} disabled={saving} size="sm">{#if saving}<Loader2 class="size-3.5 animate-spin" />{/if}{i18n.t('common.save')}</Button>
32
33
  {/snippet}
33
34
 
35
+ {#snippet primary()}
36
+ <SettingsGroup title={i18n.t('account.basicSettings')} description={i18n.t('account.companyProfileDescription')} bodyClass="space-y-4"><div class="grid gap-4 sm:grid-cols-2"><div class="space-y-2"><Label for="company-name">{i18n.t('profile.companyName')}</Label><Input id="company-name" bind:value={companyName} /></div><div class="space-y-2"><Label for="company-industry">{i18n.t('profile.industry')}</Label><Input id="company-industry" bind:value={industry} /></div><div class="space-y-2 sm:col-span-2"><Label for="company-website">{i18n.t('profile.website')}</Label><Input id="company-website" bind:value={website} type="url" /></div></div><div class="space-y-2"><Label for="company-description">{i18n.t('profile.companyDescription')}</Label><Textarea id="company-description" bind:value={description} rows={4} /></div></SettingsGroup>
37
+ {/snippet}
38
+
39
+ {#snippet secondary()}
40
+ <SettingsGroup title={i18n.t('profile.companyName')} description={industry} bodyClass="space-y-4"><p class="text-lg font-semibold text-foreground">{companyName}</p><DescriptionList columns={1} items={[{ label: i18n.t('profile.website'), value: website, href: website }, { label: i18n.t('profile.employees'), value: '1,250' }, { label: i18n.t('profile.founded'), value: '2015' }]} /></SettingsGroup>
41
+ {/snippet}
42
+
34
43
  <ContentPageShell pageId="account-company-profile" width="wide">
35
44
  <ContentPageHeader title={i18n.t('account.companyProfile')} description={i18n.t('account.companyProfileDescription')} actions={headerActions} />
36
- <div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
37
- <SettingsGroup title={i18n.t('account.basicSettings')} description={i18n.t('account.companyProfileDescription')} bodyClass="space-y-4"><div class="grid gap-4 sm:grid-cols-2"><div class="space-y-2"><Label for="company-name">{i18n.t('profile.companyName')}</Label><Input id="company-name" bind:value={companyName} /></div><div class="space-y-2"><Label for="company-industry">{i18n.t('profile.industry')}</Label><Input id="company-industry" bind:value={industry} /></div><div class="space-y-2 sm:col-span-2"><Label for="company-website">{i18n.t('profile.website')}</Label><Input id="company-website" bind:value={website} type="url" /></div></div><div class="space-y-2"><Label for="company-description">{i18n.t('profile.companyDescription')}</Label><Textarea id="company-description" bind:value={description} rows={4} /></div></SettingsGroup>
38
- <SettingsGroup title={i18n.t('profile.companyName')} description={industry} bodyClass="space-y-4"><p class="text-lg font-semibold text-foreground">{companyName}</p><DescriptionList columns={1} items={[{ label: i18n.t('profile.website'), value: website, href: website }, { label: i18n.t('profile.employees'), value: '1,250' }, { label: i18n.t('profile.founded'), value: '2015' }]} /></SettingsGroup>
39
- </div>
45
+ <WorkspaceLayout {primary} {secondary} secondaryWidth="20rem" />
40
46
  <section class="space-y-3"><SectionHeader title={i18n.t('profileSections.members')} /><MemberList {members} emptyTitle={i18n.t('common.noData')} emptyDescription={i18n.t('empty.description')} /></section>
41
47
  </ContentPageShell>
@@ -1,4 +1,12 @@
1
1
  <script lang="ts">
2
+ import {
3
+ captureAdminContext,
4
+ notifyWithProvider,
5
+ type EnterpriseSecurityEvent,
6
+ type EnterpriseSecurityPolicy,
7
+ type IdentityProviderSummary,
8
+ type Organization,
9
+ } from '@svadmin/core';
2
10
  import { useTranslation } from '@svadmin/core/i18n';
3
11
  import { Button } from '../ui/button/index.js';
4
12
  import { Input } from '../ui/input/index.js';
@@ -10,38 +18,234 @@
10
18
  import SettingsGroup from '../content/SettingsGroup.svelte';
11
19
  import SettingsFieldRow from '../content/SettingsFieldRow.svelte';
12
20
  import IntegrationCard from '../content/IntegrationCard.svelte';
13
- import type { IntegrationSummary } from '../content/IntegrationCard.svelte';
14
- import SecurityEventTable from '../content/SecurityEventTable.svelte';
15
- import type { SecurityEvent } from '../content/SecurityEventTable.svelte';
21
+ import SecurityEventTable, { type SecurityEvent } from '../content/SecurityEventTable.svelte';
22
+ import DataState from '../content/DataState.svelte';
23
+ import FeedbackNotice from '../content/FeedbackNotice.svelte';
24
+
16
25
  const i18n = useTranslation();
17
- let ssoEnabled = $state(false);
18
- let auditLogging = $state(true);
19
- const identity: IntegrationSummary[] = [{ id: 'sso', name: 'Enterprise identity provider', description: i18n.t('account.ssoDescription'), account: 'SAML / OIDC', connected: false }];
20
- const events: SecurityEvent[] = [{ id: '1', event: 'SSO policy updated', actor: 'Alex Chen', location: 'Shanghai, CN', createdAt: 'Today, 09:12', severity: 'info' }, { id: '2', event: 'Audit log retention changed', actor: 'Alex Chen', location: 'Shanghai, CN', createdAt: 'Yesterday, 16:40', severity: 'warning' }];
26
+ const adminContext = captureAdminContext();
27
+ const identityProvider = $derived(adminContext.identityGovernanceProvider);
28
+ const organizationProvider = $derived(adminContext.organizationProvider);
29
+ const isZh = $derived(i18n.locale === 'zh-CN');
30
+
31
+ let policy = $state<EnterpriseSecurityPolicy | null>(null);
32
+ let organization = $state<Organization | null>(null);
33
+ let organizationName = $state('');
34
+ let sessionTimeoutValue = $state('');
35
+ let auditRetentionValue = $state('');
36
+ let identityProviders = $state.raw<IdentityProviderSummary[]>([]);
37
+ let events = $state.raw<EnterpriseSecurityEvent[]>([]);
38
+ let metadataUrl = $state('');
39
+ let loading = $state(false);
40
+ let saving = $state(false);
41
+ let savingOrganization = $state(false);
42
+ let testing = $state(false);
43
+ let error = $state<string | null>(null);
44
+ let requestId = 0;
45
+ const organizationChanged = $derived(Boolean(
46
+ organizationProvider?.updateCurrentOrganization
47
+ && organization
48
+ && organizationName.trim()
49
+ && organizationName.trim() !== organization.name,
50
+ ));
51
+ const policyNumbersValid = $derived.by(() => {
52
+ const sessionTimeout = Number(sessionTimeoutValue);
53
+ const auditRetention = Number(auditRetentionValue);
54
+ return /^\d+$/.test(sessionTimeoutValue)
55
+ && /^\d+$/.test(auditRetentionValue)
56
+ && Number.isSafeInteger(sessionTimeout)
57
+ && Number.isSafeInteger(auditRetention)
58
+ && sessionTimeout > 0
59
+ && auditRetention > 0;
60
+ });
61
+
62
+ function message(error: unknown): string {
63
+ return error instanceof Error ? error.message : String(error);
64
+ }
65
+
66
+ async function loadSettings(): Promise<void> {
67
+ const currentRequest = ++requestId;
68
+ const scopedIdentityProvider = identityProvider;
69
+ const scopedOrganizationProvider = organizationProvider;
70
+ const context = adminContext.enterpriseRequestContext;
71
+ policy = null;
72
+ organization = null;
73
+ organizationName = '';
74
+ sessionTimeoutValue = '';
75
+ auditRetentionValue = '';
76
+ identityProviders = [];
77
+ events = [];
78
+ error = null;
79
+ loading = false;
80
+ saving = false;
81
+ savingOrganization = false;
82
+ testing = false;
83
+ if (!scopedIdentityProvider && !scopedOrganizationProvider) return;
84
+ loading = true;
85
+ try {
86
+ const [nextPolicy, nextIdentityProviders, nextEvents, nextOrganization] = await Promise.all([
87
+ scopedIdentityProvider?.getSecurityPolicy(context) ?? null,
88
+ scopedIdentityProvider?.listIdentityProviders(context) ?? [],
89
+ scopedIdentityProvider?.listSecurityEvents?.({ page: 1, pageSize: 20 }, context) ?? { data: [], total: 0 },
90
+ scopedOrganizationProvider?.getCurrentOrganization(context) ?? null,
91
+ ]);
92
+ if (currentRequest !== requestId) return;
93
+ policy = nextPolicy;
94
+ sessionTimeoutValue = nextPolicy ? String(nextPolicy.sessionTimeoutMinutes) : '';
95
+ auditRetentionValue = nextPolicy ? String(nextPolicy.auditRetentionDays) : '';
96
+ identityProviders = nextIdentityProviders;
97
+ events = nextEvents.data;
98
+ organization = nextOrganization;
99
+ organizationName = nextOrganization?.name ?? '';
100
+ metadataUrl = nextIdentityProviders[0]?.metadataUrl ?? '';
101
+ } catch (caught) {
102
+ if (currentRequest === requestId) error = message(caught);
103
+ } finally {
104
+ if (currentRequest === requestId) loading = false;
105
+ }
106
+ }
107
+
108
+ $effect(() => {
109
+ void identityProvider;
110
+ void organizationProvider;
111
+ void adminContext.tenantCacheKey?.__svadminTenant;
112
+ void loadSettings();
113
+ return () => { requestId += 1; };
114
+ });
115
+
116
+ async function saveSettings(): Promise<void> {
117
+ const provider = identityProvider;
118
+ const currentRequest = requestId;
119
+ const context = adminContext.enterpriseRequestContext;
120
+ if (!provider || !policy || !policyNumbersValid) return;
121
+ saving = true;
122
+ try {
123
+ const updatedPolicy = await provider.updateSecurityPolicy({
124
+ ...policy,
125
+ sessionTimeoutMinutes: Number(sessionTimeoutValue),
126
+ auditRetentionDays: Number(auditRetentionValue),
127
+ }, context);
128
+ if (currentRequest !== requestId) return;
129
+ policy = updatedPolicy;
130
+ sessionTimeoutValue = String(updatedPolicy.sessionTimeoutMinutes);
131
+ auditRetentionValue = String(updatedPolicy.auditRetentionDays);
132
+ notifyWithProvider({ type: 'success', message: isZh ? '企业策略已保存' : 'Enterprise policy saved' }, adminContext.notificationProvider);
133
+ } catch (caught) {
134
+ if (currentRequest !== requestId) return;
135
+ notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
136
+ } finally {
137
+ if (currentRequest === requestId) saving = false;
138
+ }
139
+ }
140
+
141
+ async function saveOrganization(): Promise<void> {
142
+ const provider = organizationProvider;
143
+ const currentRequest = requestId;
144
+ const context = adminContext.enterpriseRequestContext;
145
+ const name = organizationName.trim();
146
+ if (!provider?.updateCurrentOrganization || !organization || !name || name === organization.name) return;
147
+ savingOrganization = true;
148
+ try {
149
+ const updatedOrganization = await provider.updateCurrentOrganization({ name }, context);
150
+ if (currentRequest !== requestId) return;
151
+ organization = updatedOrganization;
152
+ organizationName = updatedOrganization.name;
153
+ notifyWithProvider({ type: 'success', message: isZh ? '组织信息已保存' : 'Organization saved' }, adminContext.notificationProvider);
154
+ } catch (caught) {
155
+ if (currentRequest !== requestId) return;
156
+ notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
157
+ } finally {
158
+ if (currentRequest === requestId) savingOrganization = false;
159
+ }
160
+ }
161
+
162
+ async function testConnection(target?: IdentityProviderSummary): Promise<void> {
163
+ const provider = identityProvider;
164
+ const currentRequest = requestId;
165
+ const context = adminContext.enterpriseRequestContext;
166
+ if (!provider) return;
167
+ testing = true;
168
+ try {
169
+ const selectedProvider = target ?? identityProviders[0];
170
+ const result = await provider.testIdentityProvider({
171
+ id: selectedProvider?.id,
172
+ protocol: selectedProvider?.protocol,
173
+ metadataUrl: target
174
+ ? target.metadataUrl
175
+ : (metadataUrl.trim() || selectedProvider?.metadataUrl),
176
+ }, context);
177
+ if (currentRequest !== requestId) return;
178
+ notifyWithProvider({
179
+ type: result.success ? 'success' : 'error',
180
+ message: result.success
181
+ ? (isZh ? '身份源连接测试通过' : 'Identity provider connection succeeded')
182
+ : (result.error?.message ?? (isZh ? '身份源连接测试失败' : 'Identity provider connection failed')),
183
+ }, adminContext.notificationProvider);
184
+ } catch (caught) {
185
+ if (currentRequest !== requestId) return;
186
+ notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
187
+ } finally {
188
+ if (currentRequest === requestId) testing = false;
189
+ }
190
+ }
191
+
192
+ const displayEvents = $derived(events.map((event): SecurityEvent => ({
193
+ id: event.id,
194
+ event: event.event,
195
+ actor: event.actor,
196
+ location: event.location,
197
+ createdAt: event.createdAt instanceof Date ? event.createdAt.toLocaleString() : String(event.createdAt),
198
+ severity: event.severity === 'critical' ? 'danger' : (event.severity ?? 'info'),
199
+ })));
21
200
  </script>
22
201
 
23
202
  {#snippet auditControl()}
24
- <Switch bind:checked={auditLogging} aria-label={i18n.t('account.auditLogging')} />
203
+ <Switch checked={policy?.auditLoggingEnabled ?? false} disabled={!policy || saving} aria-label={i18n.t('account.auditLogging')} onCheckedChange={(checked) => { if (policy) policy = { ...policy, auditLoggingEnabled: checked }; }} />
25
204
  {/snippet}
26
205
 
27
206
  {#snippet ssoControl()}
28
- <Switch bind:checked={ssoEnabled} aria-label={i18n.t('account.enableSso')} />
207
+ <Switch checked={policy?.requireSso ?? false} disabled={!policy || saving} aria-label={i18n.t('account.enableSso')} onCheckedChange={(checked) => { if (policy) policy = { ...policy, requireSso: checked }; }} />
29
208
  {/snippet}
30
209
 
31
210
  <ContentPageShell pageId="account-settings-enterprise" width="wide">
32
211
  <ContentPageHeader title={i18n.t('account.settingsEnterprise')} description={i18n.t('account.settingsEnterpriseDescription')} />
33
- <div class="grid gap-4 lg:grid-cols-2">
34
- <SettingsGroup title={i18n.t('account.securityPolicy')} description="Set organization-wide controls for authentication and sessions." bodyClass="space-y-4">
35
- <div class="grid gap-4 sm:grid-cols-2"><div class="space-y-2"><Label for="session-timeout">{i18n.t('account.sessionTimeout')}</Label><Input id="session-timeout" value="30" /></div><div class="space-y-2"><Label for="retention">{i18n.t('account.dataRetention')}</Label><Input id="retention" value="365" /></div></div>
36
- <SettingsFieldRow label={i18n.t('account.auditLogging')} description="Preserve an auditable record of organization-level changes." control={auditControl} separated />
37
- <Button size="sm">{i18n.t('common.save')}</Button>
38
- </SettingsGroup>
39
- <SettingsGroup title={i18n.t('account.singleSignOn')} description={i18n.t('account.ssoDescription')} bodyClass="space-y-4">
40
- <SettingsFieldRow label={i18n.t('account.enableSso')} description="Require the configured identity provider for organization members." control={ssoControl} />
41
- <Input value="https://idp.example.com/metadata" aria-label={i18n.t('account.ssoProvider')} />
42
- <Button variant="outline" size="sm">{i18n.t('common.test')}</Button>
43
- </SettingsGroup>
44
- </div>
45
- <section class="space-y-3"><SectionHeader title={i18n.t('account.connections')} />{#each identity as integration (integration.id)}<IntegrationCard {integration} />{/each}</section>
46
- <section class="space-y-3"><SectionHeader title={i18n.t('account.securityLog')} /><SecurityEventTable events={events} emptyTitle={i18n.t('common.noData')} emptyDescription={i18n.t('empty.description')} /></section>
212
+ {#if !identityProvider}
213
+ <FeedbackNotice tone="warning" message={isZh ? '未配置 IdentityGovernanceProvider。企业策略、SSO 测试和安全事件保持不可用,不会伪造保存结果。' : 'IdentityGovernanceProvider is not configured. Enterprise policy, SSO tests, and security events remain unavailable instead of simulating persistence.'} />
214
+ {/if}
215
+ {#if loading}
216
+ <DataState state="loading" />
217
+ {:else if error}
218
+ <DataState state="error" title={isZh ? '企业设置加载失败' : 'Enterprise settings failed to load'} description={error} retry={loadSettings} />
219
+ {:else}
220
+ <div class="grid items-start gap-4 lg:grid-cols-2">
221
+ <SettingsGroup title={i18n.t('account.securityPolicy')} description={isZh ? '配置组织级身份、会话与审计策略。' : 'Configure organization-wide identity, session, and audit controls.'} bodyClass="space-y-4">
222
+ {#if organizationProvider}
223
+ <div class="space-y-2"><Label for="organization-name">{isZh ? '组织名称' : 'Organization name'}</Label><div class="flex gap-2"><Input id="organization-name" bind:value={organizationName} disabled={!organizationProvider.updateCurrentOrganization || savingOrganization} /><Button variant="outline" size="sm" disabled={!organizationChanged || savingOrganization} onclick={saveOrganization}>{savingOrganization ? (isZh ? '保存中...' : 'Saving...') : i18n.t('common.save')}</Button></div></div>
224
+ {/if}
225
+ <div class="grid gap-4 sm:grid-cols-2">
226
+ <div class="space-y-2"><Label for="session-timeout">{i18n.t('account.sessionTimeout')}</Label><Input id="session-timeout" type="number" min="1" step="1" bind:value={sessionTimeoutValue} disabled={!policy || saving} /></div>
227
+ <div class="space-y-2"><Label for="retention">{i18n.t('account.dataRetention')}</Label><Input id="retention" type="number" min="1" step="1" bind:value={auditRetentionValue} disabled={!policy || saving} /></div>
228
+ </div>
229
+ {#if policy && !policyNumbersValid}<FeedbackNotice tone="warning" message={isZh ? '会话超时和审计保留天数必须为正整数。' : 'Session timeout and audit retention must be positive integers.'} />{/if}
230
+ <SettingsFieldRow label={i18n.t('account.auditLogging')} description={isZh ? '由后端持久化组织级审计策略。' : 'Persist the organization audit policy through the backend provider.'} control={auditControl} separated />
231
+ <Button size="sm" disabled={!identityProvider || !policy || !policyNumbersValid || saving} onclick={saveSettings}>{saving ? (isZh ? '保存中...' : 'Saving...') : i18n.t('common.save')}</Button>
232
+ </SettingsGroup>
233
+ <SettingsGroup title={i18n.t('account.singleSignOn')} description={i18n.t('account.ssoDescription')} bodyClass="space-y-4">
234
+ <SettingsFieldRow label={i18n.t('account.enableSso')} description={isZh ? '启用后由已配置身份源执行组织登录策略。' : 'Require the configured identity provider for organization members.'} control={ssoControl} />
235
+ <Input bind:value={metadataUrl} placeholder="https://idp.example.com/.well-known/openid-configuration" aria-label={i18n.t('account.ssoProvider')} disabled={!identityProvider || testing} />
236
+ <Button variant="outline" size="sm" disabled={!identityProvider || testing} onclick={() => testConnection()}>{testing ? (isZh ? '测试中...' : 'Testing...') : i18n.t('common.test')}</Button>
237
+ </SettingsGroup>
238
+ </div>
239
+ <section class="space-y-3">
240
+ <SectionHeader title={i18n.t('account.connections')} />
241
+ {#if identityProviders.length === 0}
242
+ <DataState state="empty" title={isZh ? '尚未配置身份源' : 'No identity provider configured'} description={isZh ? '请在后端 IdentityGovernanceProvider 中创建并验证身份源。' : 'Create and verify an identity provider through the backend IdentityGovernanceProvider.'} />
243
+ {:else}
244
+ {#each identityProviders as connection (connection.id)}
245
+ <IntegrationCard integration={{ id: connection.id, name: connection.name, account: connection.protocol.toUpperCase(), connected: connection.status === 'connected', description: connection.domain ?? connection.metadataUrl }} onconnect={() => testConnection(connection)} />
246
+ {/each}
247
+ {/if}
248
+ </section>
249
+ <section class="space-y-3"><SectionHeader title={i18n.t('account.securityLog')} /><SecurityEventTable events={displayEvents} emptyTitle={i18n.t('common.noData')} emptyDescription={isZh ? '当前 Provider 未返回安全事件。' : 'The provider returned no security events.'} /></section>
250
+ {/if}
47
251
  </ContentPageShell>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { captureAdminContext } from '@svadmin/core';
2
3
  import { useTranslation } from '@svadmin/core/i18n';
3
4
  import { Button } from '../ui/button/index.js';
4
5
  import { Input } from '../ui/input/index.js';
@@ -7,7 +8,11 @@
7
8
  import ContentPageHeader from '../content/ContentPageHeader.svelte';
8
9
  import SettingsGroup from '../content/SettingsGroup.svelte';
9
10
  import SettingsFieldRow from '../content/SettingsFieldRow.svelte';
11
+ import FeedbackNotice from '../content/FeedbackNotice.svelte';
10
12
  const i18n = useTranslation();
13
+ const adminContext = captureAdminContext();
14
+ const credentialProvider = $derived(adminContext.credentialProvider);
15
+ const isZh = $derived(i18n.locale === 'zh-CN');
11
16
  const sections = [{ id: 'profile', title: i18n.t('account.basicSettings') }, { id: 'signin', title: i18n.t('account.socialSignIn') }, { id: 'preferences', title: i18n.t('account.preferences') }, { id: 'api', title: i18n.t('account.manageApi') }];
12
17
  let active = $state('profile');
13
18
  let visible = $state(true);
@@ -41,7 +46,13 @@
41
46
  {:else if active === 'preferences'}
42
47
  <SettingsGroup title={i18n.t('account.preferences')} bodyClass="grid gap-4 sm:grid-cols-3"><Input value="English" aria-label={i18n.t('account.language')} /><Input value="Asia/Shanghai" aria-label={i18n.t('account.timezone')} /><Input value="CNY" aria-label={i18n.t('account.currency')} /></SettingsGroup>
43
48
  {:else}
44
- <SettingsGroup title={i18n.t('account.manageApi')} description="Use scoped credentials and rotate them when access changes." bodyClass="flex flex-col gap-3 sm:flex-row"><Input readonly value="sv_demo_pub_9f3e********41ab" aria-label="API key" /><Button variant="outline">{i18n.t('common.copy')}</Button></SettingsGroup>
49
+ <SettingsGroup title={i18n.t('account.manageApi')} description={isZh ? '使用有作用域的真实凭据,并在权限变更时轮换。' : 'Use scoped credentials and rotate them when access changes.'} bodyClass="space-y-3">
50
+ {#if credentialProvider}
51
+ <Button variant="outline" onclick={() => void adminContext.navigate('/settings/api')}>{isZh ? '打开 API 凭据管理' : 'Open API credential management'}</Button>
52
+ {:else}
53
+ <FeedbackNotice tone="warning" message={isZh ? '未配置 CredentialProvider,不会显示或生成演示密钥。' : 'CredentialProvider is not configured. No demo credential is displayed or generated.'} />
54
+ {/if}
55
+ </SettingsGroup>
45
56
  {/if}
46
57
  </div>
47
58
  </div>
@@ -9,6 +9,7 @@
9
9
  import DescriptionList from '../content/DescriptionList.svelte';
10
10
  import FileList from '../content/FileList.svelte';
11
11
  import IntegrationCard from '../content/IntegrationCard.svelte';
12
+ import WorkspaceLayout from '../content/WorkspaceLayout.svelte';
12
13
  import type { IntegrationSummary } from '../content/IntegrationCard.svelte';
13
14
  import { referenceDemoData } from '../../reference-data.js';
14
15
  const i18n = useTranslation();
@@ -22,8 +23,8 @@
22
23
 
23
24
  <ContentPageShell pageId="account-user-profile" width="wide">
24
25
  <div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"><ContentPageHeader title={i18n.t('account.userProfile')} description={i18n.t('account.userProfileDescription')} /><Button onclick={save} disabled={saving} size="sm">{#if saving}<Loader2 class="size-3.5 animate-spin" />{/if}{i18n.t('common.save')}</Button></div>
25
- <div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
26
- <div class="space-y-4"><Card.Card><Card.CardContent class="space-y-5 p-5"><div><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.personalInfo')}</h2><p class="mt-1 text-sm text-muted-foreground">Keep your contact and working details current.</p></div><DescriptionList columns={2} items={[{ label: i18n.t('profile.name'), value: 'Alex Chen' }, { label: i18n.t('account.phone'), value: '+86 138 **** 9527' }, { label: i18n.t('account.dateOfBirth'), value: '1995-04-18' }, { label: i18n.t('account.address'), value: 'Shanghai, CN' }]} /></Card.CardContent></Card.Card><Card.Card><Card.CardContent class="space-y-4 p-5"><div class="flex items-center justify-between gap-3"><div><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.work')}</h2><p class="mt-1 text-sm text-muted-foreground">{i18n.t('account.availableNow')}</p></div><Switch bind:checked={availableToHire} /></div><div class="flex flex-wrap gap-1.5">{#each ['TypeScript', 'Svelte', 'Node.js', 'Design Systems'] as skill (skill)}<span class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground">{skill}</span>{/each}</div></Card.CardContent></Card.Card><section class="space-y-3"><h2 class="text-base font-semibold text-foreground">{i18n.t('account.connections')}</h2>{#each integrations as integration (integration.id)}<IntegrationCard {integration} onconnect={() => toggle(integration.id)} />{/each}</section></div>
27
- <div class="space-y-4"><Card.Card><Card.CardContent class="space-y-3 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.myFiles')}</h2><FileList files={files} /></Card.CardContent></Card.Card><Card.Card><Card.CardContent class="space-y-3 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.calendarAccounts')}</h2><DescriptionList items={[{ label: 'Work Calendar', value: 'alex@nebulalabs.io' }, { label: 'Personal Calendar', value: 'alex@example.com' }]} /></Card.CardContent></Card.Card></div>
28
- </div>
26
+ <WorkspaceLayout secondaryWidth="20rem">
27
+ {#snippet primary()}<div class="space-y-4"><Card.Card><Card.CardContent class="space-y-5 p-5"><div><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.personalInfo')}</h2><p class="mt-1 text-sm text-muted-foreground">Keep your contact and working details current.</p></div><DescriptionList columns={2} items={[{ label: i18n.t('profile.name'), value: 'Alex Chen' }, { label: i18n.t('account.phone'), value: '+86 138 **** 9527' }, { label: i18n.t('account.dateOfBirth'), value: '1995-04-18' }, { label: i18n.t('account.address'), value: 'Shanghai, CN' }]} /></Card.CardContent></Card.Card><Card.Card><Card.CardContent class="space-y-4 p-5"><div class="flex items-center justify-between gap-3"><div><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.work')}</h2><p class="mt-1 text-sm text-muted-foreground">{i18n.t('account.availableNow')}</p></div><Switch bind:checked={availableToHire} /></div><div class="flex flex-wrap gap-1.5">{#each ['TypeScript', 'Svelte', 'Node.js', 'Design Systems'] as skill (skill)}<span class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground">{skill}</span>{/each}</div></Card.CardContent></Card.Card><section class="space-y-3"><h2 class="text-base font-semibold text-foreground">{i18n.t('account.connections')}</h2>{#each integrations as integration (integration.id)}<IntegrationCard {integration} onconnect={() => toggle(integration.id)} />{/each}</section></div>{/snippet}
28
+ {#snippet secondary()}<div class="space-y-4"><Card.Card><Card.CardContent class="space-y-3 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.myFiles')}</h2><FileList files={files} /></Card.CardContent></Card.Card><Card.Card><Card.CardContent class="space-y-3 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.calendarAccounts')}</h2><DescriptionList items={[{ label: 'Work Calendar', value: 'alex@nebulalabs.io' }, { label: 'Personal Calendar', value: 'alex@example.com' }]} /></Card.CardContent></Card.Card></div>{/snippet}
29
+ </WorkspaceLayout>
29
30
  </ContentPageShell>
@@ -1,13 +1,22 @@
1
1
  <script lang="ts">
2
- import { Copy, KeyRound, Trash2 } from '@lucide/svelte';
2
+ import { Check, Copy, KeyRound, Trash2 } from '@lucide/svelte';
3
3
  import { useTranslation } from '@svadmin/core/i18n';
4
+ import { Badge } from '../ui/badge/index.js';
4
5
  import { Button } from '../ui/button/index.js';
5
6
  import DataState from './DataState.svelte';
6
7
  import type { DataStateKind } from './DataState.svelte';
7
- export interface ApiKeySummary { id: string; name: string; prefix: string; createdAt?: string; lastUsedAt?: string; }
8
- interface Props { keys?: ApiKeySummary[]; state?: DataStateKind; stateTitle?: string; stateDescription?: string; retry?: () => void; retryLabel?: string; loadingLabel?: string; oncopy?: (key: ApiKeySummary) => void; onrevoke?: (key: ApiKeySummary) => void; class?: string; }
8
+ export interface ApiKeySummary {
9
+ id: string;
10
+ name: string;
11
+ prefix: string;
12
+ maskedToken?: string;
13
+ createdAt?: string;
14
+ lastUsedAt?: string;
15
+ permissions?: string[];
16
+ }
17
+ interface Props { keys?: ApiKeySummary[]; state?: DataStateKind; stateTitle?: string; stateDescription?: string; retry?: () => void; retryLabel?: string; loadingLabel?: string; copiedKeyId?: string | null; oncopy?: (key: ApiKeySummary) => void; onrevoke?: (key: ApiKeySummary) => void; class?: string; }
9
18
  const i18n = useTranslation();
10
- let { keys = [], state, stateTitle, stateDescription, retry, retryLabel, loadingLabel, oncopy, onrevoke, class: className = '' }: Props = $props();
19
+ let { keys = [], state, stateTitle, stateDescription, retry, retryLabel, loadingLabel, copiedKeyId = null, oncopy, onrevoke, class: className = '' }: Props = $props();
11
20
  const resolvedState = $derived(state ?? (keys.length === 0 ? 'empty' : undefined));
12
21
  const isZh = $derived(i18n.locale === 'zh-CN');
13
22
  </script>
@@ -16,7 +25,23 @@
16
25
  {:else}
17
26
  <div class={'divide-y divide-border overflow-hidden rounded-lg border border-border bg-card ' + className}>
18
27
  {#each keys as apiKey (apiKey.id)}
19
- <div class="flex flex-wrap items-center gap-3 px-4 py-3"><span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"><KeyRound class="size-4" /></span><div class="min-w-0 flex-1"><p class="truncate text-sm font-medium text-foreground">{apiKey.name}</p><p class="font-mono text-xs text-muted-foreground">{apiKey.prefix}********</p></div><div class="text-right text-xs text-muted-foreground"><p>{apiKey.createdAt ?? (isZh ? '最近创建' : 'Created recently')}</p><p>{apiKey.lastUsedAt ?? (isZh ? '从未使用' : 'Never used')}</p></div><div class="flex items-center gap-1">{#if oncopy}<Button variant="ghost" size="icon-sm" aria-label={i18n.t('common.copy') + ' ' + apiKey.name} onclick={() => oncopy?.(apiKey)}><Copy class="size-4" /></Button>{/if}{#if onrevoke}<Button variant="ghost" size="icon-sm" aria-label={i18n.t('common.delete') + ' ' + apiKey.name} onclick={() => onrevoke?.(apiKey)}><Trash2 class="size-4" /></Button>{/if}</div></div>
28
+ <div class="flex flex-wrap items-center gap-3 px-4 py-3">
29
+ <span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"><KeyRound class="size-4" /></span>
30
+ <div class="min-w-48 flex-1">
31
+ <p class="truncate text-sm font-medium text-foreground">{apiKey.name}</p>
32
+ <p class="truncate font-mono text-xs text-muted-foreground">{apiKey.maskedToken ?? `${apiKey.prefix}********`}</p>
33
+ {#if apiKey.permissions?.length}
34
+ <div class="mt-2 flex flex-wrap gap-1">
35
+ {#each apiKey.permissions as permission (permission)}<Badge variant="secondary" class="px-2 py-0 text-[10px]">{permission}</Badge>{/each}
36
+ </div>
37
+ {/if}
38
+ </div>
39
+ <div class="text-left text-xs text-muted-foreground sm:text-right"><p>{apiKey.createdAt ?? (isZh ? '最近创建' : 'Created recently')}</p><p>{apiKey.lastUsedAt ?? (isZh ? '从未使用' : 'Never used')}</p></div>
40
+ <div class="ml-auto flex items-center gap-1">
41
+ {#if oncopy}<Button variant="ghost" size="icon-sm" aria-label={i18n.t('common.copy') + ' ' + apiKey.name} onclick={() => oncopy?.(apiKey)}>{#if copiedKeyId === apiKey.id}<Check class="size-4 text-success" />{:else}<Copy class="size-4" />{/if}</Button>{/if}
42
+ {#if onrevoke}<Button variant="ghost" size="icon-sm" aria-label={i18n.t('common.delete') + ' ' + apiKey.name} onclick={() => onrevoke?.(apiKey)}><Trash2 class="size-4" /></Button>{/if}
43
+ </div>
44
+ </div>
20
45
  {/each}
21
46
  </div>
22
47
  {/if}
@@ -3,8 +3,10 @@ export interface ApiKeySummary {
3
3
  id: string;
4
4
  name: string;
5
5
  prefix: string;
6
+ maskedToken?: string;
6
7
  createdAt?: string;
7
8
  lastUsedAt?: string;
9
+ permissions?: string[];
8
10
  }
9
11
  interface Props {
10
12
  keys?: ApiKeySummary[];
@@ -14,6 +16,7 @@ interface Props {
14
16
  retry?: () => void;
15
17
  retryLabel?: string;
16
18
  loadingLabel?: string;
19
+ copiedKeyId?: string | null;
17
20
  oncopy?: (key: ApiKeySummary) => void;
18
21
  onrevoke?: (key: ApiKeySummary) => void;
19
22
  class?: string;
@@ -7,6 +7,7 @@
7
7
  secondary?: Snippet;
8
8
  summary?: Snippet;
9
9
  secondaryWidth?: string;
10
+ mobileOrder?: 'primary-first' | 'secondary-first';
10
11
  class?: string;
11
12
  }
12
13
 
@@ -15,14 +16,16 @@
15
16
  secondary,
16
17
  summary,
17
18
  secondaryWidth = '22rem',
19
+ mobileOrder = 'primary-first',
18
20
  class: className = '',
19
21
  }: Props = $props();
20
22
  </script>
21
23
 
22
24
  <div class={cn('flex flex-col gap-6', className)} data-svadmin-workspace-layout style:--workspace-secondary-width={secondaryWidth}>
23
- {#if summary}<div class="order-2 sm:order-1">{@render summary()}</div>{/if}
24
- <div class="order-1 grid min-w-0 gap-6 sm:order-2 lg:grid-cols-[minmax(0,1fr)_var(--workspace-secondary-width)]">
25
- <div class="min-w-0" data-svadmin-workspace-primary>{@render primary()}</div>
26
- {#if secondary}<aside class="min-w-0">{@render secondary()}</aside>{/if}
25
+ {#if summary}<div data-svadmin-workspace-summary>{@render summary()}</div>{/if}
26
+ <div class={cn('grid min-w-0 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_var(--workspace-secondary-width)]', mobileOrder === 'secondary-first' && 'grid-rows-[auto_auto]')}>
27
+ {#if mobileOrder === 'secondary-first' && secondary}<aside class="order-1 min-w-0 xl:order-2">{@render secondary()}</aside>{/if}
28
+ <div class={cn('min-w-0', mobileOrder === 'secondary-first' ? 'order-2 xl:order-1' : 'order-1')} data-svadmin-workspace-primary>{@render primary()}</div>
29
+ {#if mobileOrder !== 'secondary-first' && secondary}<aside class="order-2 min-w-0 xl:order-2">{@render secondary()}</aside>{/if}
27
30
  </div>
28
31
  </div>
@@ -4,6 +4,7 @@ interface Props {
4
4
  secondary?: Snippet;
5
5
  summary?: Snippet;
6
6
  secondaryWidth?: string;
7
+ mobileOrder?: 'primary-first' | 'secondary-first';
7
8
  class?: string;
8
9
  }
9
10
  declare const WorkspaceLayout: import("svelte").Component<Props, {}, "">;