@svadmin/core 0.18.1 → 0.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "author": "zuohuadong",
5
5
  "repository": {
6
6
  "type": "git",
@@ -266,7 +266,9 @@ export function useOnError() {
266
266
 
267
267
  /**
268
268
  * Fetches permissions from authProvider.getPermissions().
269
- * Returns a reactive integrated object.
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); // Force reactive invalidation
287
+ let version = $state(0);
286
288
 
287
- if (provider?.getPermissions) {
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
- /** Convenience: check if a specific permission exists */
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
- /** Convenience: check resource action (Casbin style representation if applicable) */
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
 
@@ -519,13 +519,23 @@ export function addTranslations(locale: string, translations: Record<string, str
519
519
  }
520
520
 
521
521
  /**
522
- * useTranslation — standard i18n hook
523
- * Returns { translate, getLocale, changeLocale }
522
+ * useTranslation — Svelte 5 reactive translation hook
523
+ * Tracks locale changes natively.
524
524
  */
525
525
  export function useTranslation() {
526
- return {
527
- translate: t,
528
- getLocale,
529
- changeLocale: setLocale,
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
  }
@@ -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 ({ action }) => {
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', 'list')).toEqual({ can: true });
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 () => ({ can: true }),
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 any).can).toBe(true);
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 any).can).toBe(false);
39
- expect((result as any).reason).toBe('No permission');
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 ({ resource, action }) => {
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 ({ resource, action }) => {
70
- if (resource === 'posts' && action === 'delete') {
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 ({ params }) => {
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 any).can).toBe(false);
93
- expect((result as any).reason).toBe('Record locked');
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 ({ action }) => {
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
  });
@@ -1,62 +1,116 @@
1
- // UnsavedChangesNotifier — warns user before leaving page with unsaved changes
2
- // Svelte 5 component using runes
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
- // No lifecycle imports needed — uses module-level $state pattern
4
+ import { t } from './i18n.svelte';
5
5
 
6
- let warnWhenUnsavedChanges = $state(false);
6
+ let dirty = $state(false);
7
7
 
8
- /**
9
- * Call this to mark the form as dirty (has unsaved changes).
10
- * When set to true, navigating away or closing the tab will trigger a warning.
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 warnWhenUnsavedChanges;
14
+ return dirty;
18
15
  }
19
16
 
20
- const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
21
- if (warnWhenUnsavedChanges) {
22
- e.preventDefault();
23
- // Modern browsers ignore custom messages but require returnValue to be set
24
- e.returnValue = '';
25
- return '';
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
- * Call this in your root layout or AdminApp component.
32
- * It handles both browser tab close and in-app navigation.
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
- window.addEventListener('beforeunload', beforeUnloadHandler);
38
-
39
- // For hash-based navigation, intercept hashchange
40
- const hashChangeHandler = (e: HashChangeEvent) => {
41
- if (warnWhenUnsavedChanges) {
42
- const confirmed = confirm('You have unsaved changes. Are you sure you want to leave?');
43
- if (!confirmed) {
44
- e.preventDefault();
45
- // Restore original hash
46
- if (e.oldURL) {
47
- const oldHash = new URL(e.oldURL).hash;
48
- history.replaceState(null, '', oldHash || '#');
74
+ const message = t('common.unsavedChanges');
75
+
76
+ // 1. Browser tab close / refresh
77
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
78
+ if (!dirty) return;
79
+ e.preventDefault();
80
+ e.returnValue = '';
81
+ };
82
+ window.addEventListener('beforeunload', onBeforeUnload);
83
+
84
+ // 2. Framework router (SvelteKit, etc.)
85
+ if (options?.beforeNavigate) {
86
+ options.beforeNavigate(({ cancel }) => {
87
+ if (dirty) {
88
+ if (!confirm(message)) {
89
+ cancel();
90
+ } else {
91
+ dirty = false;
49
92
  }
50
- } else {
51
- warnWhenUnsavedChanges = false;
52
93
  }
94
+ });
95
+ }
96
+
97
+ // 3. Hash-based fallback for SPAs without a framework router
98
+ const onHashChange = (e: HashChangeEvent) => {
99
+ if (!dirty) return;
100
+ if (!confirm(message)) {
101
+ e.preventDefault();
102
+ if (e.oldURL) {
103
+ history.replaceState(null, '', new URL(e.oldURL).hash || '#');
104
+ }
105
+ } else {
106
+ dirty = false;
53
107
  }
54
108
  };
109
+ window.addEventListener('hashchange', onHashChange);
55
110
 
56
- window.addEventListener('hashchange', hashChangeHandler);
57
-
111
+ // Cleanup
58
112
  return () => {
59
- window.removeEventListener('beforeunload', beforeUnloadHandler);
60
- window.removeEventListener('hashchange', hashChangeHandler);
113
+ window.removeEventListener('beforeunload', onBeforeUnload);
114
+ window.removeEventListener('hashchange', onHashChange);
61
115
  };
62
116
  }