@svadmin/ui 0.47.1 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/AdminApp.svelte +20 -0
- package/dist/components/AdminApp.svelte.d.ts +5 -1
- package/dist/components/ApiSettings.svelte +137 -50
- package/dist/components/AutoTable.svelte +7 -3
- package/dist/components/AutoTable.svelte.d.ts +1 -0
- package/dist/components/Layout.svelte +15 -1
- package/dist/components/PasswordInput.svelte +4 -0
- package/dist/components/PasswordInput.svelte.d.ts +1 -0
- package/dist/components/SecuritySettings.svelte +156 -91
- package/dist/components/SecuritySettings.svelte.d.ts +1 -2
- package/dist/components/account/SettingsEnterprisePage.svelte +227 -23
- package/dist/components/account/SettingsSidebarPage.svelte +12 -1
- package/dist/components/content/WorkspaceActionBar.svelte +33 -0
- package/dist/components/content/WorkspaceActionBar.svelte.d.ts +12 -0
- package/dist/components/content/WorkspaceInspector.svelte +57 -0
- package/dist/components/content/WorkspaceInspector.svelte.d.ts +15 -0
- package/dist/components/content/WorkspaceLayout.svelte +12 -1
- package/dist/components/content/WorkspaceLayout.svelte.d.ts +2 -0
- package/dist/components/content/WorkspaceSplitPane.svelte +34 -0
- package/dist/components/content/WorkspaceSplitPane.svelte.d.ts +11 -0
- package/dist/components/content/WorkspaceStageStepper.svelte +71 -0
- package/dist/components/content/WorkspaceStageStepper.svelte.d.ts +17 -0
- package/dist/components/content/index.d.ts +4 -0
- package/dist/components/content/index.js +4 -0
- package/dist/components/ui/sheet/sheet.svelte +122 -10
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/package.json +2 -2
|
@@ -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>
|