@svadmin/core 0.4.1 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Core SDK — hooks, types, context, i18n, permissions, router",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -51,7 +51,8 @@
51
51
  "author": "zuohuadong",
52
52
  "repository": {
53
53
  "type": "git",
54
- "url": "git+https://github.com/zuohuadong/svadmin.git"
54
+ "url": "https://github.com/zuohuadong/svadmin.git",
55
+ "directory": "packages/core"
55
56
  },
56
57
  "homepage": "https://github.com/zuohuadong/svadmin#readme",
57
58
  "bugs": {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Casbin adapter for svadmin AccessControlProvider.
3
+ *
4
+ * Converts a Casbin `Enforcer` instance into an `AccessControlProvider`.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { newEnforcer } from 'casbin';
9
+ * import { createCasbinAccessControl } from '@svadmin/core/adapters/casbin';
10
+ * import { setAccessControlProvider } from '@svadmin/core';
11
+ *
12
+ * const enforcer = await newEnforcer('model.conf', 'policy.csv');
13
+ * const currentUser = 'alice';
14
+ *
15
+ * setAccessControlProvider(
16
+ * createCasbinAccessControl(enforcer, { getUser: () => currentUser })
17
+ * );
18
+ * ```
19
+ */
20
+
21
+ import type { AccessControlProvider, CanParams, CanResult } from '../permissions';
22
+
23
+ /** Casbin Enforcer interface (minimal, to avoid hard dependency on casbin) */
24
+ interface CasbinEnforcer {
25
+ enforce: (...args: string[]) => Promise<boolean>;
26
+ }
27
+
28
+ export interface CasbinAdapterOptions {
29
+ /** Function to get the current user/subject for enforcement. */
30
+ getUser: () => string;
31
+ /** Provider-level options */
32
+ providerOptions?: AccessControlProvider['options'];
33
+ }
34
+
35
+ /**
36
+ * Create an AccessControlProvider from a Casbin Enforcer.
37
+ *
38
+ * Uses the standard (sub, obj, act) enforcement model:
39
+ * - sub = current user (from `getUser()`)
40
+ * - obj = resource name
41
+ * - act = action
42
+ *
43
+ * @param enforcer - A Casbin Enforcer instance
44
+ * @param options - Configuration with user getter
45
+ */
46
+ export function createCasbinAccessControl(
47
+ enforcer: CasbinEnforcer,
48
+ options: CasbinAdapterOptions,
49
+ ): AccessControlProvider {
50
+ return {
51
+ can: async ({ resource, action }: CanParams): Promise<CanResult> => {
52
+ const user = options.getUser();
53
+ const allowed = await enforcer.enforce(user, resource, action);
54
+ return {
55
+ can: allowed,
56
+ reason: allowed ? undefined : `User "${user}" cannot "${action}" on "${resource}"`,
57
+ };
58
+ },
59
+ options: options.providerOptions,
60
+ };
61
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * CASL adapter for svadmin AccessControlProvider.
3
+ *
4
+ * Converts a CASL `Ability` instance into an `AccessControlProvider`.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { AbilityBuilder, createMongoAbility } from '@casl/ability';
9
+ * import { createCaslAccessControl } from '@svadmin/core/adapters/casl';
10
+ * import { setAccessControlProvider } from '@svadmin/core';
11
+ *
12
+ * const { can, build } = new AbilityBuilder(createMongoAbility);
13
+ * can('read', 'Post');
14
+ * can('create', 'Post');
15
+ * can('manage', 'User');
16
+ * const ability = build();
17
+ *
18
+ * setAccessControlProvider(createCaslAccessControl(ability));
19
+ * ```
20
+ */
21
+
22
+ import type { AccessControlProvider, CanParams, CanResult } from '../permissions';
23
+
24
+ /** CASL Ability interface (minimal, to avoid hard dependency on @casl/ability) */
25
+ interface CaslAbility {
26
+ can: (action: string, subject: string, field?: string) => boolean;
27
+ cannot: (action: string, subject: string, field?: string) => boolean;
28
+ }
29
+
30
+ /**
31
+ * Create an AccessControlProvider from a CASL Ability.
32
+ *
33
+ * @param ability - A CASL Ability instance
34
+ * @param options - Optional configuration
35
+ */
36
+ export function createCaslAccessControl(
37
+ ability: CaslAbility,
38
+ options?: AccessControlProvider['options'],
39
+ ): AccessControlProvider {
40
+ return {
41
+ can: async ({ resource, action, params }: CanParams): Promise<CanResult> => {
42
+ const field = params?.field as string | undefined;
43
+ const allowed = ability.can(action, resource, field);
44
+ return {
45
+ can: allowed,
46
+ reason: allowed ? undefined : `Cannot "${action}" on "${resource}"${field ? ` (field: ${field})` : ''}`,
47
+ };
48
+ },
49
+ options,
50
+ };
51
+ }
@@ -147,7 +147,7 @@ export function useForm<
147
147
  onSuccess: (data: { data: TData }) => {
148
148
  if (invalidateScopes !== false) queryClient.invalidateQueries({ queryKey: [resource] });
149
149
  if (successNotification !== false) toast.success(successNotification || t('common.createSuccess'));
150
- audit({ action: 'create', resource, recordId: String(data.data.id) });
150
+ audit({ action: 'create', resource, recordId: String((data.data as any).id) });
151
151
  onMutationSuccess?.(data);
152
152
  if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
153
153
  },
package/src/index.ts CHANGED
@@ -28,7 +28,7 @@ export {
28
28
  } from './hooks.svelte';
29
29
  export { matchRoute, navigate, currentPath, setActiveRouterProvider } from './router';
30
30
  export { readURLState, writeURLState } from './url-sync';
31
- export { setAccessControl, canAccess, canAccessAsync } from './permissions';
31
+ export { setAccessControl, setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccess, canAccessAsync } from './permissions';
32
32
  export { useLive, useSubscription, usePublish } from './live';
33
33
  export { toast } from './toast.svelte';
34
34
  export { notify, closeNotification, setNotificationProvider, getNotificationProvider } from './notification.svelte';
@@ -64,9 +64,13 @@ export type {
64
64
  } from './types';
65
65
  export type { InvalidateScope } from './options';
66
66
  export type { LiveProvider, LiveEvent, LiveMode } from './live';
67
- export type { Action, AccessControlResult, AccessControlFn } from './permissions';
67
+ export type { Action, CanParams, CanResult, AccessControlProvider, AccessControlResult, AccessControlFn } from './permissions';
68
68
  export type { AuditEntry, AuditHandler } from './audit';
69
69
  export { useCan } from './useCan';
70
+ export type { UseCanOptions, UseCanResult } from './useCan';
71
+ export { createCaslAccessControl } from './adapters/casl';
72
+ export { createCasbinAccessControl } from './adapters/casbin';
73
+ export type { CasbinAdapterOptions } from './adapters/casbin';
70
74
  export { useExport, useImport } from './data-transfer';
71
75
  export type { UseExportOptions, UseImportOptions } from './data-transfer';
72
76
  export {
@@ -61,7 +61,7 @@ export function useCreate<TData extends BaseRecord = BaseRecord, TError = HttpEr
61
61
  queryClient.invalidateQueries({ queryKey: [resName] });
62
62
  }
63
63
  fireSuccessNotification(params.successNotification, 'Created successfully', data.data, params.variables, resName);
64
- audit({ action: 'create', resource: resName, recordId: String(data.data.id) });
64
+ audit({ action: 'create', resource: resName, recordId: String((data.data as any).id) });
65
65
  },
