@svadmin/core 0.3.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.3.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": "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": {
@@ -59,5 +60,8 @@
59
60
  },
60
61
  "devDependencies": {
61
62
  "@types/bun": "^1.3.11"
63
+ },
64
+ "dependencies": {
65
+ "svelte-sonner": "^1.1.0"
62
66
  }
63
67
  }
@@ -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
+ }
@@ -1,9 +1,8 @@
1
1
  // Auth Hooks — reactive wrappers around AuthProvider methods
2
2
  // Each hook encapsulates the auth call + loading state + error handling + redirect
3
- // IMPORTANT: getAuthProvider() must be called at hook creation time (during component init),
4
- // not inside mutate(), because Svelte's getContext() only works during init.
3
+ // Uses module-level $state — no component init-time constraints.
5
4
 
6
- import { getAuthProvider } from './context';
5
+ import { getAuthProvider } from './context.svelte';
7
6
  import { navigate } from './router';
8
7
  import { toast } from './toast.svelte';
9
8
  import { t } from './i18n.svelte';
@@ -1,14 +1,9 @@
1
- // Svelte context — provides DataProvider, AuthProvider, and Resources to all components
1
+ // Svelte 5 Context — module-level $state singletons (no setContext/getContext required)
2
+ // This removes the constraint that hooks must be called at component init time.
2
3
 
3
- import { getContext, setContext } from 'svelte';
4
4
  import type { DataProvider, AuthProvider, ResourceDefinition } from './types';
5
5
  import type { RouterProvider } from './router-provider';
6
6
 
7
- const DATA_PROVIDER_KEY = Symbol('data-provider');
8
- const AUTH_PROVIDER_KEY = Symbol('auth-provider');
9
- const RESOURCES_KEY = Symbol('resources');
10
- const ROUTER_PROVIDER_KEY = Symbol('router-provider');
11
-
12
7
  // ─── DataProvider (supports single or multiple) ─────────────────
13
8
 
14
9
  /**
@@ -18,16 +13,17 @@ const ROUTER_PROVIDER_KEY = Symbol('router-provider');
18
13
  */
19
14
  export type DataProviderInput = DataProvider | Record<string, DataProvider>;
20
15
 
16
+ let providers = $state<Record<string, DataProvider> | null>(null);
17
+
21
18
  export function setDataProvider(provider: DataProviderInput): void {
22
19
  if (isDataProvider(provider)) {
23
- setContext(DATA_PROVIDER_KEY, { default: provider } as Record<string, DataProvider>);
20
+ providers = { default: provider } as Record<string, DataProvider>;
24
21
  } else {
25
- setContext(DATA_PROVIDER_KEY, provider as Record<string, DataProvider>);
22
+ providers = provider as Record<string, DataProvider>;
26
23
  }
27
24
  }
28
25
 
