@svadmin/core 0.4.1 → 0.5.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 +3 -2
- package/src/adapters/casbin.ts +61 -0
- package/src/adapters/casl.ts +51 -0
- package/src/index.ts +6 -2
- package/src/permissions.test.ts +121 -2
- package/src/permissions.ts +108 -21
- package/src/useCan.ts +42 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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": "
|
|
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
|
+
}
|
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 {
|
package/src/permissions.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/permissions.ts
CHANGED
|
@@ -1,42 +1,129 @@
|
|
|
1
1
|
// Permission / Access Control
|
|
2
2
|
|
|
3
|
+
// ─── Types ────────────────────────────────────────────────────
|
|
3
4
|
|
|
4
|
-
|
|
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
|
|
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
|
-
) =>
|
|
58
|
+
) => CanResult | Promise<CanResult>;
|
|
59
|
+
|
|
60
|
+
// ─── State ────────────────────────────────────────────────────
|
|
16
61
|
|
|
17
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
94
|
+
/** Get global button options from the AccessControlProvider */
|
|
95
|
+
export function getAccessControlOptions() {
|
|
96
|
+
return provider?.options ?? {};
|
|
36
97
|
}
|
|
37
98
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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/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,
|
|
5
|
+
import type { Action, CanResult } from './permissions';
|
|
5
6
|
|
|
6
|
-
interface
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
})
|
|
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
|
|
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
|
}
|