@svadmin/ui 0.40.6 → 0.41.1
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/AICommandBar.svelte +46 -23
- package/dist/components/AboutSettings.svelte +4 -3
- package/dist/components/AboutSettings.svelte.d.ts +2 -17
- package/dist/components/AdminApp.svelte +92 -20
- package/dist/components/AdminApp.svelte.d.ts +17 -2
- package/dist/components/AuditLogDrawer.svelte +45 -16
- package/dist/components/AuditLogViewer.svelte +32 -14
- package/dist/components/AutoTable.svelte +23 -11
- package/dist/components/CancelTaskButton.svelte +12 -4
- package/dist/components/ChatDialog.svelte +456 -122
- package/dist/components/ChatDialog.svelte.d.ts +9 -1
- package/dist/components/CommandPalette.svelte +2 -2
- package/dist/components/CopilotPanel.svelte +67 -26
- package/dist/components/DevTools.svelte +242 -22
- package/dist/components/InferencerPanel.svelte +25 -5
- package/dist/components/InsightCard.svelte +47 -17
- package/dist/components/Layout.svelte +45 -20
- package/dist/components/LoginPage.svelte +4 -4
- package/dist/components/ProfilePage.svelte +74 -27
- package/dist/components/RetryTaskButton.svelte +12 -4
- package/dist/components/RolesSettings.svelte +50 -21
- package/dist/components/SettingsPage.svelte +4 -4
- package/dist/components/Sidebar.svelte +26 -16
- package/dist/components/SmartSuggest.svelte +71 -32
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +11 -0
- package/package.json +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { captureAdminContext, type ChatProvider } from '@svadmin/core';
|
|
3
3
|
import { Sparkles, RefreshCcw, Loader2 } from '@lucide/svelte';
|
|
4
4
|
import { Button } from './ui/button/index.js';
|
|
5
5
|
import MarkdownRenderer from './MarkdownRenderer.svelte';
|
|
@@ -13,54 +13,84 @@
|
|
|
13
13
|
|
|
14
14
|
let { title = 'AI Insights', context, autoFetch = true, class: className = '' }: Props = $props();
|
|
15
15
|
|
|
16
|
-
const
|
|
16
|
+
const adminContext = captureAdminContext();
|
|
17
|
+
const provider = $derived(adminContext.chatProvider);
|
|
17
18
|
|
|
18
19
|
let isPredicting = $state(false);
|
|
19
20
|
let insightText = $state('');
|
|
20
21
|
let abortController: AbortController | null = null;
|
|
21
|
-
let
|
|
22
|
+
let requestEpoch = 0;
|
|
22
23
|
|
|
23
24
|
$effect(() => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
const scopedProvider = provider;
|
|
26
|
+
const scopedContext = context;
|
|
27
|
+
const shouldFetch = autoFetch;
|
|
28
|
+
void adminContext.tenantCacheKey?.__svadminTenant;
|
|
29
|
+
|
|
30
|
+
clearInsightScope();
|
|
31
|
+
if (shouldFetch && scopedContext && scopedProvider) {
|
|
32
|
+
void requestInsights(scopedProvider, scopedContext);
|
|
27
33
|
}
|
|
34
|
+
|
|
35
|
+
return cancelInsightRequest;
|
|
28
36
|
});
|
|
29
37
|
|
|
30
38
|
export async function getInsights() {
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
const scopedProvider = provider;
|
|
40
|
+
if (!scopedProvider || !context) return;
|
|
41
|
+
await requestInsights(scopedProvider, context);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cancelInsightRequest() {
|
|
45
|
+
requestEpoch += 1;
|
|
46
|
+
abortController?.abort();
|
|
47
|
+
abortController = null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clearInsightScope() {
|
|
51
|
+
cancelInsightRequest();
|
|
52
|
+
isPredicting = false;
|
|
53
|
+
insightText = '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function requestInsights(scopedProvider: ChatProvider, scopedContext: string) {
|
|
57
|
+
cancelInsightRequest();
|
|
58
|
+
const epoch = requestEpoch;
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
abortController = controller;
|
|
33
61
|
isPredicting = true;
|
|
34
62
|
insightText = '';
|
|
35
|
-
|
|
36
|
-
if (abortController) abortController.abort();
|
|
37
|
-
abortController = new AbortController();
|
|
38
63
|
|
|
39
64
|
const prompt = `Based on the following data/context, generate 3 concise, highly professional insights.
|
|
40
65
|
Use bullet points. Do not include introductory text like "Here are the insights".
|
|
41
66
|
|
|
42
67
|
Data Context:
|
|
43
|
-
${
|
|
68
|
+
${scopedContext}`;
|
|
44
69
|
|
|
45
70
|
try {
|
|
46
|
-
const result =
|
|
71
|
+
const result = scopedProvider.sendMessage(
|
|
47
72
|
[{ id: 'insights', role: 'user', content: prompt, timestamp: Date.now() }],
|
|
48
|
-
{ signal:
|
|
73
|
+
{ signal: controller.signal }
|
|
49
74
|
);
|
|
50
75
|
|
|
51
76
|
if (Symbol.asyncIterator in result) {
|
|
52
77
|
for await (const chunk of result as AsyncIterable<string>) {
|
|
78
|
+
if (epoch !== requestEpoch) return;
|
|
53
79
|
insightText += chunk;
|
|
54
80
|
}
|
|
55
81
|
} else {
|
|
56
|
-
|
|
82
|
+
const response = await result;
|
|
83
|
+
if (epoch === requestEpoch) insightText = response;
|
|
57
84
|
}
|
|
58
85
|
} catch (err: unknown) {
|
|
59
|
-
if (err instanceof Error && err.name !== 'AbortError') {
|
|
86
|
+
if (epoch === requestEpoch && err instanceof Error && err.name !== 'AbortError') {
|
|
60
87
|
insightText = 'Failed to generate insights.';
|
|
61
88
|
}
|
|
62
89
|
} finally {
|
|
63
|
-
|
|
90
|
+
if (epoch === requestEpoch) {
|
|
91
|
+
isPredicting = false;
|
|
92
|
+
if (abortController === controller) abortController = null;
|
|
93
|
+
}
|
|
64
94
|
}
|
|
65
95
|
}
|
|
66
96
|
</script>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import DevTools from './DevTools.svelte';
|
|
11
11
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
12
12
|
|
|
13
|
-
import { captureAdminContext
|
|
13
|
+
import { captureAdminContext } from '@svadmin/core';
|
|
14
14
|
import type { Identity, MenuItem, TaskProvider, TaskRecord } from '@svadmin/core';
|
|
15
15
|
import { getPath } from '../router-state.svelte.js';
|
|
16
16
|
import { Skeleton } from './ui/skeleton/index.js';
|
|
@@ -26,24 +26,26 @@
|
|
|
26
26
|
let mobileMenuOpen = $state(false);
|
|
27
27
|
|
|
28
28
|
let { children, title = 'Admin', menu, siteUrl, routeMode = 'auto' }: { children: Snippet; title?: string; menu?: MenuItem[]; siteUrl?: string; routeMode?: 'hash' | 'path' | 'auto' } = $props();
|
|
29
|
+
const layoutId = $props.id();
|
|
30
|
+
const layoutScope = `svadmin-layout-${layoutId}`;
|
|
31
|
+
const chatScope = `${layoutScope}-chat`;
|
|
29
32
|
const adminContext = captureAdminContext();
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
try {
|
|
33
|
-
auth = getAuthProvider();
|
|
34
|
-
} catch { /* Auth is optional for public layouts. */ }
|
|
34
|
+
const auth = $derived(adminContext.authProvider);
|
|
35
35
|
let loading = $state(true);
|
|
36
36
|
let identity = $state<Identity | null>(null);
|
|
37
|
-
const taskProvider =
|
|
37
|
+
const taskProvider = $derived(adminContext.taskProvider as TaskProvider<TaskRecord> | undefined);
|
|
38
38
|
const TaskQueueComponent = getComponentRegistry()?.TaskQueueDrawer;
|
|
39
39
|
|
|
40
40
|
$effect(() => {
|
|
41
|
+
const scopedAuth = auth;
|
|
41
42
|
let cancelled = false;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
|
|
44
|
+
identity = null;
|
|
45
|
+
loading = Boolean(scopedAuth);
|
|
46
|
+
if (!scopedAuth) return;
|
|
47
|
+
|
|
48
|
+
scopedAuth.getIdentity().then(id => {
|
|
47
49
|
if (!cancelled) {
|
|
48
50
|
identity = id;
|
|
49
51
|
loading = false;
|
|
@@ -72,7 +74,29 @@
|
|
|
72
74
|
let touchStartX = $state(0);
|
|
73
75
|
let touchEndX = $state(0);
|
|
74
76
|
|
|
77
|
+
function ownsLayoutEvent(event: Event): boolean {
|
|
78
|
+
const target = event.target instanceof HTMLElement ? event.target : document.activeElement as HTMLElement | null;
|
|
79
|
+
const owner = target
|
|
80
|
+
?.closest<HTMLElement>('[data-svadmin-layout-scope]')
|
|
81
|
+
?.dataset.svadminLayoutScope;
|
|
82
|
+
if (owner) return owner === layoutScope;
|
|
83
|
+
return document.querySelectorAll('[data-svadmin-layout-scope]').length === 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleGlobalKeydown(e: KeyboardEvent) {
|
|
87
|
+
if (!ownsLayoutEvent(e)) return;
|
|
88
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
commandOpen = true;
|
|
91
|
+
}
|
|
92
|
+
if (e.key === '?' && !['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement)?.tagName)) {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
shortcutsOpen = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
75
98
|
function handleTouchStart(e: TouchEvent) {
|
|
99
|
+
if (!ownsLayoutEvent(e)) return;
|
|
76
100
|
if (e.touches.length === 1) {
|
|
77
101
|
touchStartX = e.touches[0].clientX;
|
|
78
102
|
touchEndX = e.touches[0].clientX;
|
|
@@ -80,12 +104,14 @@
|
|
|
80
104
|
}
|
|
81
105
|
|
|
82
106
|
function handleTouchMove(e: TouchEvent) {
|
|
107
|
+
if (!ownsLayoutEvent(e)) return;
|
|
83
108
|
if (e.touches.length === 1) {
|
|
84
109
|
touchEndX = e.touches[0].clientX;
|
|
85
110
|
}
|
|
86
111
|
}
|
|
87
112
|
|
|
88
|
-
function handleTouchEnd() {
|
|
113
|
+
function handleTouchEnd(e: TouchEvent) {
|
|
114
|
+
if (!ownsLayoutEvent(e)) return;
|
|
89
115
|
// Only trigger swipe-to-open if starting near the left edge (e.g., within 30px)
|
|
90
116
|
// and swiping right by at least 50px
|
|
91
117
|
if (touchStartX < 30 && touchEndX - touchStartX > 50) {
|
|
@@ -95,16 +121,13 @@
|
|
|
95
121
|
</script>
|
|
96
122
|
|
|
97
123
|
<svelte:window
|
|
98
|
-
onkeydown={
|
|
99
|
-
if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); commandOpen = true; }
|
|
100
|
-
if (e.key === '?' && !['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement)?.tagName)) { e.preventDefault(); shortcutsOpen = true; }
|
|
101
|
-
}}
|
|
124
|
+
onkeydown={handleGlobalKeydown}
|
|
102
125
|
ontouchstart={handleTouchStart}
|
|
103
126
|
ontouchmove={handleTouchMove}
|
|
104
127
|
ontouchend={handleTouchEnd}
|
|
105
128
|
/>
|
|
106
129
|
{#if loading}
|
|
107
|
-
<div class="flex h-screen" in:fade={{ duration: 150 }}>
|
|
130
|
+
<div data-svadmin-layout-scope={layoutScope} class="flex h-screen" in:fade={{ duration: 150 }}>
|
|
108
131
|
<div class="hidden md:block w-[252px] bg-sidebar/80 p-4 space-y-4">
|
|
109
132
|
<Skeleton class="h-8 w-32" />
|
|
110
133
|
<div class="space-y-2 mt-6">
|
|
@@ -126,7 +149,7 @@
|
|
|
126
149
|
<DevTools docked />
|
|
127
150
|
</div>
|
|
128
151
|
{:else}
|
|
129
|
-
<div class="flex h-screen bg-background" in:fade={{ duration: 200, delay: 50 }}>
|
|
152
|
+
<div data-svadmin-layout-scope={layoutScope} class="flex h-screen bg-background" in:fade={{ duration: 200, delay: 50 }}>
|
|
130
153
|
<!-- Desktop sidebar -->
|
|
131
154
|
<div class="hidden md:block">
|
|
132
155
|
<Sidebar {collapsed} {identity} {title} {menu} {routeMode} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
|
|
@@ -190,7 +213,7 @@
|
|
|
190
213
|
|
|
191
214
|
<footer class="flex min-h-14 shrink-0 items-center justify-end gap-2 border-t border-border/60 bg-background px-4 empty:hidden">
|
|
192
215
|
<DevTools docked />
|
|
193
|
-
<ChatDialog docked />
|
|
216
|
+
<ChatDialog docked scope={chatScope} ownerScope={layoutScope} />
|
|
194
217
|
</footer>
|
|
195
218
|
</div>
|
|
196
219
|
</div>
|
|
@@ -198,7 +221,9 @@
|
|
|
198
221
|
bind:open={commandOpen}
|
|
199
222
|
onAskAI={(q) => {
|
|
200
223
|
if (typeof window !== 'undefined') {
|
|
201
|
-
window.dispatchEvent(new CustomEvent('svadmin:ask-ai', {
|
|
224
|
+
window.dispatchEvent(new CustomEvent('svadmin:ask-ai', {
|
|
225
|
+
detail: { query: q, scope: chatScope },
|
|
226
|
+
}));
|
|
202
227
|
}
|
|
203
228
|
}}
|
|
204
229
|
/>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { captureAdminContext, useLogin
|
|
2
|
+
import { captureAdminContext, useLogin } from '@svadmin/core';
|
|
3
3
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
4
4
|
import { Button } from './ui/button/index.js';
|
|
5
5
|
import { Input } from './ui/input/index.js';
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
const adminContext = captureAdminContext();
|
|
38
38
|
const login = useLogin({ errorMessage: false });
|
|
39
|
-
const authProvider =
|
|
39
|
+
const authProvider = $derived(adminContext.authProvider);
|
|
40
40
|
|
|
41
41
|
function getInitialIdentifier() {
|
|
42
42
|
return defaultIdentifier;
|
|
@@ -171,7 +171,7 @@
|
|
|
171
171
|
<Label for="login-password" class="text-sm font-semibold text-foreground/80">
|
|
172
172
|
{i18n.t('auth.password')}
|
|
173
173
|
</Label>
|
|
174
|
-
{#if authProvider
|
|
174
|
+
{#if authProvider?.forgotPassword}
|
|
175
175
|
<Button variant="link" class="text-xs h-auto p-0 font-medium hover:text-primary transition-colors" onclick={() => adminContext.navigate('/forgot-password')}>
|
|
176
176
|
{i18n.t('auth.forgotPasswordLink')}
|
|
177
177
|
</Button>
|
|
@@ -217,7 +217,7 @@
|
|
|
217
217
|
</div>
|
|
218
218
|
{/if}
|
|
219
219
|
|
|
220
|
-
{#if authProvider
|
|
220
|
+
{#if authProvider?.register}
|
|
221
221
|
<div class="flex items-center justify-center gap-1.5 mt-8 pt-6 border-t border-border/60">
|
|
222
222
|
<span class="text-sm text-muted-foreground">{i18n.t('auth.noAccount')}</span>
|
|
223
223
|
<Button variant="link" class="text-sm h-auto p-0 font-semibold text-primary hover:underline" onclick={() => adminContext.navigate('/register')}>
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
captureAdminContext,
|
|
4
|
+
useGetIdentity,
|
|
5
|
+
useUpdatePassword,
|
|
6
|
+
type AuthProvider,
|
|
7
|
+
} from '@svadmin/core';
|
|
3
8
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
4
9
|
import { Button } from './ui/button/index.js';
|
|
5
10
|
import { Label } from './ui/label/index.js';
|
|
@@ -10,21 +15,62 @@
|
|
|
10
15
|
import { User, Mail, Lock, Loader2, AlertCircle, CheckCircle, Camera } from '@lucide/svelte';
|
|
11
16
|
|
|
12
17
|
const i18n = useTranslation();
|
|
18
|
+
const adminContext = captureAdminContext();
|
|
13
19
|
|
|
14
20
|
const identity = useGetIdentity();
|
|
15
21
|
const updatePw = useUpdatePassword();
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const canUpdateProfile = !!authProvider?.updateProfile;
|
|
23
|
+
const authProvider = $derived(adminContext.authProvider);
|
|
24
|
+
const tenantIdentity = $derived(adminContext.tenantCacheKey?.__svadminTenant);
|
|
25
|
+
const canUpdateProfile = $derived(Boolean(authProvider?.updateProfile));
|
|
21
26
|
|
|
22
|
-
// Profile edit state
|
|
23
27
|
let editingProfile = $state(false);
|
|
24
28
|
let editName = $state('');
|
|
25
29
|
let profileSaving = $state(false);
|
|
26
30
|
let profileSuccess = $state(false);
|
|
27
31
|
let profileError = $state('');
|
|
32
|
+
let avatarUploading = $state(false);
|
|
33
|
+
let fileInput = $state<HTMLInputElement>();
|
|
34
|
+
let currentPassword = $state('');
|
|
35
|
+
let newPassword = $state('');
|
|
36
|
+
let confirmPassword = $state('');
|
|
37
|
+
let pwError = $state('');
|
|
38
|
+
let pwSuccess = $state(false);
|
|
39
|
+
let scopeEpoch = 0;
|
|
40
|
+
|
|
41
|
+
function resetProfileScope(): void {
|
|
42
|
+
editingProfile = false;
|
|
43
|
+
editName = '';
|
|
44
|
+
profileSaving = false;
|
|
45
|
+
profileSuccess = false;
|
|
46
|
+
profileError = '';
|
|
47
|
+
avatarUploading = false;
|
|
48
|
+
currentPassword = '';
|
|
49
|
+
newPassword = '';
|
|
50
|
+
confirmPassword = '';
|
|
51
|
+
pwError = '';
|
|
52
|
+
pwSuccess = false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isActiveScope(
|
|
56
|
+
epoch: number,
|
|
57
|
+
provider: AuthProvider | null,
|
|
58
|
+
scopedTenantIdentity: string | number | undefined,
|
|
59
|
+
): boolean {
|
|
60
|
+
return epoch === scopeEpoch
|
|
61
|
+
&& provider === authProvider
|
|
62
|
+
&& scopedTenantIdentity === tenantIdentity;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
$effect(() => {
|
|
66
|
+
void authProvider;
|
|
67
|
+
void tenantIdentity;
|
|
68
|
+
scopeEpoch++;
|
|
69
|
+
resetProfileScope();
|
|
70
|
+
return () => {
|
|
71
|
+
scopeEpoch++;
|
|
72
|
+
};
|
|
73
|
+
});
|
|
28
74
|
|
|
29
75
|
function startEditProfile() {
|
|
30
76
|
editName = identity.data?.name ?? '';
|
|
@@ -34,11 +80,15 @@
|
|
|
34
80
|
}
|
|
35
81
|
|
|
36
82
|
async function saveProfile() {
|
|
37
|
-
|
|
83
|
+
const provider = authProvider;
|
|
84
|
+
if (!provider?.updateProfile) return;
|
|
85
|
+
const epoch = scopeEpoch;
|
|
86
|
+
const scopedTenantIdentity = tenantIdentity;
|
|
38
87
|
profileSaving = true;
|
|
39
88
|
profileError = '';
|
|
40
89
|
try {
|
|
41
|
-
const result = await
|
|
90
|
+
const result = await provider.updateProfile({ name: editName });
|
|
91
|
+
if (!isActiveScope(epoch, provider, scopedTenantIdentity)) return;
|
|
42
92
|
if (result.success) {
|
|
43
93
|
profileSuccess = true;
|
|
44
94
|
editingProfile = false;
|
|
@@ -47,35 +97,29 @@
|
|
|
47
97
|
profileError = result.error?.message ?? i18n.t('common.operationFailed');
|
|
48
98
|
}
|
|
49
99
|
} catch (e) {
|
|
50
|
-
|
|
100
|
+
if (isActiveScope(epoch, provider, scopedTenantIdentity)) {
|
|
101
|
+
profileError = e instanceof Error ? e.message : String(e);
|
|
102
|
+
}
|
|
51
103
|
} finally {
|
|
52
|
-
profileSaving = false;
|
|
104
|
+
if (isActiveScope(epoch, provider, scopedTenantIdentity)) profileSaving = false;
|
|
53
105
|
}
|
|
54
106
|
}
|
|
55
107
|
|
|
56
|
-
// Avatar upload
|
|
57
|
-
let avatarUploading = $state(false);
|
|
58
|
-
let fileInput = $state<HTMLInputElement>(null!); // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
59
|
-
|
|
60
108
|
async function handleAvatarChange(e: Event) {
|
|
61
109
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
62
|
-
|
|
110
|
+
const provider = authProvider;
|
|
111
|
+
if (!file || !provider?.updateProfile) return;
|
|
112
|
+
const epoch = scopeEpoch;
|
|
113
|
+
const scopedTenantIdentity = tenantIdentity;
|
|
63
114
|
avatarUploading = true;
|
|
64
115
|
try {
|
|
65
|
-
const result = await
|
|
66
|
-
if (result.success) identity.refetch();
|
|
116
|
+
const result = await provider.updateProfile({ avatar: file });
|
|
117
|
+
if (isActiveScope(epoch, provider, scopedTenantIdentity) && result.success) identity.refetch();
|
|
67
118
|
} finally {
|
|
68
|
-
avatarUploading = false;
|
|
119
|
+
if (isActiveScope(epoch, provider, scopedTenantIdentity)) avatarUploading = false;
|
|
69
120
|
}
|
|
70
121
|
}
|
|
71
122
|
|
|
72
|
-
// Password change form
|
|
73
|
-
let currentPassword = $state('');
|
|
74
|
-
let newPassword = $state('');
|
|
75
|
-
let confirmPassword = $state('');
|
|
76
|
-
let pwError = $state('');
|
|
77
|
-
let pwSuccess = $state(false);
|
|
78
|
-
|
|
79
123
|
async function handlePasswordChange(e: SubmitEvent) {
|
|
80
124
|
e.preventDefault();
|
|
81
125
|
pwError = '';
|
|
@@ -84,11 +128,15 @@
|
|
|
84
128
|
if (!newPassword) { pwError = i18n.t('auth.passwordRequired'); return; }
|
|
85
129
|
if (newPassword !== confirmPassword) { pwError = i18n.t('auth.passwordMismatch'); return; }
|
|
86
130
|
|
|
131
|
+
const provider = authProvider;
|
|
132
|
+
const epoch = scopeEpoch;
|
|
133
|
+
const scopedTenantIdentity = tenantIdentity;
|
|
87
134
|
const result = await updatePw.mutate({
|
|
88
135
|
password: newPassword,
|
|
89
136
|
currentPassword,
|
|
90
137
|
confirmPassword,
|
|
91
138
|
});
|
|
139
|
+
if (!isActiveScope(epoch, provider, scopedTenantIdentity)) return;
|
|
92
140
|
if (result.success) {
|
|
93
141
|
pwSuccess = true;
|
|
94
142
|
currentPassword = '';
|
|
@@ -99,7 +147,6 @@
|
|
|
99
147
|
}
|
|
100
148
|
}
|
|
101
149
|
|
|
102
|
-
// Avatar initials
|
|
103
150
|
const initials = $derived(
|
|
104
151
|
identity.data?.name
|
|
105
152
|
? identity.data.name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2)
|
|
@@ -151,7 +198,7 @@
|
|
|
151
198
|
{#if canUpdateProfile}
|
|
152
199
|
<button
|
|
153
200
|
class="absolute inset-0 rounded-lg bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
|
154
|
-
onclick={() => fileInput
|
|
201
|
+
onclick={() => fileInput?.click()}
|
|
155
202
|
disabled={avatarUploading}
|
|
156
203
|
>
|
|
157
204
|
{#if avatarUploading}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { captureAdminContext } from '@svadmin/core';
|
|
3
3
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
4
4
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
5
5
|
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
import { Loader2, RotateCcw } from '@lucide/svelte';
|
|
8
8
|
|
|
9
9
|
const i18n = useTranslation();
|
|
10
|
+
const adminContext = captureAdminContext();
|
|
10
11
|
|
|
11
12
|
let {
|
|
12
13
|
taskId,
|
|
13
|
-
taskProvider =
|
|
14
|
+
taskProvider = adminContext.taskProvider ?? undefined,
|
|
14
15
|
onSuccess,
|
|
15
16
|
onError,
|
|
16
17
|
disabled = false,
|
|
@@ -37,8 +38,15 @@
|
|
|
37
38
|
try {
|
|
38
39
|
await taskProvider.retry(taskId);
|
|
39
40
|
await Promise.all([
|
|
40
|
-
queryClient.invalidateQueries({
|
|
41
|
-
|
|
41
|
+
queryClient.invalidateQueries({
|
|
42
|
+
predicate: (query) => query.queryKey[0] === 'taskList'
|
|
43
|
+
&& adminContext.matchesTenantQuery(query.queryKey),
|
|
44
|
+
}),
|
|
45
|
+
queryClient.invalidateQueries({
|
|
46
|
+
predicate: (query) => query.queryKey[0] === 'task'
|
|
47
|
+
&& query.queryKey[1] === taskId
|
|
48
|
+
&& adminContext.matchesTenantQuery(query.queryKey),
|
|
49
|
+
}),
|
|
42
50
|
]);
|
|
43
51
|
onSuccess?.();
|
|
44
52
|
} catch (error) {
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
3
|
-
import {
|
|
3
|
+
import { captureAdminContext } from '@svadmin/core';
|
|
4
|
+
import type { AuthProvider } from '@svadmin/core';
|
|
4
5
|
import PermissionMatrix from './PermissionMatrix.svelte';
|
|
5
6
|
import type { RoleInfo, ResourceInfo, ActionInfo } from '../types.js';
|
|
6
7
|
import { toast } from '@svadmin/core/toast';
|
|
7
8
|
import { AlertCircle } from '@lucide/svelte';
|
|
8
9
|
|
|
9
10
|
const i18n = useTranslation();
|
|
11
|
+
const adminContext = captureAdminContext();
|
|
10
12
|
|
|
11
|
-
const authProvider =
|
|
12
|
-
const rawResources =
|
|
13
|
+
const authProvider = $derived(adminContext.authProvider);
|
|
14
|
+
const rawResources = $derived(adminContext.resources);
|
|
13
15
|
|
|
14
16
|
let roles = $state<RoleInfo[]>([]);
|
|
15
17
|
let selectedRoleCode = $state<string>('');
|
|
@@ -21,6 +23,9 @@
|
|
|
21
23
|
let loadingPermissions = $state(false);
|
|
22
24
|
let saving = $state(false);
|
|
23
25
|
let error = $state<string | null>(null);
|
|
26
|
+
let roleRequestEpoch = 0;
|
|
27
|
+
let permissionRequestEpoch = 0;
|
|
28
|
+
let mutationRequestEpoch = 0;
|
|
24
29
|
|
|
25
30
|
// Convert system resources to MatrixResource format
|
|
26
31
|
let matrixResources = $derived<ResourceInfo[]>(
|
|
@@ -37,36 +42,38 @@
|
|
|
37
42
|
{ code: 'delete', name: i18n.t('common.delete') ?? 'Delete' },
|
|
38
43
|
]);
|
|
39
44
|
|
|
40
|
-
async function loadRoles() {
|
|
41
|
-
if (!
|
|
45
|
+
async function loadRoles(scopedProvider: AuthProvider | null, epoch: number) {
|
|
46
|
+
if (!scopedProvider?.getRoles) {
|
|
47
|
+
if (epoch !== roleRequestEpoch) return;
|
|
42
48
|
error = i18n.t('settings.rbacNotSupported') ?? 'RBAC not supported by AuthProvider';
|
|
43
49
|
loadingRoles = false;
|
|
44
50
|
return;
|
|
45
51
|
}
|
|
46
52
|
try {
|
|
47
|
-
const fetchedRoles = await
|
|
53
|
+
const fetchedRoles = await scopedProvider.getRoles();
|
|
54
|
+
if (epoch !== roleRequestEpoch) return;
|
|
48
55
|
roles = fetchedRoles.map(r => ({ code: r.id, ...r }));
|
|
49
|
-
|
|
50
|
-
selectedRoleCode = roles[0].code;
|
|
51
|
-
}
|
|
56
|
+
selectedRoleCode = roles[0]?.code ?? '';
|
|
52
57
|
} catch (e) {
|
|
53
|
-
error = (e as Error).message;
|
|
58
|
+
if (epoch === roleRequestEpoch) error = (e as Error).message;
|
|
54
59
|
} finally {
|
|
55
|
-
loadingRoles = false;
|
|
60
|
+
if (epoch === roleRequestEpoch) loadingRoles = false;
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
async function loadPermissionsForRole(roleId: string) {
|
|
60
|
-
|
|
65
|
+
const scopedProvider = authProvider;
|
|
66
|
+
const epoch = permissionRequestEpoch;
|
|
67
|
+
if (!scopedProvider?.getRolePermissions || permissionsCache[roleId]) return;
|
|
61
68
|
|
|
62
69
|
loadingPermissions = true;
|
|
63
70
|
try {
|
|
64
|
-
const perms = await
|
|
65
|
-
permissionsCache[roleId] = perms;
|
|
71
|
+
const perms = await scopedProvider.getRolePermissions(roleId);
|
|
72
|
+
if (epoch === permissionRequestEpoch) permissionsCache[roleId] = perms;
|
|
66
73
|
} catch (e) {
|
|
67
|
-
toast.error((e as Error).message);
|
|
74
|
+
if (epoch === permissionRequestEpoch) toast.error((e as Error).message);
|
|
68
75
|
} finally {
|
|
69
|
-
loadingPermissions = false;
|
|
76
|
+
if (epoch === permissionRequestEpoch) loadingPermissions = false;
|
|
70
77
|
}
|
|
71
78
|
}
|
|
72
79
|
|
|
@@ -78,7 +85,25 @@
|
|
|
78
85
|
});
|
|
79
86
|
|
|
80
87
|
$effect(() => {
|
|
81
|
-
|
|
88
|
+
const scopedProvider = authProvider;
|
|
89
|
+
const epoch = ++roleRequestEpoch;
|
|
90
|
+
void adminContext.tenantCacheKey?.__svadminTenant;
|
|
91
|
+
permissionRequestEpoch += 1;
|
|
92
|
+
mutationRequestEpoch += 1;
|
|
93
|
+
roles = [];
|
|
94
|
+
selectedRoleCode = '';
|
|
95
|
+
permissionsCache = {};
|
|
96
|
+
error = null;
|
|
97
|
+
loadingRoles = true;
|
|
98
|
+
loadingPermissions = false;
|
|
99
|
+
saving = false;
|
|
100
|
+
void loadRoles(scopedProvider, epoch);
|
|
101
|
+
|
|
102
|
+
return () => {
|
|
103
|
+
roleRequestEpoch += 1;
|
|
104
|
+
permissionRequestEpoch += 1;
|
|
105
|
+
mutationRequestEpoch += 1;
|
|
106
|
+
};
|
|
82
107
|
});
|
|
83
108
|
|
|
84
109
|
// isGranted logic
|
|
@@ -89,7 +114,9 @@
|
|
|
89
114
|
}
|
|
90
115
|
|
|
91
116
|
async function handleToggle(roleCode: string, resourceCode: string, actionCode: string, grant: boolean) {
|
|
92
|
-
|
|
117
|
+
const scopedProvider = authProvider;
|
|
118
|
+
if (!scopedProvider?.updateRolePermissions) return;
|
|
119
|
+
const mutationEpoch = ++mutationRequestEpoch;
|
|
93
120
|
|
|
94
121
|
// Copy state
|
|
95
122
|
const currentRolePerms = { ...(permissionsCache[roleCode] || {}) };
|
|
@@ -109,19 +136,21 @@
|
|
|
109
136
|
saving = true;
|
|
110
137
|
try {
|
|
111
138
|
// Send the entire role's permissions back (as defined by our AuthProvider interface)
|
|
112
|
-
const result = await
|
|
139
|
+
const result = await scopedProvider.updateRolePermissions(roleCode, currentRolePerms);
|
|
140
|
+
if (mutationEpoch !== mutationRequestEpoch) return;
|
|
113
141
|
if (result.success) {
|
|
114
142
|
// Success silently
|
|
115
143
|
} else {
|
|
116
144
|
throw new Error(result.error?.message ?? i18n.t('common.operationFailed'));
|
|
117
145
|
}
|
|
118
146
|
} catch (e) {
|
|
147
|
+
if (mutationEpoch !== mutationRequestEpoch) return;
|
|
119
148
|
toast.error((e as Error).message);
|
|
120
149
|
// Rollback on fail
|
|
121
150
|
delete permissionsCache[roleCode]; // eslint-disable-line @typescript-eslint/no-dynamic-delete
|
|
122
|
-
loadPermissionsForRole(roleCode);
|
|
151
|
+
void loadPermissionsForRole(roleCode);
|
|
123
152
|
} finally {
|
|
124
|
-
saving = false;
|
|
153
|
+
if (mutationEpoch === mutationRequestEpoch) saving = false;
|
|
125
154
|
}
|
|
126
155
|
}
|
|
127
156
|
</script>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Component, Snippet } from "svelte";
|
|
3
|
-
import { captureAdminContext
|
|
3
|
+
import { captureAdminContext } from "@svadmin/core";
|
|
4
4
|
import { useTranslation } from "@svadmin/core/i18n";
|
|
5
5
|
|
|
6
6
|
import { User, Palette, Info, Shield, FileSearch, Lock, Puzzle, Bell, Key } from "@lucide/svelte";
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import ApiSettings from "./ApiSettings.svelte";
|
|
16
16
|
|
|
17
17
|
const adminContext = captureAdminContext();
|
|
18
|
-
const authProvider =
|
|
18
|
+
const authProvider = $derived(adminContext.authProvider);
|
|
19
19
|
|
|
20
20
|
interface SettingsItem {
|
|
21
21
|
key: string;
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
content,
|
|
58
58
|
}: Props = $props();
|
|
59
59
|
|
|
60
|
-
const defaultSections = [
|
|
60
|
+
const defaultSections = $derived.by((): SettingsSection[] => [
|
|
61
61
|
{
|
|
62
62
|
group: "settings.general",
|
|
63
63
|
items: [
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
{ key: "about", path: "/settings/about", icon: Info, label: "settings.about" },
|
|
87
87
|
],
|
|
88
88
|
},
|
|
89
|
-
];
|
|
89
|
+
]);
|
|
90
90
|
|
|
91
91
|
const resolvedSections = $derived.by(() => {
|
|
92
92
|
if (customSections) return customSections;
|