@svadmin/core 0.14.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.14.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,
@@ -181,7 +181,9 @@ export function useGetIdentity() {
181
181
  let isLoading = $state(true);
182
182
  let error = $state<Error | null>(null);
183
183
 
184
- if (provider) {
184
+ function fetch() {
185
+ if (!provider) { isLoading = false; return; }
186
+ isLoading = true;
185
187
  provider.getIdentity().then(identity => {
186
188
  data = identity;
187
189
  isLoading = false;
@@ -190,14 +192,15 @@ export function useGetIdentity() {
190
192
  isLoading = false;
191
193
  console.warn('[svadmin] useGetIdentity failed:', err);
192
194
  });
193
- } else {
194
- isLoading = false;
195
195
  }
196
196
 
197
+ fetch();
198
+
197
199
  return {
198
200
  get data() { return data; },
199
201
  get isLoading() { return isLoading; },
200
202
  get error() { return error; },
203
+ refetch: fetch,
201
204
  };
202
205
  }
203
206
 
@@ -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);
@@ -140,6 +140,58 @@ const locales: Record<string, Locale> = {
140
140
  'validation.maxLength': '最多 {max} 个字符',
141
141
  'validation.invalidEmail': '请输入有效的邮箱地址',
142
142
  'validation.invalidFormat': '格式不正确',
143
+ // Settings
144
+ 'settings.title': '设置',
145
+ 'settings.profile': '个人资料',
146
+ 'settings.appearance': '外观设置',
147
+ 'settings.about': '关于',
148
+ 'settings.general': '通用',
149
+ 'settings.system': '系统',
150
+ 'settings.themeMode': '主题模式',
151
+ 'settings.light': '浅色',
152
+ 'settings.dark': '深色',
153
+ 'settings.systemTheme': '跟随系统',
154
+ 'settings.colorAccent': '强调色',
155
+ 'settings.language': '语言',
156
+ 'settings.interface': '界面',
157
+ 'settings.sidebarDensity': '侧边栏密度',
158
+ 'settings.compact': '紧凑',
159
+ 'settings.standard': '标准',
160
+ 'settings.defaultPageSize': '默认每页条数',
161
+ 'settings.version': '版本',
162
+ 'settings.registeredResources': '已注册资源',
163
+ 'settings.dataProvider': '数据源',
164
+ 'settings.currentEnvironment': '当前环境',
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': '变更快照',
187
+ // Profile
188
+ 'profile.title': '个人资料',
189
+ 'profile.name': '姓名',
190
+ 'profile.notAvailable': '无法获取资料',
191
+ 'profile.changePassword': '修改密码',
192
+ 'profile.currentPassword': '当前密码',
193
+ 'profile.passwordChanged': '密码已修改',
194
+ 'profile.updatePassword': '更新密码',
143
195
  },
144
196
  'en': {
145
197
  // Common
@@ -278,6 +330,58 @@ const locales: Record<string, Locale> = {
278
330
  'validation.maxLength': 'Maximum {max} characters',
279
331
  'validation.invalidEmail': 'Please enter a valid email address',
280
332
  'validation.invalidFormat': 'Invalid format',
333
+ // Settings
334
+ 'settings.title': 'Settings',
335
+ 'settings.profile': 'Profile',
336
+ 'settings.appearance': 'Appearance',
337
+ 'settings.about': 'About',
338
+ 'settings.general': 'General',
339
+ 'settings.system': 'System',
340
+ 'settings.themeMode': 'Theme Mode',
341
+ 'settings.light': 'Light',
342
+ 'settings.dark': 'Dark',
343
+ 'settings.systemTheme': 'System',
344
+ 'settings.colorAccent': 'Color Accent',
345
+ 'settings.language': 'Language',
346
+ 'settings.interface': 'Interface',
347
+ 'settings.sidebarDensity': 'Sidebar Density',
348
+ 'settings.compact': 'Compact',
349
+ 'settings.standard': 'Standard',
350
+ 'settings.defaultPageSize': 'Default Page Size',
351
+ 'settings.version': 'Version',
352
+ 'settings.registeredResources': 'Registered Resources',
353
+ 'settings.dataProvider': 'Data Provider',
354
+ 'settings.currentEnvironment': 'Current Environment',
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',
377
+ // Profile
378
+ 'profile.title': 'Profile',
379
+ 'profile.name': 'Name',
380
+ 'profile.notAvailable': 'Profile not available',
381
+ 'profile.changePassword': 'Change Password',
382
+ 'profile.currentPassword': 'Current Password',
383
+ 'profile.passwordChanged': 'Password updated successfully',
384
+ 'profile.updatePassword': 'Update Password',
281
385
  },
282
386
  };
283
387
 
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';
@@ -44,6 +44,8 @@ export interface AdminOptions {
44
44
  disableRouteChangeHandler?: boolean;
45
45
  onLiveEvent?: (event: unknown) => void;
46
46
  menuItems?: (items: { name: string; route: string; label: string; icon?: string; parentName?: string; order?: number }[]) => { name: string; route: string; label: string; icon?: string; parentName?: string; order?: number }[];
47
+ /** Default page size for tables. Falls back to localStorage 'svadmin-default-page-size' or 10. */
48
+ defaultPageSize?: number;
47
49
  }
48
50
 
49
51
  const defaultTextTransformers: TextTransformers = {
@@ -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>;
@@ -222,9 +229,30 @@ export interface AuthProvider {
222
229
  register?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
223
230
  forgotPassword?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
224
231
  updatePassword?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
232
+ /** Update user profile (name, avatar, etc.) */
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 }>;
225
242
  onError?: (error: unknown) => Promise<{ redirectTo?: string; logout?: boolean }>;
226
243
  }
227
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
+
228
256
  // ─── NotificationProvider ─────────────────────────────────────
229
257
 
230
258
  export interface NotificationProvider {
@@ -289,7 +317,7 @@ export interface FieldDefinition {
289
317
  label: string;
290
318
  type: 'text' | 'number' | 'boolean' | 'date' | 'select' | 'multiselect' | 'tags'
291
319
  | 'textarea' | 'richtext' | 'image' | 'images' | 'json' | 'relation' | 'color'
292
- | 'url' | 'email' | 'phone' | 'file' | 'markdown' | 'password';
320
+ | 'url' | 'email' | 'phone' | 'file' | 'markdown' | 'password' | 'array';
293
321
  required?: boolean;
294
322
  searchable?: boolean;
295
323
  sortable?: boolean;
@@ -306,6 +334,8 @@ export interface FieldDefinition {
306
334
  resource?: string; // related resource name
307
335
  optionLabel?: string; // field to use as label
308
336
  optionValue?: string; // field to use as value
337
+ // Array support
338
+ subFields?: FieldDefinition[]; // nested fields for array types
309
339
  // Validation
310
340
  validate?: (value: unknown) => string | null;
311
341
  // Grouping
@@ -334,7 +364,7 @@ export interface FieldDefinition {
334
364
  */
335
365
  export interface ResourceTypeMap {}
336
366
 
337
- /** When ResourceTypeMap is empty → string (backward compatible); otherwise → registered keys */
367
+ /** When ResourceTypeMap is empty → string; otherwise → registered keys */
338
368
  export type KnownResources = keyof ResourceTypeMap extends never
339
369
  ? string
340
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
-