29
26
  export function getDataProvider(name?: string): DataProvider {
30
- const providers = getContext<Record<string, DataProvider>>(DATA_PROVIDER_KEY);
31
27
  if (!providers) throw new Error('DataProvider not found. Did you call setDataProvider in App.svelte?');
32
28
  const key = name ?? 'default';
33
29
  const provider = providers[key];
@@ -38,7 +34,6 @@ export function getDataProvider(name?: string): DataProvider {
38
34
  }
39
35
 
40
36
  export function getDataProviderNames(): string[] {
41
- const providers = getContext<Record<string, DataProvider>>(DATA_PROVIDER_KEY);
42
37
  return providers ? Object.keys(providers) : [];
43
38
  }
44
39
 
@@ -71,41 +66,58 @@ function isDataProvider(value: unknown): value is DataProvider {
71
66
 
72
67
  // ─── Auth Provider ──────────────────────────────────────────────
73
68
 
69
+ let authProvider = $state<AuthProvider | null>(null);
70
+
74
71
  export function setAuthProvider(provider: AuthProvider): void {
75
- setContext(AUTH_PROVIDER_KEY, provider);
72
+ authProvider = provider;
76
73
  }
77
74
 
78
75
  export function getAuthProvider(): AuthProvider {
79
- const provider = getContext<AuthProvider>(AUTH_PROVIDER_KEY);
80
- if (!provider) throw new Error('AuthProvider not found. Did you call setAuthProvider in App.svelte?');
81
- return provider;
76
+ if (!authProvider) throw new Error('AuthProvider not found. Did you call setAuthProvider in App.svelte?');
77
+ return authProvider;
82
78
  }
83
79
 
84
80
  // ─── Resources ──────────────────────────────────────────────────
85
81
 
86
- export function setResources(resources: ResourceDefinition[]): void {
87
- setContext(RESOURCES_KEY, resources);
82
+ let resources = $state.raw<ResourceDefinition[]>([]);
83
+
84
+ export function setResources(newResources: ResourceDefinition[]): void {
85
+ resources = newResources;
88
86
  }
89
87
 
90
88
  export function getResources(): ResourceDefinition[] {
91
- const resources = getContext<ResourceDefinition[]>(RESOURCES_KEY);
92
- if (!resources) throw new Error('Resources not found. Did you call setResources in App.svelte?');
89
+ if (!resources || resources.length === 0) throw new Error('Resources not found. Did you call setResources in App.svelte?');
93
90
  return resources;
94
91
  }
95
92
 
96
93
  export function getResource(nameOrIdentifier: string): ResourceDefinition {
97
- const resources = getResources();
98
- const resource = resources.find(r => r.identifier === nameOrIdentifier || r.name === nameOrIdentifier);
94
+ const rList = getResources();
95
+ const resource = rList.find(r => r.identifier === nameOrIdentifier || r.name === nameOrIdentifier);
99
96
  if (!resource) throw new Error(`Resource "${nameOrIdentifier}" not found in resource definitions.`);
100
97
  return resource;
101
98
  }
102
99
 
103
100
  // ─── Router Provider ────────────────────────────────────────────
104
101
 
102
+ let routerProvider = $state<RouterProvider | undefined>(undefined);
103
+
105
104
  export function setRouterProvider(provider: RouterProvider): void {
106
- setContext(ROUTER_PROVIDER_KEY, provider);
105
+ routerProvider = provider;
107
106
  }
108
107
 
109
108
  export function getRouterProvider(): RouterProvider | undefined {
110
- return getContext<RouterProvider>(ROUTER_PROVIDER_KEY);
109
+ return routerProvider;
110
+ }
111
+
112
+
113
+ // ─── Live Provider ──────────────────────────────────────────────
114
+
115
+ let liveProviderState: import('./live').LiveProvider | undefined = undefined;
116
+
117
+ export function setLiveProvider(provider: import('./live').LiveProvider): void {
118
+ liveProviderState = provider;
119
+ }
120
+
121
+ export function getLiveProvider(): import('./live').LiveProvider | undefined {
122
+ return liveProviderState;
111
123
  }
@@ -1,4 +1,4 @@
1
- import { getDataProviderForResource } from './context';
1
+ import { getDataProviderForResource } from './context.svelte';
2
2
  import { useParsed } from './useParsed';
3
3
  import type { GetListResult, Sort, Filter, BaseRecord } from './types';
4
4
 
@@ -1,7 +1,7 @@
1
1
  import { useQueryClient } from '@tanstack/svelte-query';
2
2
  import { useParsed } from './useParsed';
3
3
  import { getAdminOptions } from './options';
4
- import { getDataProviderForResource } from './context';
4
+ import { getDataProviderForResource } from './context.svelte';
5
5
  import { createQuery, createMutation } from '@tanstack/svelte-query';
6
6
  import { toast } from './toast.svelte';
7
7
  import { t } from './i18n.svelte';
@@ -103,7 +103,7 @@ export function useForm<
103
103
  let errors = $state<Record<string, string>>({});
104
104
  function setFieldError(field: string, message: string) { errors = { ...errors, [field]: message }; }
105
105
  function clearErrors() { errors = {}; }
106
- function clearFieldError(field: string) { const next = { ...errors }; delete next[field]; errors = next; }
106
+ function clearFieldError(field: string) { delete errors[field]; }
107
107
 
108
108
  function runValidation(values: TVariables): boolean {
109
109
  clearErrors();
@@ -178,14 +178,14 @@ export function useForm<
178
178
  async function onFinish(values: TVariables, finishOptions?: { redirect?: 'list' | 'edit' | 'show' | false }) {
179
179
  if (!runValidation(values)) { toast.warning(t('validation.required')); return; }
180
180
  redirectOverride = finishOptions?.redirect;
181
- if (action === 'create' || action === 'clone') await (createMut as any).mutateAsync(values);
182
- else await (updateMut as any).mutateAsync(values);
181
+ if (action === 'create' || action === 'clone') await createMut.mutateAsync(values);
182
+ else await updateMut.mutateAsync(values);
183
183
  }
184
184
 
185
185
  async function onFinishAutoSave(values: TVariables) {
186
186
  if (action === 'create' || action === 'clone') return; // Only autosave on edit
187
187
  redirectOverride = false; // Never redirect on autoSave
188
- await (updateMut as any).mutateAsync(values);
188
+ await updateMut.mutateAsync(values);
189
189
  }
190
190
 
191
191
  let autoSaveStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
@@ -244,7 +244,7 @@ export function useForm<
244
244
 
245
245
  return {
246
246
  query,
247
- get formLoading() { return ((query as any)?.isLoading ?? false) || (createMut as any).isPending || (updateMut as any).isPending; },
247
+ get formLoading() { return (query?.isLoading ?? false) || createMut.isPending || updateMut.isPending; },
248
248
  mutation: action === 'edit' ? updateMut : createMut,
249
249
  onFinish,
250
250
  onFinishAutoSave,
@@ -23,7 +23,7 @@ export type { OvertimeResult, OvertimeOptions, NotificationConfig } from './hook
23
23
 
24
24
  import { createQuery, createInfiniteQuery, createMutation, useQueryClient } from '@tanstack/svelte-query';
25
25
  import { getAdminOptions } from './options';
26
- import { getDataProviderForResource, getDataProvider } from './context';
26
+ import { getDataProviderForResource, getDataProvider } from './context.svelte';
27
27
  import { useParsed } from './useParsed';
28
28
  import { createOvertimeTracker, fireSuccessNotification, fireErrorNotification } from './hook-utils.svelte';
29
29
  import type { NotificationConfig, OvertimeOptions } from './hook-utils.svelte';
@@ -203,7 +203,7 @@ export function useCustom<TData = unknown, TError = HttpError>(options: UseCusto
203
203
  return provider.custom<TData>({
204
204
  url: options.url,
205
205
  method: options.method,
206
- payload: options.config?.payload as any,
206
+ payload: options.config?.payload,
207
207
  query: options.config?.query,
208
208
  headers: options.config?.headers,
209
209
  sorters: options.config?.sorters,
@@ -229,7 +229,7 @@ export function useCustomMutation<TData = unknown, TError = HttpError, TVariable
229
229
  const mutation = createMutation<{ data: TData }, TError, { url: string; method: 'get' | 'post' | 'put' | 'patch' | 'delete'; values?: TVariables; meta?: Record<string, unknown> }>(() => ({
230
230
  mutationFn: async (params) => {
231
231
  if (!provider.custom) throw new Error('DataProvider does not support custom method');
232
- return provider.custom<TData>({ url: params.url, method: params.method, payload: params.values as any, meta: params.meta });
232
+ return provider.custom<TData>({ url: params.url, method: params.method, payload: params.values, meta: params.meta });
233
233
  },
234
234
  }));
235
235
 
@@ -38,6 +38,9 @@ const locales: Record<string, Locale> = {
38
38
  'common.home': '首页',
39
39
  'common.logout': '退出登录',
40
40
  'common.toggleTheme': '切换主题',
41
+ 'common.switchLanguage': '切换语言',
42
+ 'common.clear': '清除',
43
+ 'common.menu': '菜单',
41
44
  'common.darkMode': '暗色模式',
42
45
  'common.lightMode': '亮色模式',
43
46
  'common.detail': '详情',
@@ -47,19 +50,39 @@ const locales: Record<string, Locale> = {
47
50
  'common.retry': '重试',
48
51
  'common.yes': '是',
49
52
  'common.no': '否',
53
+ 'common.undo': '撤销',
54
+ 'common.filter': '筛选',
55
+ 'common.reset': '重置',
50
56
  'common.welcome': '欢迎使用 {title}',
51
57
  'common.dashboardHint': '从侧边栏选择一个资源开始。',
52
58
  'common.redirecting': '正在跳转到登录页...',
59
+ 'common.expand': '展开',
60
+ 'common.collapse': '收起',
61
+ 'common.close': '关闭',
62
+ 'common.copy': '复制',
63
+ 'common.copyAll': '复制全部',
64
+ 'common.copied': '已复制',
65
+ 'common.next': '下一步',
66
+ 'common.back': '返回',
67
+ 'common.step': '步骤',
68
+ 'common.columns': '列',
69
+ 'common.perPage': '每页',
70
+ 'common.copyId': '复制 ID',
71
+ 'common.showMore': '展开更多',
72
+ 'common.showLess': '收起',
53
73
  // Field
54
74
  'field.enterValue': '输入{label}',
55
75
  'field.selectPlaceholder': '请选择',
56
76
  'field.tagsPlaceholder': '输入标签后按回车...',
77
+ 'field.noOptions': '无可用选项',
78
+ 'field.addImage': '添加图片',
57
79
  // Config errors
58
80
  'config.missingEnvTitle': '环境配置缺失',
59
81
  'config.missingEnvDescription': '应用无法启动,因为缺少必要的环境变量。',
60
82
  'config.addToEnvFile': '请在 .env 文件中添加以下内容:',
61
83
  'config.envFilePath': '文件路径: .env',
62
84
  'config.reload': '配置完成后刷新页面',
85
+ 'config.reloadButton': '重新加载页面',
63
86
  // Auth pages
64
87
  'auth.login': '登录',
65
88
  'auth.register': '注册',
@@ -86,6 +109,17 @@ const locales: Record<string, Locale> = {
86
109
  'auth.welcomeMessage': '登录以继续使用管理后台',
87
110
  'auth.createAccount': '创建账号',
88
111
  'auth.createAccountMessage': '填写信息以创建您的账号',
112
+ // AutoSave
113
+ 'autoSave.idle': '已保存',
114
+ 'autoSave.saving': '保存中...',
115
+ 'autoSave.saved': '已保存',
116
+ 'autoSave.error': '保存失败',
117
+ // Empty states
118
+ 'empty.title': '暂无数据',
119
+ 'empty.description': '没有找到符合条件的记录。',
120
+ 'empty.noMore': '没有更多了',
121
+ // DevTools
122
+ 'devtools.title': '开发者工具 (Ctrl+Shift+D)',
89
123
  // Validation
90
124
  'validation.required': '此字段为必填项',
91
125
  'validation.minLength': '最少 {min} 个字符',
@@ -128,6 +162,9 @@ const locales: Record<string, Locale> = {
128
162
  'common.home': 'Home',
129
163
  'common.logout': 'Log out',
130
164
  'common.toggleTheme': 'Toggle theme',
165
+ 'common.switchLanguage': 'Switch language',
166
+ 'common.clear': 'Clear',
167
+ 'common.menu': 'Menu',
131
168
  'common.darkMode': 'Dark mode',
132
169
  'common.lightMode': 'Light mode',
133
170
  'common.detail': 'Detail',
@@ -137,19 +174,39 @@ const locales: Record<string, Locale> = {
137
174
  'common.retry': 'Retry',
138
175
  'common.yes': 'Yes',
139
176
  'common.no': 'No',
177
+ 'common.undo': 'Undo',
178
+ 'common.filter': 'Filter',
179
+ 'common.reset': 'Reset',
140
180
  'common.welcome': 'Welcome to {title}',
141
181
  'common.dashboardHint': 'Select a resource from the sidebar to get started.',
142
182
  'common.redirecting': 'Redirecting to login...',
183
+ 'common.expand': 'Expand',
184
+ 'common.collapse': 'Collapse',
185
+ 'common.close': 'Close',
186
+ 'common.copy': 'Copy',
187
+ 'common.copyAll': 'Copy All',
188
+ 'common.copied': 'Copied!',
189
+ 'common.next': 'Next',
190
+ 'common.back': 'Back',
191
+ 'common.step': 'Step',
192
+ 'common.columns': 'Columns',
193
+ 'common.perPage': 'per page',
194
+ 'common.copyId': 'Copy ID',
195
+ 'common.showMore': 'Show more',
196
+ 'common.showLess': 'Show less',
143
197
  // Field
144
198
  'field.enterValue': 'Enter {label}',
145
199
  'field.selectPlaceholder': 'Select...',
146
200
  'field.tagsPlaceholder': 'Type a tag and press Enter...',
201
+ 'field.noOptions': 'No options available',
202
+ 'field.addImage': 'Add image',
147
203
  // Config errors
148
204
  'config.missingEnvTitle': 'Missing Configuration',
149
205
  'config.missingEnvDescription': 'The app cannot start because required environment variables are missing.',
150
206
  'config.addToEnvFile': 'Add the following to your .env file:',
151
207
  'config.envFilePath': 'File path: .env',
152
- 'config.reload': 'Reload after configuration',
208
+ 'config.reload': 'Refresh page after configuration',
209
+ 'config.reloadButton': 'Reload Page',
153
210
  // Auth pages
154
211
  'auth.login': 'Login',
155
212
  'auth.register': 'Register',
@@ -176,6 +233,17 @@ const locales: Record<string, Locale> = {
176
233
  'auth.welcomeMessage': 'Sign in to continue to admin panel',
177
234
  'auth.createAccount': 'Create Account',
178
235
  'auth.createAccountMessage': 'Fill in your details to create an account',
236
+ // AutoSave
237
+ 'autoSave.idle': 'Saved',
238
+ 'autoSave.saving': 'Saving...',
239
+ 'autoSave.saved': 'Saved',
240
+ 'autoSave.error': 'Save failed',
241
+ // Empty states
242
+ 'empty.title': 'No data',
243
+ 'empty.description': 'No records found matching your criteria.',
244
+ 'empty.noMore': 'No more items',
245
+ // DevTools
246
+ 'devtools.title': 'DevTools (Ctrl+Shift+D)',
179
247
  // Validation
180
248
  'validation.required': 'This field is required',
181
249
  'validation.minLength': 'Minimum {min} characters',
package/src/index.ts CHANGED
@@ -5,8 +5,8 @@ export {
5
5
  setAuthProvider, getAuthProvider,
6
6
  setResources, getResources, getResource,
7
7
  setRouterProvider, getRouterProvider,
8
- } from './context';
9
- export type { DataProviderInput } from './context';
8
+ } from './context.svelte';
9
+ export type { DataProviderInput } from './context.svelte';
10
10
  export {
11
11
  useList, useInfiniteList,
12
12
  useOne, useShow,
@@ -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 {
@@ -92,3 +96,5 @@ export {
92
96
  unionFilters, unionSorters,
93
97
  file2Base64, generateDefaultDocumentTitle,
94
98
  } from './helpers';
99
+ export { TableState } from './table-state.svelte';
100
+ export type { TableStateOptions } from './table-state.svelte';
@@ -1,6 +1,6 @@
1
1
  import { createMutation, useQueryClient } from '@tanstack/svelte-query';
2
2
  import { getAdminOptions } from './options';
3
- import { getDataProviderForResource } from './context';
3
+ import { getDataProviderForResource } from './context.svelte';
4
4
  import { useParsed } from './useParsed';
5
5
  import { audit } from './audit';
6
6
  import { UndoError } from './types';
@@ -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
+ });