@svadmin/core 0.15.0 → 0.16.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.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Core SDK — hooks, types, context, i18n, permissions, router",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -8,7 +8,7 @@ import type { RouterProvider } from './router-provider';
8
8
 
9
9
  /**
10
10
  * Accepts either:
11
- * - A single DataProvider (backwards compatible)
11
+ * - A single DataProvider
12
12
  * - A record of named providers: { default: rest, cms: graphql }
13
13
  */
14
14
  export type DataProviderInput = DataProvider | Record<string, DataProvider>;
@@ -125,8 +125,8 @@ export interface UseFormReturn<
125
125
  readonly autoSave: { status: 'idle' | 'saving' | 'saved' | 'error'; data: unknown; error: unknown };
126
126
 
127
127
  // ─── Raw query/mutation (escape hatch) ────────────────────────
128
- readonly query: ReturnType<typeof createQuery> | null;
129
- readonly mutation: ReturnType<typeof createMutation>;
128
+ readonly query: unknown;
129
+ readonly mutation: unknown;
130
130
  }
131
131
 
132
132
  // ─── Implementation ──────────────────────────────────────────────────
@@ -423,6 +423,6 @@ export function useForm<
423
423
 
424
424
  // Raw escape hatches
425
425
  query,
426
- mutation: (action === 'edit' ? updateMut : createMut) as unknown as ReturnType<typeof createMutation>,
426
+ mutation: action === 'edit' ? updateMut : createMut,
427
427
  };
428
428
  }