66
66
  onError: (error, params) => {
67
67
  fireErrorNotification(params.errorNotification, 'Create failed', error);
@@ -1,7 +1,7 @@
1
1
  // Unit tests for permissions module
2
2
  import { describe, test, expect, beforeEach } from 'bun:test';
3
- import { setAccessControl, canAccess, canAccessAsync } from './permissions';
4
- import type { Action } from './permissions';
3
+ import { setAccessControl, setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccess, canAccessAsync } from './permissions';
4
+ import type { Action, AccessControlProvider } from './permissions';
5
5
 
6
6
  describe('permissions', () => {
7
7
  beforeEach(() => {
@@ -53,3 +53,122 @@ describe('permissions', () => {
53
53
  expect(canAccess('posts', 'delete').can).toBe(true);
54
54
  });
55
55
  });
56
+
57
+ describe('AccessControlProvider', () => {
58
+ beforeEach(() => {
59
+ setAccessControlProvider({
60
+ can: async () => ({ can: true }),
61
+ });
62
+ });
63
+
64
+ test('setAccessControlProvider registers a provider', () => {
65
+ const provider = getAccessControlProvider();
66
+ expect(provider).not.toBeNull();
67
+ });
68
+
69
+ test('provider can() is called via canAccessAsync', async () => {
70
+ setAccessControlProvider({
71
+ can: async ({ resource, action }) => {
72
+ if (resource === 'posts' && action === 'delete') {
73
+ return { can: false, reason: 'Cannot delete posts' };
74
+ }
75
+ return { can: true };
76
+ },
77
+ });
78
+ const denied = await canAccessAsync('posts', 'delete');
79
+ expect(denied.can).toBe(false);
80
+ expect(denied.reason).toBe('Cannot delete posts');
81
+
82
+ const allowed = await canAccessAsync('posts', 'list');
83
+ expect(allowed.can).toBe(true);
84
+ });
85
+
86
+ test('params.id is passed through', async () => {
87
+ setAccessControlProvider({
88
+ can: async ({ params }) => {
89
+ if (params?.id === 42) return { can: false, reason: 'Record locked' };
90
+ return { can: true };
91
+ },
92
+ });
93
+ const result = await canAccessAsync('posts', 'edit', { id: 42 });
94
+ expect(result.can).toBe(false);
95
+ expect(result.reason).toBe('Record locked');
96
+ });
97
+
98
+ test('extended action types work', async () => {
99
+ setAccessControlProvider({
100
+ can: async ({ action }) => {
101
+ if (action === 'show') return { can: false, reason: 'No show' };
102
+ if (action === 'field') return { can: false, reason: 'No field' };
103
+ return { can: true };
104
+ },
105
+ });
106
+ expect((await canAccessAsync('posts', 'show')).can).toBe(false);
107
+ expect((await canAccessAsync('posts', 'field')).can).toBe(false);
108
+ expect((await canAccessAsync('posts', 'list')).can).toBe(true);
109
+ });
110
+
111
+ test('getAccessControlOptions returns provider options', () => {
112
+ setAccessControlProvider({
113
+ can: async () => ({ can: true }),
114
+ options: {
115
+ buttons: { enableAccessControl: true, hideIfUnauthorized: true },
116
+ },
117
+ });
118
+ const options = getAccessControlOptions();
119
+ expect(options.buttons?.enableAccessControl).toBe(true);
120
+ expect(options.buttons?.hideIfUnauthorized).toBe(true);
121
+ });
122
+
123
+ test('getAccessControlOptions returns {} when no options', () => {
124
+ setAccessControlProvider({
125
+ can: async () => ({ can: true }),
126
+ });
127
+ expect(getAccessControlOptions()).toEqual({});
128
+ });
129
+ });
130
+
131
+ describe('CASL adapter', () => {
132
+ test('createCaslAccessControl works', async () => {
133
+ // Import dynamically to test the adapter
134
+ const { createCaslAccessControl } = await import('./adapters/casl');
135
+
136
+ // Mock CASL Ability
137
+ const ability = {
138
+ can: (action: string, subject: string) => !(action === 'delete' && subject === 'users'),
139
+ cannot: (action: string, subject: string) => action === 'delete' && subject === 'users',
140
+ };
141
+
142
+ const provider = createCaslAccessControl(ability);
143
+
144
+ const allowed = await provider.can({ resource: 'posts', action: 'edit' });
145
+ expect(allowed.can).toBe(true);
146
+
147
+ const denied = await provider.can({ resource: 'users', action: 'delete' });
148
+ expect(denied.can).toBe(false);
149
+ expect(denied.reason).toContain('Cannot "delete" on "users"');
150
+ });
151
+ });
152
+
153
+ describe('Casbin adapter', () => {
154
+ test('createCasbinAccessControl works', async () => {
155
+ const { createCasbinAccessControl } = await import('./adapters/casbin');
156
+
157
+ // Mock Casbin Enforcer
158
+ const enforcer = {
159
+ enforce: async (sub: string, obj: string, act: string) =>
160
+ !(sub === 'alice' && obj === 'users' && act === 'delete'),
161
+ };
162
+
163
+ const provider = createCasbinAccessControl(enforcer, {
164
+ getUser: () => 'alice',
165
+ });
166
+
167
+ const allowed = await provider.can({ resource: 'posts', action: 'edit' });
168
+ expect(allowed.can).toBe(true);
169
+
170
+ const denied = await provider.can({ resource: 'users', action: 'delete' });
171
+ expect(denied.can).toBe(false);
172
+ expect(denied.reason).toContain('alice');
173
+ });
174
+ });
@@ -1,42 +1,129 @@
1
1
  // Permission / Access Control
2
2
 
3
+ // ─── Types ────────────────────────────────────────────────────
3
4
 
4
- export type Action = 'list' | 'create' | 'edit' | 'delete' | 'export';
5
+ /** Actions that can be checked for access control. Extensible via string literal union. */
6
+ export type Action = 'list' | 'show' | 'create' | 'edit' | 'delete' | 'export' | 'field' | (string & {});
5
7
 
6
- export interface AccessControlResult {
8
+ export interface CanParams {
9
+ resource: string;
10
+ action: Action;
11
+ params?: { id?: string | number; [key: string]: unknown };
12
+ }
13
+
14
+ export interface CanResult {
7
15
  can: boolean;
8
16
  reason?: string;
9
17
  }
10
18
 
19
+ /** @deprecated Use `CanResult` instead */
20
+ export type AccessControlResult = CanResult;
21
+
22
+ /**
23
+ * Formal Access Control Provider interface.
24
+ * Implement this to define your authorization logic.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * const accessControlProvider: AccessControlProvider = {
29
+ * can: async ({ resource, action, params }) => {
30
+ * if (resource === 'users' && action === 'delete') {
31
+ * return { can: false, reason: 'Cannot delete users' };
32
+ * }
33
+ * return { can: true };
34
+ * },
35
+ * options: {
36
+ * buttons: { enableAccessControl: true, hideIfUnauthorized: false },
37
+ * },
38
+ * };
39
+ * ```
40
+ */
41
+ export interface AccessControlProvider {
42
+ can: (params: CanParams) => Promise<CanResult>;
43
+ options?: {
44
+ buttons?: {
45
+ /** Enable access control checks on CRUD buttons globally. Default: false */
46
+ enableAccessControl?: boolean;
47
+ /** Hide buttons when unauthorized instead of disabling. Default: false */
48
+ hideIfUnauthorized?: boolean;
49
+ };
50
+ };
51
+ }
52
+
53
+ /** @deprecated Use `AccessControlProvider['can']` instead */
11
54
  export type AccessControlFn = (
12
55
  resource: string,
13
56
  action: Action,
14
57
  params?: Record<string, unknown>,
15
- ) => AccessControlResult | Promise<AccessControlResult>;
58
+ ) => CanResult | Promise<CanResult>;
59
+
60
+ // ─── State ────────────────────────────────────────────────────
16
61
 
17
- let accessControlFn: AccessControlFn | null = null;
62
+ let provider: AccessControlProvider | null = null;
63
+ let legacyFn: AccessControlFn | null = null;
18
64
 
65
+ // ─── API ──────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Register an AccessControlProvider.
69
+ */
70
+ export function setAccessControlProvider(p: AccessControlProvider): void {
71
+ provider = p;
72
+ legacyFn = null;
73
+ }
74
+
75
+ /**
76
+ * @deprecated Use `setAccessControlProvider()` with full `AccessControlProvider` interface.
77
+ * Legacy API preserved for backward compatibility.
78
+ */
19
79
  export function setAccessControl(fn: AccessControlFn): void {
20
- accessControlFn = fn;
80
+ legacyFn = fn;
81
+ provider = {
82
+ can: async ({ resource, action, params }) => {
83
+ const result = fn(resource, action, params);
84
+ return result instanceof Promise ? result : Promise.resolve(result);
85
+ },
86
+ };
21
87
  }
22
88
 
23
- export function canAccess(resource: string, action: Action, params?: Record<string, unknown>): AccessControlResult {
24
- if (!accessControlFn) return { can: true };
25
- const result = accessControlFn(resource, action, params);
26
- if (result instanceof Promise) {
27
- console.warn('[permissions] canAccess called synchronously but accessControlFn returned a Promise. Defaulting to { can: true }. Use canAccessAsync() instead.');
28
- return { can: true };
29
- }
30
- return result;
89
+ /** Get the current AccessControlProvider (or null) */
90
+ export function getAccessControlProvider(): AccessControlProvider | null {
91
+ return provider;
31
92
  }
32
93
 
33
- export async function canAccessAsync(resource: string, action: Action, params?: Record<string, unknown>): Promise<AccessControlResult> {
34
- if (!accessControlFn) return { can: true };
35
- return accessControlFn(resource, action, params);
94
+ /** Get global button options from the AccessControlProvider */
95
+ export function getAccessControlOptions() {
96
+ return provider?.options ?? {};
36
97
  }
37
98
 
38
- // Example usage:
39
- // setAccessControl((resource, action) => {
40
- // if (resource === 'users' && action === 'delete') return { can: false, reason: '无权删除用户' };
41
- // return { can: true };
42
- // });
99
+ /**
100
+ * Synchronous access check. Returns `{ can: true }` if no provider/fn is set.
101
+ * When a legacy sync fn is registered via `setAccessControl()`, this calls it synchronously.
102
+ * When only a provider is registered, this warns if provider.can() returns a Promise.
103
+ */
104
+ export function canAccess(resource: string, action: Action, params?: Record<string, unknown>): CanResult {
105
+ // Fast path: use legacy sync fn if available
106
+ if (legacyFn) {
107
+ const result = legacyFn(resource, action, params);
108
+ if (result instanceof Promise) {
109
+ console.warn('[permissions] canAccess called synchronously but accessControlFn returned a Promise. Defaulting to { can: true }. Use canAccessAsync() instead.');
110
+ return { can: true };
111
+ }
112
+ return result;
113
+ }
114
+ if (!provider) return { can: true };
115
+ // Provider path (always async) — warn
116
+ console.warn('[permissions] canAccess called synchronously but provider.can() returned a Promise. Defaulting to { can: true }. Use canAccessAsync() instead.');
117
+ return { can: true };
118
+ }
119
+
120
+ /**
121
+ * Async access check. Preferred over `canAccess()`.
122
+ */
123
+ export async function canAccessAsync(resource: string, action: Action, params?: Record<string, unknown>): Promise<CanResult> {
124
+ if (legacyFn) {
125
+ return legacyFn(resource, action, params);
126
+ }
127
+ if (!provider) return { can: true };
128
+ return provider.can({ resource, action, params });
129
+ }
package/src/types.ts CHANGED
@@ -25,7 +25,7 @@ export class UndoError extends Error {
25
25
 
26
26
  // ─── Base Types ───────────────────────────────────────────────
27
27
 
28
- export type BaseRecord = Record<string, unknown>;
28
+ export type BaseRecord = any;
29
29
 
30
30
  // ─── DataProvider ─────────────────────────────────────────────
31
31
 
package/src/useCan.ts CHANGED
@@ -1,37 +1,54 @@
1
- // useCan — reactive permission check hook
1
+ // useCan — reactive permission check hook with TanStack Query integration
2
2
 
3
+ import { createQuery } from '@tanstack/svelte-query';
3
4
  import { canAccessAsync } from './permissions';
4
- import type { Action, AccessControlResult } from './permissions';
5
+ import type { Action, CanResult } from './permissions';
5
6
 
6
- interface UseCanResult {
7
- allowed: boolean;
8
- reason?: string;
9
- isLoading: boolean;
7
+ export interface UseCanOptions {
8
+ resource: string;
9
+ action: Action;
10
+ params?: Record<string, unknown>;
11
+ queryOptions?: {
12
+ enabled?: boolean;
13
+ staleTime?: number;
14
+ };
15
+ }
16
+
17
+ export interface UseCanResult {
18
+ readonly allowed: boolean;
19
+ readonly reason: string | undefined;
20
+ readonly isLoading: boolean;
10
21
  }
11
22
 
12
23
  /**
13
- * Reactive permission check. Calls canAccessAsync() and returns reactive state.
14
- * Usage: const can = useCan('posts', 'delete');
15
- * if (can.allowed) { ... }
24
+ * Reactive permission check backed by TanStack Query for caching and deduplication.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * // Simple usage (backward compatible)
29
+ * const can = useCan('posts', 'delete');
30
+ * if (can.allowed) { ... }
31
+ *
32
+ * // With options object
33
+ * const can = useCan({ resource: 'posts', action: 'delete', params: { id: 1 } });
34
+ * ```
16
35
  */
17
- export function useCan(resource: string, action: Action, params?: Record<string, unknown>): UseCanResult {
18
- let allowed = $state(true);
19
- let reason = $state<string | undefined>(undefined);
20
- let isLoading = $state(true);
36
+ export function useCan(resourceOrOptions: string | UseCanOptions, action?: Action, params?: Record<string, unknown>): UseCanResult {
37
+ // Normalize arguments: support both (resource, action, params) and (options) signatures
38
+ const options: UseCanOptions = typeof resourceOrOptions === 'string'
39
+ ? { resource: resourceOrOptions, action: action ?? 'list', params }
40
+ : resourceOrOptions;
21
41
 
22
- // Run the async check
23
- canAccessAsync(resource, action, params).then((result: AccessControlResult) => {
24
- allowed = result.can;
25
- reason = result.reason;
26
- isLoading = false;
27
- }).catch(() => {
28
- allowed = true; // default to allowed on error
29
- isLoading = false;
30
- });
42
+ const query = createQuery<CanResult>(() => ({
43
+ queryKey: ['useCan', options.resource, options.action, options.params],
44
+ queryFn: () => canAccessAsync(options.resource, options.action, options.params),
45
+ enabled: options.queryOptions?.enabled ?? true,
46
+ staleTime: options.queryOptions?.staleTime ?? 5 * 60 * 1000, // 5 min default cache
47
+ }));
31
48
 
32
49
  return {
33
- get allowed() { return allowed; },
34
- get reason() { return reason; },
35
- get isLoading() { return isLoading; },
50
+ get allowed() { return query.data?.can ?? true; },
51
+ get reason() { return query.data?.reason; },
52
+ get isLoading() { return query.isLoading; },
36
53
  };
37
54
  }