@svadmin/ui 0.40.5 → 0.41.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.
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { getChatProvider } from '@svadmin/core';
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 provider = $derived(getChatProvider());
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 hasFetchedForContext = $state('');
22
+ let requestEpoch = 0;
22
23
 
23
24
  $effect(() => {
24
- if (autoFetch && context && provider && hasFetchedForContext !== context) {
25
- hasFetchedForContext = context;
26
- getInsights();
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
- if (!provider || !context) return;
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
- ${context}`;
68
+ ${scopedContext}`;
44
69
 
45
70
  try {
46
- const result = provider.sendMessage(
71
+ const result = scopedProvider.sendMessage(
47
72
  [{ id: 'insights', role: 'user', content: prompt, timestamp: Date.now() }],
48
- { signal: abortController.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
- insightText = await result;
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
- isPredicting = false;
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, getAuthProvider, getTaskProvider } from '@svadmin/core';
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
- let auth: ReturnType<typeof getAuthProvider> | null = null;
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 = getTaskProvider({ optional: true }) as TaskProvider<TaskRecord> | undefined;
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
- if (!auth) {
43
- if (!cancelled) loading = false;
44
- return;
45
- }
46
- auth.getIdentity().then(id => {
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={(e) => {
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', { detail: q }));
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, getAuthProvider } from '@svadmin/core';
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 = getAuthProvider();
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.forgotPassword}
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.register}
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 { useGetIdentity, useUpdatePassword, getAuthProvider } from '@svadmin/core';
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
- // Check if auth provider supports updateProfile
18
- let authProvider: ReturnType<typeof getAuthProvider> | null = null;
19
- try { authProvider = getAuthProvider(); } catch { /* no auth */ }
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
- if (!authProvider?.updateProfile) return;
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 authProvider.updateProfile({ name: editName });
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
- profileError = (e as Error).message;
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
- if (!file || !authProvider?.updateProfile) return;
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 authProvider.updateProfile({ avatar: file });
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.click()}
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 { getTaskProvider } from '@svadmin/core';
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 = getTaskProvider({ optional: true }),
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({ queryKey: ['taskList'] }),
41
- queryClient.invalidateQueries({ queryKey: ['task', taskId] }),
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 { getAuthProvider, getResources } from '@svadmin/core';
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 = getAuthProvider({ optional: true });
12
- const rawResources = getResources();
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 (!authProvider?.getRoles) {
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 authProvider.getRoles();
53
+ const fetchedRoles = await scopedProvider.getRoles();
54
+ if (epoch !== roleRequestEpoch) return;
48
55
  roles = fetchedRoles.map(r => ({ code: r.id, ...r }));
49
- if (roles.length > 0 && !selectedRoleCode) {
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
- if (!authProvider?.getRolePermissions || permissionsCache[roleId]) return;
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 authProvider.getRolePermissions(roleId);
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
- loadRoles();
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
- if (!authProvider?.updateRolePermissions) return;
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 authProvider.updateRolePermissions(roleCode, currentRolePerms);
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, getAuthProvider } from "@svadmin/core";
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 = getAuthProvider({ optional: true });
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;