@@ -97,6 +97,7 @@ export function fireSuccessNotification(
97
97
  resource?: string,
98
98
  ): void {
99
99
  if (config === false) return;
100
+ if (!config && !defaultMessage) return;
100
101
  if (typeof config === 'function') {
101
102
  const result = config(data, values, resource);
102
103
  toast.success(result.message);
@@ -163,6 +163,27 @@ const locales: Record<string, Locale> = {
163
163
  'settings.dataProvider': '数据源',
164
164
  'settings.currentEnvironment': '当前环境',
165
165
  'settings.settingsDescription': '管理您的账户和界面偏好。',
166
+ 'settings.rolesAndPermissions': '角色与权限',
167
+ 'settings.roles': '角色',
168
+ 'settings.rolesDescription': '管理系统访问控制与资源权限。',
169
+ 'settings.rbacNotSupported': '当前数据源不支持 RBAC 管理',
170
+ 'settings.selectRoleHint': '请在左侧选择一个角色以查看权限',
171
+ 'settings.resource': '资源',
172
+ 'resource.posts': '文章',
173
+ 'resource.users': '用户',
174
+ 'resource.categories': '分类',
175
+ // Audit
176
+ 'settings.auditLogs': '操作日志',
177
+ 'settings.auditDescription': '追踪和审查所有系统操作,用于合规与调试。',
178
+ 'settings.auditNotSupported': '当前数据源不支持操作日志',
179
+ 'settings.searchLogs': '搜索用户、操作或资源...',
180
+ 'settings.auditTime': '时间',
181
+ 'settings.auditUser': '操作人',
182
+ 'settings.auditAction': '操作',
183
+ 'settings.auditResource': '资源',
184
+ 'settings.auditIp': 'IP 地址',
185
+ 'settings.auditTotal': '条记录',
186
+ 'settings.auditSnapshot': '变更快照',
166
187
  // Profile
167
188
  'profile.title': '个人资料',
168
189
  'profile.name': '姓名',
@@ -332,6 +353,27 @@ const locales: Record<string, Locale> = {
332
353
  'settings.dataProvider': 'Data Provider',
333
354
  'settings.currentEnvironment': 'Current Environment',
334
355
  'settings.settingsDescription': 'Manage your account and interface preferences.',
356
+ 'settings.rolesAndPermissions': 'Roles & Permissions',
357
+ 'settings.roles': 'Roles',
358
+ 'settings.rolesDescription': 'Manage system access control and resource permissions.',
359
+ 'settings.rbacNotSupported': 'RBAC management is not supported by the current data provider',
360
+ 'settings.selectRoleHint': 'Select a role on the left to view permissions',
361
+ 'settings.resource': 'Resource',
362
+ 'resource.posts': 'Posts',
363
+ 'resource.users': 'Users',
364
+ 'resource.categories': 'Categories',
365
+ // Audit
366
+ 'settings.auditLogs': 'Audit Logs',
367
+ 'settings.auditDescription': 'Track and review all system operations for compliance and debugging.',
368
+ 'settings.auditNotSupported': 'Audit logs are not supported by the current data provider',
369
+ 'settings.searchLogs': 'Search by user, action, or resource...',
370
+ 'settings.auditTime': 'Time',
371
+ 'settings.auditUser': 'User',
372
+ 'settings.auditAction': 'Action',
373
+ 'settings.auditResource': 'Resource',
374
+ 'settings.auditIp': 'IP Address',
375
+ 'settings.auditTotal': 'total records',
376
+ 'settings.auditSnapshot': 'Change Snapshot',
335
377
  // Profile
336
378
  'profile.title': 'Profile',
337
379
  'profile.name': 'Name',
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ export {
27
27
  } from './hooks.svelte';
28
28
  export { matchRoute, navigate, currentPath, setActiveRouterProvider } from './router';
29
29
  export { readURLState, writeURLState } from './url-sync';
30
- export { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccess, canAccessAsync } from './permissions';
30
+ export { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccessAsync } from './permissions';
31
31
  export { useLive, useSubscription, usePublish } from './live.svelte';
32
32
  export { toast } from './toast.svelte';
33
33
  export { notify, closeNotification, setNotificationProvider, getNotificationProvider } from './notification.svelte';
@@ -61,7 +61,7 @@ export type {
61
61
  ResourceDefinition, FieldDefinition, MenuItem,
62
62
  AuthActionResult, CheckResult,
63
63
  ResourceTypeMap, KnownResources, InferData,
64
- BaseRecord,
64
+ BaseRecord, Role, AuditLog
65
65
  } from './types';
66
66
  export type { InvalidateScope } from './options.svelte';
67
67
  export type { LiveProvider, LiveEvent, LiveMode } from './live.svelte';
@@ -1,6 +1,6 @@
1
1
  // Unit tests for permissions module
2
2
  import { describe, test, expect, beforeEach } from 'bun:test';
3
- import { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccess, canAccessAsync } from './permissions';
3
+ import { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccessAsync } from './permissions';
4
4
  import type { Action, AccessControlProvider } from './permissions';
5
5
 
6
6
  describe('permissions', () => {
@@ -11,12 +11,6 @@ describe('permissions', () => {
11
11
  });
12
12
  });
13
13
 
14
- test('canAccess returns { can: true } when provider is async (warns)', () => {
15
- // canAccess is sync but provider.can() is async — should default to { can: true }
16
- const result = canAccess('posts', 'list' as Action);
17
- expect(result.can).toBe(true);
18
- });
19
-
20
14
  test('canAccessAsync respects deny rule', async () => {
21
15
  setAccessControlProvider({
22
16
  can: async ({ action }) => {
@@ -73,17 +73,7 @@ export function getAccessControlOptions() {
73
73
  }
74
74
 
75
75
  /**
76
- * Synchronous access check. Returns `{ can: true }` if no provider is set.
77
- * When a provider is registered, this warns since provider.can() is always async.
78
- */
79
- export function canAccess(resource: string, action: Action, params?: Record<string, unknown>): CanResult {
80
- if (!provider) return { can: true };
81
- console.warn('[permissions] canAccess called synchronously but provider.can() is async. Defaulting to { can: true }. Use canAccessAsync() instead.');
82
- return { can: true };
83
- }
84
-
85
- /**
86
- * Async access check. Preferred over `canAccess()`.
76
+ * Async access check. Returns `{ can: true }` if no provider is registered.
87
77
  */
88
78
  export async function canAccessAsync(resource: string, action: Action, params?: Record<string, unknown>): Promise<CanResult> {
89
79
  if (!provider) return { can: true };
@@ -75,7 +75,7 @@ export function useList<TData extends BaseRecord = BaseRecord, TError = HttpErro
75
75
  });
76
76
 
77
77
  $effect(() => {
78
- if (query.isSuccess) {
78
+ if (query.isSuccess && options.successNotification) {
79
79
  fireSuccessNotification(options.successNotification, '', query.data, undefined, resource);
80
80
  } else if (query.isError) {
81
81
  fireErrorNotification(options.errorNotification, 'Fetch failed', query.error);
package/src/router.ts CHANGED
@@ -53,9 +53,9 @@ export function matchRoute(
53
53
  /**
54
54
  * Navigate to a path. Uses RouterProvider if available, falls back to hash.
55
55
  */
56
- export function navigate(path: string): void {
56
+ export function navigate(path: string, options?: { replaceState?: boolean }): void {
57
57
  if (_routerProvider) {
58
- _routerProvider.go({ to: path });
58
+ _routerProvider.go({ to: path, type: options?.replaceState ? 'replace' : 'push' });
59
59
  } else {
60
60
  window.location.hash = '#' + path.replace(/^#/, '');
61
61
  }
@@ -42,7 +42,6 @@ export function useGo() {
42
42
  if (routerProvider?.go) {
43
43
  routerProvider.go({ to: targetUrl, type: options.type });
44
44
  } else {
45
- // @ts-expect-error SvelteKit / internal navigate might only take 1 arg or options obj
46
45
  navigate(targetUrl, options.type === 'replace' ? { replaceState: true } : undefined);
47
46
  }
48
47
  };
@@ -69,15 +69,15 @@ export function useTable<
69
69
 
70
70
  const querySorters = $derived(sortersMode === 'server' ? effectiveSorters : []);
71
71
  const queryFilters = $derived(filtersMode === 'server' ? effectiveFilters : []);
72
- const queryPagination = paginationMode === 'server'
72
+ const queryPagination = $derived(paginationMode === 'server'
73
73
  ? pagination
74
- : { current: 1, pageSize: 999999, mode: paginationMode as 'client' | 'off' };
74
+ : { current: 1, pageSize: 999999, mode: paginationMode as 'client' | 'off' });
75
75
 
76
76
  const tableQueryInfo = useList<TQueryFnData, TError>({
77
77
  resource,
78
- pagination: queryPagination,
79
- sorters: querySorters,
80
- filters: queryFilters,
78
+ get pagination() { return queryPagination; },
79
+ get sorters() { return querySorters; },
80
+ get filters() { return queryFilters; },
81
81
  meta,
82
82
  dataProviderName,
83
83
  ...restOptions
package/src/types.ts CHANGED
@@ -213,6 +213,13 @@ export interface CheckResult {
213
213
  logout?: boolean;
214
214
  }
215
215
 
216
+ export interface Role {
217
+ id: string;
218
+ name: string;
219
+ description?: string;
220
+ [key: string]: unknown;
221
+ }
222
+
216
223
  export interface AuthProvider {
217
224
  login: (params: Record<string, unknown>) => Promise<AuthActionResult>;
218
225
  logout: (params?: Record<string, unknown>) => Promise<AuthActionResult>;
@@ -224,9 +231,28 @@ export interface AuthProvider {
224
231
  updatePassword?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
225
232
  /** Update user profile (name, avatar, etc.) */
226
233
  updateProfile?: (params: { name?: string; avatar?: string | File; [key: string]: unknown }) => Promise<AuthActionResult>;
234
+ /** Get list of system roles for RBAC mapping */
235
+ getRoles?: () => Promise<Role[]>;
236
+ /** Get specific role permissions as a matrix map, e.g. { "users": ["create", "read"] } */
237
+ getRolePermissions?: (roleId: string) => Promise<Record<string, string[]>>;
238
+ /** Update permissions for a specific role */
239
+ updateRolePermissions?: (roleId: string, permissions: Record<string, string[]>) => Promise<AuthActionResult>;
240
+ /** Get recent audit logs */
241
+ getAuditLogs?: (params?: { page?: number; pageSize?: number }) => Promise<{ data: AuditLog[]; total: number }>;
227
242
  onError?: (error: unknown) => Promise<{ redirectTo?: string; logout?: boolean }>;
228
243
  }
229
244
 
245
+ export interface AuditLog {
246
+ id: string | number;
247
+ userId?: string | number;
248
+ userName?: string;
249
+ action: string;
250
+ resource?: string;
251
+ createdAt: string | Date;
252
+ ipAddress?: string;
253
+ details?: Record<string, unknown> | string;
254
+ }
255
+
230
256
  // ─── NotificationProvider ─────────────────────────────────────
231
257
 
232
258
  export interface NotificationProvider {
@@ -291,7 +317,7 @@ export interface FieldDefinition {
291
317
  label: string;
292
318
  type: 'text' | 'number' | 'boolean' | 'date' | 'select' | 'multiselect' | 'tags'
293
319
  | 'textarea' | 'richtext' | 'image' | 'images' | 'json' | 'relation' | 'color'
294
- | 'url' | 'email' | 'phone' | 'file' | 'markdown' | 'password';
320
+ | 'url' | 'email' | 'phone' | 'file' | 'markdown' | 'password' | 'array';
295
321
  required?: boolean;
296
322
  searchable?: boolean;
297
323
  sortable?: boolean;
@@ -308,6 +334,8 @@ export interface FieldDefinition {
308
334
  resource?: string; // related resource name
309
335
  optionLabel?: string; // field to use as label
310
336
  optionValue?: string; // field to use as value
337
+ // Array support
338
+ subFields?: FieldDefinition[]; // nested fields for array types
311
339
  // Validation
312
340
  validate?: (value: unknown) => string | null;
313
341
  // Grouping
@@ -336,7 +364,7 @@ export interface FieldDefinition {
336
364
  */
337
365
  export interface ResourceTypeMap {}
338
366
 
339
- /** When ResourceTypeMap is empty → string (backward compatible); otherwise → registered keys */
367
+ /** When ResourceTypeMap is empty → string; otherwise → registered keys */
340
368
  export type KnownResources = keyof ResourceTypeMap extends never
341
369
  ? string
342
370
  : Extract<keyof ResourceTypeMap, string>
package/src/useCan.ts CHANGED
@@ -22,36 +22,28 @@ export interface UseCanResult {
22
22
 
23
23
  /**
24
24
  * Reactive permission check backed by TanStack Query for caching and deduplication.
25
+ * Accepts a getter function for Svelte 5 fine-grained reactivity.
25
26
  *
26
27
  * @example
27
28
  * ```ts
28
- * // Simple usage (backward compatible)
29
- * const can = useCan('posts', 'delete');
29
+ * const can = useCan(() => ({ resource: 'posts', action: 'delete', params: { id: 1 } }));
30
30
  * if (can.allowed) { ... }
31
- *
32
- * // With options object
33
- * const can = useCan({ resource: 'posts', action: 'delete', params: { id: 1 } });
34
31
  * ```
35
32
  */
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;
41
-
42
- // @tanstack/svelte-query v6 Accessor pattern
43
- const query = createQuery<CanResult>(() => ({
44
- queryKey: ['useCan', options.resource, options.action, options.params] as const,
45
- queryFn: () => canAccessAsync(options.resource, options.action, options.params),
46
- enabled: options.queryOptions?.enabled ?? true,
47
- staleTime: options.queryOptions?.staleTime ?? 5 * 60 * 1000, // 5 min default cache
48
- }));
33
+ export function useCan(options: () => UseCanOptions): UseCanResult {
34
+ const query = createQuery<CanResult>(() => {
35
+ const opts = options();
36
+ return {
37
+ queryKey: ['useCan', opts.resource, opts.action, opts.params] as const,
38
+ queryFn: () => canAccessAsync(opts.resource, opts.action, opts.params),
39
+ enabled: opts.queryOptions?.enabled ?? true,
40
+ staleTime: opts.queryOptions?.staleTime ?? 5 * 60 * 1000,
41
+ };
42
+ });
49
43
 
50
- // Access via query store — use safe property access with fallbacks
51
44
  return {
52
45
  get allowed() { return (query.data as CanResult | undefined)?.can ?? true; },
53
46
  get reason() { return (query.data as CanResult | undefined)?.reason; },
54
47
  get isLoading() { return query.isLoading; },
55
48
  };
56
49
  }
57
-