@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 +6 -2
- package/src/adapters/casbin.ts +61 -0
- package/src/adapters/casl.ts +51 -0
- package/src/auth-hooks.svelte.ts +2 -3
- package/src/{context.ts → context.svelte.ts} +35 -23
- package/src/data-transfer.ts +1 -1
- package/src/form-hooks.svelte.ts +6 -6
- package/src/hooks.svelte.ts +3 -3
- package/src/i18n.svelte.ts +69 -1
- package/src/index.ts +10 -4
- package/src/mutation-hooks.svelte.ts +1 -1
- package/src/permissions.test.ts +121 -2
- package/src/permissions.ts +108 -21
- package/src/query-hooks.svelte.ts +2 -2
- package/src/routing-hooks.svelte.ts +1 -1
- package/src/table-hooks.svelte.ts +4 -4
- package/src/table-state.svelte.ts +243 -0
- package/src/toast.svelte.ts +22 -6
- package/src/types.ts +5 -0
- package/src/unsaved-changes.svelte.ts +1 -1
- package/src/useCan.ts +42 -25
- package/src/useParsed.ts +1 -1
- package/src/useStepsForm.svelte.ts +1 -1
- package/src/utility-hooks.svelte.ts +1 -1
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": {
|
|
@@ -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
|
+
}
|
package/src/auth-hooks.svelte.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
-
|
|
20
|
+
providers = { default: provider } as Record<string, DataProvider>;
|
|
24
21
|
} else {
|
|
25
|
-
|
|
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
|
-
|
|
72
|
+
authProvider = provider;
|
|
76
73
|
}
|
|
77
74
|
|
|
78
75
|
export function getAuthProvider(): AuthProvider {
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
98
|
-
const resource =
|
|
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
|
-
|
|
105
|
+
routerProvider = provider;
|
|
107
106
|
}
|
|
108
107
|
|
|
109
108
|
export function getRouterProvider(): RouterProvider | undefined {
|
|
110
|
-
return
|
|
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
|
}
|
package/src/data-transfer.ts
CHANGED
package/src/form-hooks.svelte.ts
CHANGED
|
@@ -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) {
|
|
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
|
|
182
|
-
else await
|
|
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
|
|
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 (
|
|
247
|
+
get formLoading() { return (query?.isLoading ?? false) || createMut.isPending || updateMut.isPending; },
|
|
248
248
|
mutation: action === 'edit' ? updateMut : createMut,
|
|
249
249
|
onFinish,
|
|
250
250
|
onFinishAutoSave,
|
package/src/hooks.svelte.ts
CHANGED
|
@@ -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
|
|
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
|
|
232
|
+
return provider.custom<TData>({ url: params.url, method: params.method, payload: params.values, meta: params.meta });
|
|
233
233
|
},
|
|
234
234
|
}));
|
|
235
235
|
|
package/src/i18n.svelte.ts
CHANGED
|
@@ -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': '
|
|
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';
|
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
|
+
});
|