@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
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
|
|
57
57
|
<div class="h-full flex flex-col pt-2">
|
|
58
58
|
<!-- Header Title -->
|
|
59
|
-
<div class="mb-6 flex items-
|
|
59
|
+
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
60
60
|
<div>
|
|
61
61
|
<h1 class="text-2xl font-bold text-foreground flex items-center gap-2">
|
|
62
62
|
<Shield class="w-6 h-6 text-primary" />
|
|
@@ -78,10 +78,19 @@
|
|
|
78
78
|
{/if}
|
|
79
79
|
</div>
|
|
80
80
|
|
|
81
|
-
<div class="flex flex-col
|
|
81
|
+
<div class="flex flex-col lg:flex-row gap-6 flex-1 min-h-[500px]">
|
|
82
|
+
<div class="w-full rounded-lg border border-border bg-card p-4 shadow-sm lg:hidden">
|
|
83
|
+
<label for="permission-role-select" class="mb-2 block text-sm font-medium text-foreground">{i18n.t('permissions.roles') || 'Roles'}</label>
|
|
84
|
+
<select id="permission-role-select" bind:value={selectedRole} class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" disabled={roles.length === 0}>
|
|
85
|
+
{#each roles as role (role.code)}<option value={role.code}>{role.name}</option>{/each}
|
|
86
|
+
</select>
|
|
87
|
+
{#if roles.length === 0}<p class="mt-2 text-sm text-muted-foreground">{i18n.t('permissions.noRoles') || 'No roles available'}</p>{/if}
|
|
88
|
+
{#if sidebarExtra}<div class="mt-4 border-t border-border pt-4">{@render sidebarExtra()}</div>{/if}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
82
91
|
<!-- Left: Role Selector Sidebar -->
|
|
83
|
-
<div class="w-full
|
|
84
|
-
<h2 class="text-sm font-semibold text-muted-
|
|
92
|
+
<div class="hidden w-full flex-shrink-0 flex-col rounded-lg border border-border bg-card p-4 shadow-sm lg:flex lg:w-64">
|
|
93
|
+
<h2 class="mb-4 px-2 text-sm font-semibold text-muted-foreground">{i18n.t('permissions.roles') || 'Roles'}</h2>
|
|
85
94
|
<div class="space-y-1 flex-1 overflow-y-auto">
|
|
86
95
|
{#each roles as role, _i (_i)}
|
|
87
96
|
<button
|
|
@@ -89,7 +98,7 @@
|
|
|
89
98
|
class="w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-200 {selectedRole === role.code ? 'bg-primary/10 text-primary font-medium shadow-sm ring-1 ring-primary/20' : 'text-foreground hover:bg-muted'}"
|
|
90
99
|
>
|
|
91
100
|
{role.name}
|
|
92
|
-
<div class="text-[10px]
|
|
101
|
+
<div class="mt-0.5 font-mono text-[10px] opacity-60">{role.code}</div>
|
|
93
102
|
</button>
|
|
94
103
|
{/each}
|
|
95
104
|
{#if roles.length === 0}
|
|
@@ -108,8 +117,8 @@
|
|
|
108
117
|
<!-- Right: Matrix Grid -->
|
|
109
118
|
<div class="flex-1 bg-card rounded-lg shadow-sm border border-border overflow-hidden flex flex-col">
|
|
110
119
|
<!-- Matrix Header -->
|
|
111
|
-
<div class="p-5 border-b border-border bg-muted/30 flex items-
|
|
112
|
-
<div class="flex items-center gap-4">
|
|
120
|
+
<div class="p-5 border-b border-border bg-muted/30 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
121
|
+
<div class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
|
113
122
|
<div>
|
|
114
123
|
<h2 class="text-lg font-bold text-foreground">{i18n.t('permissions.currentRole') || 'Current role'}:
|
|
115
124
|
<span class="text-primary">{roles.find(r => r.code === selectedRole)?.name || (i18n.t('permissions.noneSelected') || 'None selected')}</span>
|
|
@@ -156,7 +165,7 @@
|
|
|
156
165
|
<!-- Section Grouping Header -->
|
|
157
166
|
{#if resource.section && (i === 0 || resource.section !== resources[i-1].section)}
|
|
158
167
|
<tr class="bg-primary/5">
|
|
159
|
-
<td colspan="{actions.length + 1}" class="px-4 py-2 text-xs font-bold text-
|
|
168
|
+
<td colspan="{actions.length + 1}" class="px-4 py-2 text-xs font-bold text-primary">{resource.section}</td>
|
|
160
169
|
</tr>
|
|
161
170
|
{/if}
|
|
162
171
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Ellipsis } from '@lucide/svelte';
|
|
3
|
+
import type { Component, Snippet } from 'svelte';
|
|
4
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
5
|
+
import { cn, type WithElementRef } from '../utils.js';
|
|
6
|
+
import * as DropdownMenu from './ui/dropdown-menu/index.js';
|
|
7
|
+
import { Button } from './ui/button/index.js';
|
|
8
|
+
|
|
9
|
+
export type RowActionItem = {
|
|
10
|
+
label: string;
|
|
11
|
+
icon?: Component<{ class?: string }>;
|
|
12
|
+
href?: string;
|
|
13
|
+
onclick?: (e: MouseEvent) => void;
|
|
14
|
+
variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary';
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
title?: string;
|
|
17
|
+
danger?: boolean;
|
|
18
|
+
hidden?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type Props = WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
|
22
|
+
actions?: RowActionItem[];
|
|
23
|
+
maxVisible?: number;
|
|
24
|
+
moreLabel?: string;
|
|
25
|
+
children?: Snippet;
|
|
26
|
+
moreContent?: Snippet;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
actions = [],
|
|
31
|
+
maxVisible = 2,
|
|
32
|
+
moreLabel = 'More actions',
|
|
33
|
+
ref = $bindable(null),
|
|
34
|
+
class: className,
|
|
35
|
+
children,
|
|
36
|
+
moreContent,
|
|
37
|
+
...restProps
|
|
38
|
+
}: Props = $props();
|
|
39
|
+
|
|
40
|
+
let overflowOpen = $state(false);
|
|
41
|
+
const availableActions = $derived(actions.filter((action) => !action.hidden));
|
|
42
|
+
const visibleLimit = $derived(
|
|
43
|
+
Number.isFinite(maxVisible)
|
|
44
|
+
? Math.max(0, Math.floor(maxVisible))
|
|
45
|
+
: availableActions.length
|
|
46
|
+
);
|
|
47
|
+
const visibleActions = $derived(availableActions.slice(0, visibleLimit));
|
|
48
|
+
const overflowActions = $derived(availableActions.slice(visibleLimit));
|
|
49
|
+
|
|
50
|
+
function resolvedVariant(action: RowActionItem): RowActionItem['variant'] {
|
|
51
|
+
return action.variant ?? (action.danger ? 'destructive' : 'ghost');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleOverflowAction(action: RowActionItem, event: MouseEvent): void {
|
|
55
|
+
if (action.disabled) {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
overflowOpen = false;
|
|
60
|
+
action.onclick?.(event);
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<div
|
|
65
|
+
bind:this={ref}
|
|
66
|
+
data-slot="row-actions"
|
|
67
|
+
class={cn("inline-flex items-center gap-1.5", className)}
|
|
68
|
+
{...restProps}
|
|
69
|
+
>
|
|
70
|
+
{#if children}
|
|
71
|
+
{@render children()}
|
|
72
|
+
{/if}
|
|
73
|
+
|
|
74
|
+
{#each visibleActions as action (action)}
|
|
75
|
+
{#if action.href}
|
|
76
|
+
<Button
|
|
77
|
+
variant={resolvedVariant(action)}
|
|
78
|
+
size="sm"
|
|
79
|
+
href={action.href}
|
|
80
|
+
disabled={action.disabled}
|
|
81
|
+
title={action.title || action.label}
|
|
82
|
+
class="h-8 px-2 text-xs"
|
|
83
|
+
>
|
|
84
|
+
{#if action.icon}
|
|
85
|
+
{@const Icon = action.icon}
|
|
86
|
+
<Icon class="mr-1 size-3.5" />
|
|
87
|
+
{/if}
|
|
88
|
+
{action.label}
|
|
89
|
+
</Button>
|
|
90
|
+
{:else}
|
|
91
|
+
<Button
|
|
92
|
+
variant={resolvedVariant(action)}
|
|
93
|
+
size="sm"
|
|
94
|
+
onclick={action.onclick}
|
|
95
|
+
disabled={action.disabled}
|
|
96
|
+
title={action.title || action.label}
|
|
97
|
+
class="h-8 px-2 text-xs"
|
|
98
|
+
>
|
|
99
|
+
{#if action.icon}
|
|
100
|
+
{@const Icon = action.icon}
|
|
101
|
+
<Icon class="mr-1 size-3.5" />
|
|
102
|
+
{/if}
|
|
103
|
+
{action.label}
|
|
104
|
+
</Button>
|
|
105
|
+
{/if}
|
|
106
|
+
{/each}
|
|
107
|
+
|
|
108
|
+
{#if overflowActions.length > 0 || moreContent}
|
|
109
|
+
<DropdownMenu.Root bind:open={overflowOpen}>
|
|
110
|
+
<DropdownMenu.Trigger>
|
|
111
|
+
{#snippet child({ props })}
|
|
112
|
+
<Button {...props} variant="ghost" size="icon-sm" title={moreLabel} aria-label={moreLabel}>
|
|
113
|
+
<Ellipsis class="size-4" />
|
|
114
|
+
</Button>
|
|
115
|
+
{/snippet}
|
|
116
|
+
</DropdownMenu.Trigger>
|
|
117
|
+
<DropdownMenu.Content align="end" class="min-w-[8rem]">
|
|
118
|
+
{#each overflowActions as action (action)}
|
|
119
|
+
{#if action.href}
|
|
120
|
+
<Button
|
|
121
|
+
href={action.href}
|
|
122
|
+
variant={resolvedVariant(action)}
|
|
123
|
+
size="sm"
|
|
124
|
+
disabled={action.disabled}
|
|
125
|
+
onclick={(event) => handleOverflowAction(action, event)}
|
|
126
|
+
class="h-auto w-full justify-start rounded-md px-2 py-1.5 text-sm font-normal"
|
|
127
|
+
>
|
|
128
|
+
{#if action.icon}
|
|
129
|
+
{@const Icon = action.icon}
|
|
130
|
+
<Icon class="size-3.5" />
|
|
131
|
+
{/if}
|
|
132
|
+
{action.label}
|
|
133
|
+
</Button>
|
|
134
|
+
{:else}
|
|
135
|
+
<DropdownMenu.Item
|
|
136
|
+
onclick={(event) => handleOverflowAction(action, event)}
|
|
137
|
+
disabled={action.disabled}
|
|
138
|
+
destructive={action.danger || action.variant === 'destructive'}
|
|
139
|
+
>
|
|
140
|
+
{#if action.icon}
|
|
141
|
+
{@const Icon = action.icon}
|
|
142
|
+
<Icon class="size-3.5" />
|
|
143
|
+
{/if}
|
|
144
|
+
{action.label}
|
|
145
|
+
</DropdownMenu.Item>
|
|
146
|
+
{/if}
|
|
147
|
+
{/each}
|
|
148
|
+
{#if moreContent}
|
|
149
|
+
{@render moreContent()}
|
|
150
|
+
{/if}
|
|
151
|
+
</DropdownMenu.Content>
|
|
152
|
+
</DropdownMenu.Root>
|
|
153
|
+
{/if}
|
|
154
|
+
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Component, Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import { type WithElementRef } from '../utils.js';
|
|
4
|
+
export type RowActionItem = {
|
|
5
|
+
label: string;
|
|
6
|
+
icon?: Component<{
|
|
7
|
+
class?: string;
|
|
8
|
+
}>;
|
|
9
|
+
href?: string;
|
|
10
|
+
onclick?: (e: MouseEvent) => void;
|
|
11
|
+
variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary';
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
title?: string;
|
|
14
|
+
danger?: boolean;
|
|
15
|
+
hidden?: boolean;
|
|
16
|
+
};
|
|
17
|
+
type Props = WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
|
18
|
+
actions?: RowActionItem[];
|
|
19
|
+
maxVisible?: number;
|
|
20
|
+
moreLabel?: string;
|
|
21
|
+
children?: Snippet;
|
|
22
|
+
moreContent?: Snippet;
|
|
23
|
+
};
|
|
24
|
+
declare const RowActions: Component<Props, {}, "ref">;
|
|
25
|
+
type RowActions = ReturnType<typeof RowActions>;
|
|
26
|
+
export default RowActions;
|
|
@@ -1,176 +1,201 @@
|
|
|
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
|
-
import { AlertTriangle, KeyRound, Monitor, Shield, ShieldAlert, ShieldCheck, Smartphone, Trash2 } from '@lucide/svelte';
|
|
6
|
-
import type { Component } from 'svelte';
|
|
4
|
+
import { AlertTriangle, Monitor, Smartphone, Trash2 } from '@lucide/svelte';
|
|
7
5
|
import * as Alert from './ui/alert/index.js';
|
|
8
|
-
import * as Card from './ui/card/index.js';
|
|
9
6
|
import { Button } from './ui/button/index.js';
|
|
10
|
-
import { Label } from './ui/label/index.js';
|
|
11
7
|
import { Switch } from './ui/switch/index.js';
|
|
12
8
|
import PasswordInput from './PasswordInput.svelte';
|
|
9
|
+
import SettingsGroup from './content/SettingsGroup.svelte';
|
|
10
|
+
import SettingsFieldRow from './content/SettingsFieldRow.svelte';
|
|
11
|
+
import DataState from './content/DataState.svelte';
|
|
12
|
+
import FeedbackNotice from './content/FeedbackNotice.svelte';
|
|
13
13
|
|
|
14
14
|
const i18n = useTranslation();
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
os: string;
|
|
20
|
-
browser: string;
|
|
21
|
-
ip: string;
|
|
22
|
-
lastActive: string;
|
|
23
|
-
isCurrent: boolean;
|
|
24
|
-
Icon: Component;
|
|
25
|
-
}
|
|
15
|
+
const adminContext = captureAdminContext();
|
|
16
|
+
const authProvider = $derived(adminContext.authProvider);
|
|
17
|
+
const sessionProvider = $derived(adminContext.sessionProvider);
|
|
18
|
+
const isZh = $derived(i18n.locale === 'zh-CN');
|
|
26
19
|
|
|
27
20
|
let is2faEnabled = $state(false);
|
|
28
|
-
let sessions = $state<SessionInfo[]>([
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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);
|
|
34
26
|
let currentPassword = $state('');
|
|
35
27
|
let newPassword = $state('');
|
|
36
28
|
let confirmPassword = $state('');
|
|
37
29
|
let passwordErrorMessage = $state('');
|
|
38
|
-
let
|
|
30
|
+
let changingPassword = $state(false);
|
|
31
|
+
let requestId = 0;
|
|
39
32
|
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
function message(error: unknown): string {
|
|
34
|
+
return error instanceof Error ? error.message : String(error);
|
|
35
|
+
}
|
|
43
36
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
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;
|
|
55
59
|
}
|
|
60
|
+
}
|
|
56
61
|
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
$effect(() => {
|
|
63
|
+
void authProvider;
|
|
64
|
+
void sessionProvider;
|
|
65
|
+
void adminContext.tenantCacheKey?.__svadminTenant;
|
|
59
66
|
currentPassword = '';
|
|
60
67
|
newPassword = '';
|
|
61
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
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
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
|
+
}
|
|
62
121
|
}
|
|
63
122
|
|
|
64
|
-
function revokeSession(id: string) {
|
|
65
|
-
|
|
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
|
+
}
|
|
66
140
|
}
|
|
67
141
|
|
|
68
|
-
function revokeAllOthers() {
|
|
69
|
-
|
|
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
|
+
}
|
|
70
159
|
}
|
|
71
160
|
</script>
|
|
72
161
|
|
|
73
162
|
<div class="space-y-6">
|
|
74
|
-
<div>
|
|
75
|
-
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
</Card.Card>
|
|
103
|
-
|
|
104
|
-
<Card.Card>
|
|
105
|
-
<Card.CardHeader>
|
|
106
|
-
<Card.CardTitle class="flex items-center gap-2 text-base">
|
|
107
|
-
<KeyRound class="h-4 w-4 text-muted-foreground" />
|
|
108
|
-
{i18n.t('profile.changePassword')}
|
|
109
|
-
</Card.CardTitle>
|
|
110
|
-
<Card.CardDescription>{i18n.t('security.passwordLastChanged', { time: lastChanged })}</Card.CardDescription>
|
|
111
|
-
</Card.CardHeader>
|
|
112
|
-
<Card.CardContent>
|
|
113
|
-
<form onsubmit={handlePasswordChange} class="max-w-sm space-y-4">
|
|
114
|
-
{#if passwordErrorMessage}
|
|
115
|
-
<Alert.Root variant="destructive">
|
|
116
|
-
<AlertTriangle class="h-4 w-4" />
|
|
117
|
-
<Alert.Description>{passwordErrorMessage}</Alert.Description>
|
|
118
|
-
</Alert.Root>
|
|
119
|
-
{/if}
|
|
120
|
-
|
|
121
|
-
<PasswordInput id="security-current-password" label={i18n.t('profile.currentPassword')} bind:value={currentPassword} autocomplete="current-password" />
|
|
122
|
-
<PasswordInput id="security-new-password" label={i18n.t('profile.newPassword')} bind:value={newPassword} autocomplete="new-password" showStrength />
|
|
123
|
-
<PasswordInput id="security-confirm-password" label={i18n.t('auth.confirmPassword')} bind:value={confirmPassword} autocomplete="new-password" />
|
|
124
|
-
|
|
125
|
-
<Button type="submit">{i18n.t('profile.updatePassword')}</Button>
|
|
126
|
-
</form>
|
|
127
|
-
</Card.CardContent>
|
|
128
|
-
</Card.Card>
|
|
129
|
-
|
|
130
|
-
<Card.Card>
|
|
131
|
-
<Card.CardHeader class="flex flex-row items-start justify-between gap-4">
|
|
132
|
-
<div class="space-y-1">
|
|
133
|
-
<Card.CardTitle class="flex items-center gap-2 text-base">
|
|
134
|
-
<Shield class="h-4 w-4 text-muted-foreground" />
|
|
135
|
-
{i18n.t('security.activeSessions')}
|
|
136
|
-
</Card.CardTitle>
|
|
137
|
-
<Card.CardDescription>{i18n.t('security.sessionsDescription')}</Card.CardDescription>
|
|
138
|
-
</div>
|
|
139
|
-
{#if sessions.length > 1}
|
|
140
|
-
<Button variant="outline" size="sm" onclick={revokeAllOthers}>{i18n.t('security.revokeOthers')}</Button>
|
|
141
|
-
{/if}
|
|
142
|
-
</Card.CardHeader>
|
|
143
|
-
<Card.CardContent>
|
|
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}
|
|
167
|
+
<SettingsGroup title={i18n.t('security.twoFactorAuth')} description={i18n.t('security.twoFactorDescription')}>
|
|
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>
|
|
171
|
+
</SettingsGroup>
|
|
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>
|
|
181
|
+
</SettingsGroup>
|
|
182
|
+
<SettingsGroup title={i18n.t('security.activeSessions')} description={i18n.t('security.sessionsDescription')}>
|
|
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}
|
|
144
191
|
<div class="divide-y">
|
|
145
192
|
{#each sessions as session (session.id)}
|
|
146
193
|
<div class="flex items-center justify-between gap-4 py-3">
|
|
147
|
-
<div class="flex min-w-0 items-center gap-3">
|
|
148
|
-
|
|
149
|
-
<session.Icon class="h-5 w-5" />
|
|
150
|
-
</div>
|
|
151
|
-
<div class="min-w-0 space-y-1">
|
|
152
|
-
<div class="flex flex-wrap items-center gap-2">
|
|
153
|
-
<span class="text-sm font-medium">{session.os} - {session.browser}</span>
|
|
154
|
-
{#if session.isCurrent}
|
|
155
|
-
<span class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary">{i18n.t('security.currentSession')}</span>
|
|
156
|
-
{/if}
|
|
157
|
-
</div>
|
|
158
|
-
<div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
159
|
-
<span>{session.ip}</span>
|
|
160
|
-
<span>{session.lastActive}</span>
|
|
161
|
-
</div>
|
|
162
|
-
</div>
|
|
163
|
-
</div>
|
|
164
|
-
{#if !session.isCurrent}
|
|
165
|
-
<Button variant="ghost" size="icon-sm" onclick={() => revokeSession(session.id)} title={i18n.t('security.revokeSession')}>
|
|
166
|
-
<Trash2 class="h-4 w-4" />
|
|
167
|
-
</Button>
|
|
168
|
-
{/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}
|
|
169
196
|
</div>
|
|
170
|
-
{:else}
|
|
171
|
-
<div class="py-6 text-center text-sm text-muted-foreground">{i18n.t('security.noSessions')}</div>
|
|
172
197
|
{/each}
|
|
173
198
|
</div>
|
|
174
|
-
|
|
175
|
-
</
|
|
199
|
+
{/if}
|
|
200
|
+
</SettingsGroup>
|
|
176
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;
|
|
@@ -115,6 +115,16 @@
|
|
|
115
115
|
return content ? alias : "profile";
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
const contentWidth = $derived(
|
|
119
|
+
activeKey === 'roles' || activeKey === 'audit'
|
|
120
|
+
? 'max-w-[92rem]'
|
|
121
|
+
: activeKey === 'api'
|
|
122
|
+
? 'max-w-[78rem]'
|
|
123
|
+
: activeKey === 'integrations'
|
|
124
|
+
? 'max-w-[64rem]'
|
|
125
|
+
: 'max-w-3xl',
|
|
126
|
+
);
|
|
127
|
+
|
|
118
128
|
// Default redirect to profile
|
|
119
129
|
$effect(() => {
|
|
120
130
|
if (getRoute() === "/settings") {
|
|
@@ -151,7 +161,7 @@
|
|
|
151
161
|
</div>
|
|
152
162
|
{#each resolvedSections as section, _i (_i)}
|
|
153
163
|
<div>
|
|
154
|
-
<h3 class="px-3 mb-1 text-
|
|
164
|
+
<h3 class="px-3 mb-1 text-xs font-medium text-muted-foreground">
|
|
155
165
|
{i18n.t(section.group)}
|
|
156
166
|
</h3>
|
|
157
167
|
<div class="space-y-0.5">
|
|
@@ -175,7 +185,7 @@
|
|
|
175
185
|
</nav>
|
|
176
186
|
|
|
177
187
|
<!-- Right content area -->
|
|
178
|
-
<div class="flex-1 p-6 lg:p-8 {
|
|
188
|
+
<div class="min-w-0 w-full flex-1 p-6 lg:p-8 {contentWidth}">
|
|
179
189
|
{#if activeKey === "profile"}
|
|
180
190
|
{#if profile}{@render profile()}{:else}<ProfilePage />{/if}
|
|
181
191
|
{:else if activeKey === "appearance"}
|