@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.
- package/dist/components/AboutSettings.svelte +21 -96
- package/dist/components/AdminApp.svelte +20 -0
- package/dist/components/AdminApp.svelte.d.ts +5 -1
- package/dist/components/ApiSettings.svelte +153 -246
- package/dist/components/AppearanceSettings.svelte +36 -165
- package/dist/components/AutoForm.svelte +21 -3
- package/dist/components/DetailDrawer.svelte +74 -0
- package/dist/components/DetailDrawer.svelte.d.ts +16 -0
- package/dist/components/FieldRenderer.svelte +70 -2
- package/dist/components/FieldRenderer.svelte.d.ts +2 -0
- package/dist/components/IntegrationsSettings.svelte +22 -61
- package/dist/components/Layout.svelte +15 -1
- package/dist/components/NotificationsSettings.svelte +22 -103
- package/dist/components/PasswordInput.svelte +4 -0
- package/dist/components/PasswordInput.svelte.d.ts +1 -0
- package/dist/components/PermissionMatrix.svelte +17 -8
- package/dist/components/RowActions.svelte +154 -0
- package/dist/components/RowActions.svelte.d.ts +26 -0
- package/dist/components/SecuritySettings.svelte +165 -140
- package/dist/components/SecuritySettings.svelte.d.ts +1 -2
- package/dist/components/SettingsPage.svelte +12 -2
- package/dist/components/account/CompanyProfilePage.svelte +10 -4
- package/dist/components/account/SettingsEnterprisePage.svelte +227 -23
- package/dist/components/account/SettingsSidebarPage.svelte +12 -1
- package/dist/components/account/UserProfilePage.svelte +5 -4
- package/dist/components/content/ApiKeyList.svelte +30 -5
- package/dist/components/content/ApiKeyList.svelte.d.ts +3 -0
- package/dist/components/content/WorkspaceLayout.svelte +7 -4
- package/dist/components/content/WorkspaceLayout.svelte.d.ts +1 -0
- package/dist/components/ui/sheet/sheet.svelte +122 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- 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
|
-
<
|
|
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
|
|
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>
|
|
@@ -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
|
-
<
|
|
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
|
-
</
|
|
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 {
|
|
8
|
-
|
|
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"
|
|
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
|
|
24
|
-
<div class=
|
|
25
|
-
<
|
|
26
|
-
|
|
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>
|