@svadmin/core 0.18.1 → 0.19.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/package.json +1 -1
- package/src/auth-hooks.svelte.ts +14 -12
- package/src/form-hooks.svelte.ts +10 -4
- package/src/hooks.svelte.ts +5 -0
- package/src/i18n.svelte.ts +16 -6
- package/src/permissions.test.ts +39 -27
- package/src/theme.svelte.ts +2 -2
- package/src/unsaved-changes.svelte.ts +92 -40
package/package.json
CHANGED
package/src/auth-hooks.svelte.ts
CHANGED
|
@@ -266,7 +266,9 @@ export function useOnError() {
|
|
|
266
266
|
|
|
267
267
|
/**
|
|
268
268
|
* Fetches permissions from authProvider.getPermissions().
|
|
269
|
-
* Returns a reactive
|
|
269
|
+
* Returns a reactive object with convenience methods for permission checks.
|
|
270
|
+
*
|
|
271
|
+
* Supports `refetch()` for session-level permission refresh (e.g., after role change).
|
|
270
272
|
*
|
|
271
273
|
* @example
|
|
272
274
|
* ```svelte
|
|
@@ -282,9 +284,12 @@ export function usePermissions<T = unknown>() {
|
|
|
282
284
|
let permissions = $state<T | null>(null);
|
|
283
285
|
let isLoading = $state(true);
|
|
284
286
|
let error = $state<Error | null>(null);
|
|
285
|
-
let version = $state(0);
|
|
287
|
+
let version = $state(0);
|
|
286
288
|
|
|
287
|
-
|
|
289
|
+
function fetch() {
|
|
290
|
+
if (!provider?.getPermissions) { isLoading = false; return; }
|
|
291
|
+
isLoading = true;
|
|
292
|
+
error = null;
|
|
288
293
|
provider.getPermissions().then(p => {
|
|
289
294
|
permissions = p as T;
|
|
290
295
|
version++;
|
|
@@ -294,19 +299,18 @@ export function usePermissions<T = unknown>() {
|
|
|
294
299
|
isLoading = false;
|
|
295
300
|
console.warn('[svadmin] usePermissions failed:', err);
|
|
296
301
|
});
|
|
297
|
-
} else {
|
|
298
|
-
isLoading = false;
|
|
299
302
|
}
|
|
300
303
|
|
|
304
|
+
fetch();
|
|
305
|
+
|
|
301
306
|
return {
|
|
302
307
|
get isLoading() { return isLoading; },
|
|
303
308
|
get error() { return error; },
|
|
304
309
|
get version() { return version; },
|
|
305
|
-
|
|
306
|
-
/** Get the raw permissions data */
|
|
307
310
|
get raw() { return permissions; },
|
|
311
|
+
refetch: fetch,
|
|
308
312
|
|
|
309
|
-
/**
|
|
313
|
+
/** Check if a specific permission string exists */
|
|
310
314
|
has(perm: string): boolean {
|
|
311
315
|
if (!permissions) return false;
|
|
312
316
|
if (Array.isArray(permissions)) return permissions.includes(perm);
|
|
@@ -315,12 +319,10 @@ export function usePermissions<T = unknown>() {
|
|
|
315
319
|
return false;
|
|
316
320
|
},
|
|
317
321
|
|
|
318
|
-
/**
|
|
322
|
+
/** Check resource:action style permission */
|
|
319
323
|
can(resource: string, action: string): boolean {
|
|
320
|
-
// Tries to map to a standard permission string format like "resource:action"
|
|
321
|
-
// Replace with your own logic if your permissions are structured differently.
|
|
322
324
|
return this.has(`${resource}:${action}`);
|
|
323
|
-
}
|
|
325
|
+
},
|
|
324
326
|
};
|
|
325
327
|
}
|
|
326
328
|
|
package/src/form-hooks.svelte.ts
CHANGED
|
@@ -326,10 +326,16 @@ export function useForm<
|
|
|
326
326
|
|
|
327
327
|
if (!runValidation()) { toast.warning(t('validation.required')); return; }
|
|
328
328
|
redirectOverride = overrides?.redirect;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
329
|
+
try {
|
|
330
|
+
if (action === 'create' || action === 'clone') await createMut.mutateAsync(values);
|
|
331
|
+
else await updateMut.mutateAsync(values);
|
|
332
|
+
// Untaint on success
|
|
333
|
+
tainted = {};
|
|
334
|
+
} catch (error) {
|
|
335
|
+
// Errors are already handled by the mutation's onError callback.
|
|
336
|
+
// We catch here to prevent unhandled promise rejections.
|
|
337
|
+
handleHttpError(error instanceof Error ? error : new Error(String(error)));
|
|
338
|
+
}
|
|
333
339
|
}
|
|
334
340
|
|
|
335
341
|
// ─── AutoSave ───────────────────────────────────────────────────
|
package/src/hooks.svelte.ts
CHANGED
|
@@ -170,6 +170,11 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
|
|
|
170
170
|
debounceTimer = setTimeout(() => { searchText = value; }, debounceMs);
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
// Cleanup debounce timer on unmount
|
|
174
|
+
$effect(() => {
|
|
175
|
+
return () => { if (debounceTimer) clearTimeout(debounceTimer); };
|
|
176
|
+
});
|
|
177
|
+
|
|
173
178
|
return {
|
|
174
179
|
query,
|
|
175
180
|
get options() { return selectOptions; },
|
package/src/i18n.svelte.ts
CHANGED
|
@@ -519,13 +519,23 @@ export function addTranslations(locale: string, translations: Record<string, str
|
|
|
519
519
|
}
|
|
520
520
|
|
|
521
521
|
/**
|
|
522
|
-
* useTranslation —
|
|
523
|
-
*
|
|
522
|
+
* useTranslation — Svelte 5 reactive translation hook
|
|
523
|
+
* Tracks locale changes natively.
|
|
524
524
|
*/
|
|
525
525
|
export function useTranslation() {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
526
|
+
const translate = $derived.by(() => {
|
|
527
|
+
// Read currentLocale so this $derived depends on it
|
|
528
|
+
const loc = currentLocale;
|
|
529
|
+
return (key: string, params?: Record<string, string | number>): string => {
|
|
530
|
+
// Intentionally passing the key to the main t() function which uses currentLocale
|
|
531
|
+
return t(key, params);
|
|
532
|
+
};
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
get t() { return translate; },
|
|
537
|
+
get locale() { return currentLocale; },
|
|
538
|
+
setLocale,
|
|
539
|
+
getAvailableLocales
|
|
530
540
|
};
|
|
531
541
|
}
|
package/src/permissions.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Unit tests for permissions module
|
|
2
2
|
import { describe, test, expect, beforeEach } from 'bun:test';
|
|
3
|
-
import { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccessAsync } from './permissions';
|
|
3
|
+
import { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccessAsync, type CanParams, type CanResult } from './permissions';
|
|
4
4
|
import type { Action, AccessControlProvider } from './permissions';
|
|
5
5
|
|
|
6
6
|
describe('permissions', () => {
|
|
@@ -13,21 +13,26 @@ describe('permissions', () => {
|
|
|
13
13
|
|
|
14
14
|
test('canAccessAsync respects deny rule', async () => {
|
|
15
15
|
setAccessControlProvider({
|
|
16
|
-
can: async (
|
|
16
|
+
can: async (p) => {
|
|
17
|
+
const { action } = p as CanParams;
|
|
17
18
|
if (action === 'delete') return { can: false, reason: 'Denied' };
|
|
18
19
|
return { can: true };
|
|
19
20
|
},
|
|
20
21
|
});
|
|
21
22
|
expect(await canAccessAsync('posts', 'delete')).toEqual({ can: false, reason: 'Denied' });
|
|
22
|
-
expect(await canAccessAsync('posts', '
|
|
23
|
+
expect((await canAccessAsync('posts', 'delete')) as CanResult).toEqual({ can: false, reason: 'Denied' });
|
|
24
|
+
expect((await canAccessAsync('posts', 'list')) as CanResult).toEqual({ can: true });
|
|
23
25
|
});
|
|
24
26
|
|
|
25
27
|
test('canAccessAsync resolves with allowed status', async () => {
|
|
26
28
|
setAccessControlProvider({
|
|
27
|
-
can: async () =>
|
|
29
|
+
can: async (params) => {
|
|
30
|
+
expect((params as CanParams).params?.published).toBe(true);
|
|
31
|
+
return { can: true };
|
|
32
|
+
},
|
|
28
33
|
});
|
|
29
|
-
const result = await canAccessAsync('posts', 'edit');
|
|
30
|
-
expect((result as
|
|
34
|
+
const result = await canAccessAsync('posts', 'edit', { published: true });
|
|
35
|
+
expect((result as CanResult).can).toBe(true);
|
|
31
36
|
});
|
|
32
37
|
|
|
33
38
|
test('canAccessAsync resolves with denied status', async () => {
|
|
@@ -35,20 +40,21 @@ describe('permissions', () => {
|
|
|
35
40
|
can: async () => ({ can: false, reason: 'No permission' }),
|
|
36
41
|
});
|
|
37
42
|
const result = await canAccessAsync('users', 'delete');
|
|
38
|
-
expect((result as
|
|
39
|
-
expect((result as
|
|
43
|
+
expect((result as CanResult).can).toBe(false);
|
|
44
|
+
expect((result as CanResult).reason).toBe('No permission');
|
|
40
45
|
});
|
|
41
46
|
|
|
42
47
|
test('resource-specific rules', async () => {
|
|
43
48
|
setAccessControlProvider({
|
|
44
|
-
can: async (
|
|
49
|
+
can: async (p) => {
|
|
50
|
+
const { resource, action } = p as CanParams;
|
|
45
51
|
if (resource === 'users' && action === 'delete') return { can: false, reason: 'Cannot delete users' };
|
|
46
52
|
return { can: true };
|
|
47
53
|
},
|
|
48
54
|
});
|
|
49
|
-
expect((await canAccessAsync('users', 'delete')).can).toBe(false);
|
|
50
|
-
expect((await canAccessAsync('users', 'list')).can).toBe(true);
|
|
51
|
-
expect((await canAccessAsync('posts', 'delete')).can).toBe(true);
|
|
55
|
+
expect((await canAccessAsync('users', 'delete') as CanResult).can).toBe(false);
|
|
56
|
+
expect((await canAccessAsync('users', 'list') as CanResult).can).toBe(true);
|
|
57
|
+
expect((await canAccessAsync('posts', 'delete') as CanResult).can).toBe(true);
|
|
52
58
|
});
|
|
53
59
|
});
|
|
54
60
|
|
|
@@ -65,45 +71,51 @@ describe('AccessControlProvider', () => {
|
|
|
65
71
|
});
|
|
66
72
|
|
|
67
73
|
test('provider can() is called via canAccessAsync', async () => {
|
|
74
|
+
let callCount = 0;
|
|
68
75
|
setAccessControlProvider({
|
|
69
|
-
can: async (
|
|
70
|
-
|
|
76
|
+
can: async (params) => {
|
|
77
|
+
callCount++;
|
|
78
|
+
expect((params as CanParams).resource).toBe('posts');
|
|
79
|
+
if ((params as CanParams).action === 'delete') {
|
|
71
80
|
return { can: false, reason: 'Cannot delete posts' };
|
|
72
81
|
}
|
|
73
82
|
return { can: true };
|
|
74
83
|
},
|
|
75
84
|
});
|
|
76
|
-
const denied = await canAccessAsync('posts', 'delete');
|
|
85
|
+
const denied = await canAccessAsync('posts', 'delete') as CanResult;
|
|
77
86
|
expect(denied.can).toBe(false);
|
|
78
87
|
expect(denied.reason).toBe('Cannot delete posts');
|
|
79
88
|
|
|
80
|
-
const allowed = await canAccessAsync('posts', 'list');
|
|
89
|
+
const allowed = await canAccessAsync('posts', 'list') as CanResult;
|
|
81
90
|
expect(allowed.can).toBe(true);
|
|
91
|
+
expect(callCount).toBe(2);
|
|
82
92
|
});
|
|
83
93
|
|
|
84
94
|
test('params.id is passed through', async () => {
|
|
85
95
|
setAccessControlProvider({
|
|
86
|
-
can: async (
|
|
96
|
+
can: async (p) => {
|
|
97
|
+
const { params } = p as CanParams;
|
|
87
98
|
if (params?.id === 42) return { can: false, reason: 'Record locked' };
|
|
88
99
|
return { can: true };
|
|
89
100
|
},
|
|
90
101
|
});
|
|
91
102
|
const result = await canAccessAsync('posts', 'edit', { id: 42 });
|
|
92
|
-
expect((result as
|
|
93
|
-
expect((result as
|
|
103
|
+
expect((result as CanResult).can).toBe(false);
|
|
104
|
+
expect((result as CanResult).reason).toBe('Record locked');
|
|
94
105
|
});
|
|
95
106
|
|
|
96
107
|
test('extended action types work', async () => {
|
|
97
108
|
setAccessControlProvider({
|
|
98
|
-
can: async (
|
|
109
|
+
can: async (p) => {
|
|
110
|
+
const { action } = p as CanParams;
|
|
99
111
|
if (action === 'show') return { can: false, reason: 'No show' };
|
|
100
112
|
if (action === 'field') return { can: false, reason: 'No field' };
|
|
101
113
|
return { can: true };
|
|
102
114
|
},
|
|
103
115
|
});
|
|
104
|
-
expect((await canAccessAsync('posts', 'show')).can).toBe(false);
|
|
105
|
-
expect((await canAccessAsync('posts', 'field')).can).toBe(false);
|
|
106
|
-
expect((await canAccessAsync('posts', 'list')).can).toBe(true);
|
|
116
|
+
expect(((await canAccessAsync('posts', 'show')) as CanResult).can).toBe(false);
|
|
117
|
+
expect(((await canAccessAsync('posts', 'field')) as CanResult).can).toBe(false);
|
|
118
|
+
expect(((await canAccessAsync('posts', 'list')) as CanResult).can).toBe(true);
|
|
107
119
|
});
|
|
108
120
|
|
|
109
121
|
test('getAccessControlOptions returns provider options', () => {
|
|
@@ -139,10 +151,10 @@ describe('CASL adapter', () => {
|
|
|
139
151
|
|
|
140
152
|
const provider = createCaslAccessControl(ability);
|
|
141
153
|
|
|
142
|
-
const allowed = await provider.can({ resource: 'posts', action: 'edit' });
|
|
154
|
+
const allowed = await provider.can({ resource: 'posts', action: 'edit' }) as CanResult;
|
|
143
155
|
expect(allowed.can).toBe(true);
|
|
144
156
|
|
|
145
|
-
const denied = await provider.can({ resource: 'users', action: 'delete' });
|
|
157
|
+
const denied = await provider.can({ resource: 'users', action: 'delete' }) as CanResult;
|
|
146
158
|
expect(denied.can).toBe(false);
|
|
147
159
|
expect(denied.reason).toContain('Cannot "delete" on "users"');
|
|
148
160
|
});
|
|
@@ -162,10 +174,10 @@ describe('Casbin adapter', () => {
|
|
|
162
174
|
getUser: () => 'alice',
|
|
163
175
|
});
|
|
164
176
|
|
|
165
|
-
const allowed = await provider.can({ resource: 'posts', action: 'edit' });
|
|
177
|
+
const allowed = await provider.can({ resource: 'posts', action: 'edit' }) as CanResult;
|
|
166
178
|
expect(allowed.can).toBe(true);
|
|
167
179
|
|
|
168
|
-
const denied = await provider.can({ resource: 'users', action: 'delete' });
|
|
180
|
+
const denied = await provider.can({ resource: 'users', action: 'delete' }) as CanResult;
|
|
169
181
|
expect(denied.can).toBe(false);
|
|
170
182
|
expect(denied.reason).toContain('alice');
|
|
171
183
|
});
|
package/src/theme.svelte.ts
CHANGED
|
@@ -336,7 +336,7 @@ function applyTheme(m: ThemeMode): void {
|
|
|
336
336
|
}
|
|
337
337
|
|
|
338
338
|
// Apply on init
|
|
339
|
-
applyTheme(
|
|
339
|
+
applyTheme(getStoredTheme());
|
|
340
340
|
|
|
341
341
|
// Listen for system preference changes
|
|
342
342
|
if (typeof window !== 'undefined') {
|
|
@@ -383,7 +383,7 @@ function applyColorTheme(ct: ColorTheme): void {
|
|
|
383
383
|
}
|
|
384
384
|
|
|
385
385
|
// Apply on init
|
|
386
|
-
applyColorTheme(
|
|
386
|
+
applyColorTheme(getStoredColorTheme());
|
|
387
387
|
|
|
388
388
|
export function getColorTheme(): ColorTheme {
|
|
389
389
|
return colorTheme;
|
|
@@ -1,62 +1,114 @@
|
|
|
1
|
-
// UnsavedChangesNotifier —
|
|
2
|
-
//
|
|
1
|
+
// UnsavedChangesNotifier — guards against accidental navigation with dirty forms
|
|
2
|
+
// Supports: browser tab close, hash-based SPA routing, and framework router interception
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import { t } from './i18n.svelte';
|
|
5
5
|
|
|
6
|
-
let
|
|
6
|
+
let dirty = $state(false);
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*/
|
|
12
|
-
export function setUnsavedChanges(dirty: boolean) {
|
|
13
|
-
warnWhenUnsavedChanges = dirty;
|
|
8
|
+
/** Mark whether the current form has unsaved changes */
|
|
9
|
+
export function setUnsavedChanges(value: boolean) {
|
|
10
|
+
dirty = value;
|
|
14
11
|
}
|
|
15
12
|
|
|
16
13
|
export function getUnsavedChanges(): boolean {
|
|
17
|
-
return
|
|
14
|
+
return dirty;
|
|
18
15
|
}
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Navigation interceptor callback type.
|
|
19
|
+
* Frameworks like SvelteKit can register a cancel function here.
|
|
20
|
+
* Return `true` to allow navigation, `false` to block it.
|
|
21
|
+
*/
|
|
22
|
+
type NavigationGuard = (opts: { cancel: () => void }) => void;
|
|
23
|
+
|
|
24
|
+
let _navigationGuard: NavigationGuard | null = null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Register a framework-level navigation guard.
|
|
28
|
+
*
|
|
29
|
+
* In SvelteKit, call this from your root layout:
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { beforeNavigate } from '$app/navigation';
|
|
32
|
+
* import { registerNavigationGuard } from '@svadmin/core';
|
|
33
|
+
*
|
|
34
|
+
* registerNavigationGuard(({ cancel }) => {
|
|
35
|
+
* beforeNavigate(({ cancel: kitCancel }) => {
|
|
36
|
+
* cancel = kitCancel;
|
|
37
|
+
* });
|
|
38
|
+
* });
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* Or more idiomatically, use the built-in `initUnsavedChangesNotifier`
|
|
42
|
+
* and pass a `beforeNavigate` callback.
|
|
43
|
+
*/
|
|
44
|
+
export function registerNavigationGuard(guard: NavigationGuard): void {
|
|
45
|
+
_navigationGuard = guard;
|
|
46
|
+
}
|
|
28
47
|
|
|
29
48
|
/**
|
|
30
49
|
* Initialize the UnsavedChangesNotifier.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
50
|
+
*
|
|
51
|
+
* @param options.beforeNavigate - Framework-level navigation interceptor.
|
|
52
|
+
* For SvelteKit: pass the `beforeNavigate` function from `$app/navigation`.
|
|
53
|
+
* For hash-based SPAs: omit this, hash navigation is handled automatically.
|
|
54
|
+
*
|
|
55
|
+
* @example SvelteKit
|
|
56
|
+
* ```svelte
|
|
57
|
+
* <script>
|
|
58
|
+
* import { beforeNavigate } from '$app/navigation';
|
|
59
|
+
* import { initUnsavedChangesNotifier } from '@svadmin/core';
|
|
60
|
+
* initUnsavedChangesNotifier({ beforeNavigate });
|
|
61
|
+
* </script>
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* @example Hash-based SPA
|
|
65
|
+
* ```ts
|
|
66
|
+
* initUnsavedChangesNotifier();
|
|
67
|
+
* ```
|
|
33
68
|
*/
|
|
34
|
-
export function initUnsavedChangesNotifier(
|
|
69
|
+
export function initUnsavedChangesNotifier(options?: {
|
|
70
|
+
beforeNavigate?: (callback: (nav: { cancel: () => void }) => void) => void;
|
|
71
|
+
}) {
|
|
35
72
|
if (typeof window === 'undefined') return;
|
|
36
73
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
74
|
+
// 1. Browser tab close / refresh
|
|
75
|
+
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
76
|
+
if (!dirty) return;
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
e.returnValue = '';
|
|
79
|
+
};
|
|
80
|
+
window.addEventListener('beforeunload', onBeforeUnload);
|
|
81
|
+
|
|
82
|
+
// 2. Framework router (SvelteKit, etc.)
|
|
83
|
+
if (options?.beforeNavigate) {
|
|
84
|
+
options.beforeNavigate(({ cancel }) => {
|
|
85
|
+
if (dirty) {
|
|
86
|
+
if (!confirm(t('common.unsavedChanges'))) {
|
|
87
|
+
cancel();
|
|
88
|
+
} else {
|
|
89
|
+
dirty = false;
|
|
49
90
|
}
|
|
50
|
-
} else {
|
|
51
|
-
warnWhenUnsavedChanges = false;
|
|
52
91
|
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. Hash-based fallback for SPAs without a framework router
|
|
96
|
+
const onHashChange = (e: HashChangeEvent) => {
|
|
97
|
+
if (!dirty) return;
|
|
98
|
+
if (!confirm(t('common.unsavedChanges'))) {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
if (e.oldURL) {
|
|
101
|
+
history.replaceState(null, '', new URL(e.oldURL).hash || '#');
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
dirty = false;
|
|
53
105
|
}
|
|
54
106
|
};
|
|
107
|
+
window.addEventListener('hashchange', onHashChange);
|
|
55
108
|
|
|
56
|
-
|
|
57
|
-
|
|
109
|
+
// Cleanup
|
|
58
110
|
return () => {
|
|
59
|
-
window.removeEventListener('beforeunload',
|
|
60
|
-
window.removeEventListener('hashchange',
|
|
111
|
+
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
112
|
+
window.removeEventListener('hashchange', onHashChange);
|
|
61
113
|
};
|
|
62
114
|
}
|