@svadmin/core 0.15.0 → 0.17.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.17.0",
4
4
  "description": "Core SDK — hooks, types, context, i18n, permissions, router",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -1,4 +1,10 @@
1
- // Chat provider — headless interface for AI chat integration
1
+ // Chat & Agent provider — headless interface for AI integration
2
+ //
3
+ // Two abstraction levels:
4
+ // 1. ChatProvider — simple message-in / text-out (backward compatible)
5
+ // 2. AgentProvider — tool calling, approval gates, generative UI events
6
+ //
7
+ // Inspired by Vercel AI SDK 6 agent abstractions.
2
8
 
3
9
  /** Describes an actionable button rendered in assistant messages (tool-call UX). */
4
10
  export interface ChatAction {
@@ -40,6 +46,117 @@ export interface ChatProvider {
40
46
  ): Promise<string> | AsyncGenerator<string, void, unknown>;
41
47
  }
42
48
 
49
+ // ─── Agent Provider (extends ChatProvider concept) ─────────────
50
+ //
51
+ // An AgentProvider emits typed events instead of raw strings, enabling:
52
+ // • Tool calling — Agent invokes admin operations (CRUD, custom)
53
+ // • Approval gates — Dangerous tools require user confirmation before execution
54
+ // • Generative UI — Agent returns component name + props for dynamic rendering
55
+
56
+ /** JSON-Schema-compatible parameter definition for an admin tool. */
57
+ export interface AdminToolParameter {
58
+ type: 'string' | 'number' | 'boolean' | 'object' | 'array';
59
+ description?: string;
60
+ enum?: string[];
61
+ required?: boolean;
62
+ }
63
+
64
+ /**
65
+ * A tool that the Agent can invoke within the admin panel.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * const deletePostTool: AdminTool = {
70
+ * name: 'deletePosts',
71
+ * description: 'Delete posts matching a filter',
72
+ * parameters: {
73
+ * status: { type: 'string', description: 'Filter by status', enum: ['draft', 'archived'] },
74
+ * },
75
+ * needsApproval: true,
76
+ * execute: async (params) => {
77
+ * const result = await dataProvider.deleteMany({ resource: 'posts', ids: params.ids });
78
+ * return { success: true, data: result };
79
+ * },
80
+ * };
81
+ * ```
82
+ */
83
+ export interface AdminTool {
84
+ /** Unique tool name (e.g. 'getList', 'deleteRecords', 'generateReport') */
85
+ name: string;
86
+ /** Human-readable description for LLM tool-use prompting */
87
+ description: string;
88
+ /** JSON-Schema-style parameter definitions */
89
+ parameters: Record<string, AdminToolParameter>;
90
+ /**
91
+ * When `true`, execution is paused until the user explicitly approves.
92
+ * The agent emits an `approval_request` event and waits for `approveToolCall()`.
93
+ */
94
+ needsApproval?: boolean;
95
+ /** Execute the tool with the given arguments. */
96
+ execute(args: Record<string, unknown>): Promise<ToolResult>;
97
+ }
98
+
99
+ /** Result returned by a tool execution. */
100
+ export interface ToolResult {
101
+ success: boolean;
102
+ data?: unknown;
103
+ error?: string;
104
+ }
105
+
106
+ /** Discriminated union of events emitted by an AgentProvider. */
107
+ export type AgentEvent =
108
+ /** Streaming text chunk */
109
+ | { type: 'text'; content: string }
110
+ /** Agent wants to call a tool (informational — execution is handled internally) */
111
+ | { type: 'tool_call'; tool: string; args: Record<string, unknown> }
112
+ /** Agent wants to call a tool that requires user approval before execution */
113
+ | { type: 'approval_request'; id: string; tool: string; args: Record<string, unknown>; description: string }
114
+ /** Tool execution completed */
115
+ | { type: 'tool_result'; tool: string; result: ToolResult }
116
+ /** Agent wants to render a named component (Generative UI) */
117
+ | { type: 'component'; name: string; props: Record<string, unknown> }
118
+ /** Stream finished */
119
+ | { type: 'done' };
120
+
121
+ /** Options passed to AgentProvider.chat() */
122
+ export interface AgentOptions {
123
+ signal?: AbortSignal;
124
+ /** Admin context is injected automatically by the framework */
125
+ context?: ChatContext;
126
+ }
127
+
128
+ /**
129
+ * AgentProvider — advanced AI integration with tool calling and approval gates.
130
+ *
131
+ * Implement this interface to connect to agentic AI backends that support
132
+ * function calling (OpenAI, Anthropic, Google Gemini, etc.).
133
+ *
134
+ * For simpler chat-only use cases, use `ChatProvider` instead.
135
+ *
136
+ * @example
137
+ * ```ts
138
+ * const agentProvider: AgentProvider = {
139
+ * tools: [deletePostTool, getStatsTool],
140
+ * async *chat(messages, options) {
141
+ * // Stream events from your AI backend
142
+ * yield { type: 'text', content: 'Looking up draft posts...' };
143
+ * yield { type: 'tool_call', tool: 'getList', args: { resource: 'posts', filter: { status: 'draft' } } };
144
+ * yield { type: 'approval_request', id: 'req-1', tool: 'deletePosts', args: { ids: ['1', '2'] }, description: 'Delete 2 draft posts' };
145
+ * yield { type: 'done' };
146
+ * },
147
+ * };
148
+ * ```
149
+ */
150
+ export interface AgentProvider {
151
+ /** Available tools the agent can invoke */
152
+ tools?: AdminTool[];
153
+ /** Send messages and receive a stream of typed agent events */
154
+ chat(
155
+ messages: ChatMessage[],
156
+ options?: AgentOptions,
157
+ ): AsyncGenerator<AgentEvent, void, unknown>;
158
+ }
159
+
43
160
  // ─── Chat Provider singleton ───────────────────────────────────
44
161
 
45
162
  let chatProvider: ChatProvider | null = $state(null);
@@ -52,6 +169,53 @@ export function getChatProvider(): ChatProvider | null {
52
169
  return chatProvider;
53
170
  }
54
171
 
172
+ // ─── Agent Provider singleton ──────────────────────────────────
173
+
174
+ let agentProvider: AgentProvider | null = $state(null);
175
+
176
+ export function setAgentProvider(provider: AgentProvider): void {
177
+ agentProvider = provider;
178
+ }
179
+
180
+ export function getAgentProvider(): AgentProvider | null {
181
+ return agentProvider;
182
+ }
183
+
184
+ // ─── Tool Approval ─────────────────────────────────────────────
185
+
186
+ type ApprovalCallback = (approved: boolean) => void;
187
+ const pendingApprovals = new Map<string, ApprovalCallback>();
188
+
189
+ /**
190
+ * Register a pending approval request. Called internally by the framework
191
+ * when an `approval_request` event is received from the agent.
192
+ */
193
+ export function registerApproval(id: string, callback: ApprovalCallback): void {
194
+ pendingApprovals.set(id, callback);
195
+ }
196
+
197
+ /**
198
+ * Approve or reject a pending tool call.
199
+ *
200
+ * @example
201
+ * ```svelte
202
+ * <button onclick={() => resolveApproval('req-1', true)}>Approve</button>
203
+ * <button onclick={() => resolveApproval('req-1', false)}>Reject</button>
204
+ * ```
205
+ */
206
+ export function resolveApproval(id: string, approved: boolean): boolean {
207
+ const cb = pendingApprovals.get(id);
208
+ if (!cb) return false;
209
+ cb(approved);
210
+ pendingApprovals.delete(id);
211
+ return true;
212
+ }
213
+
214
+ /** Check if there are any pending approval requests. */
215
+ export function hasPendingApprovals(): boolean {
216
+ return pendingApprovals.size > 0;
217
+ }
218
+
55
219
  // ─── Chat Context singleton ────────────────────────────────────
56
220
 
57
221
  let chatContext = $state<ChatContext>({});
@@ -166,3 +166,223 @@ describe('localStorage persistence simulation', () => {
166
166
  expect(restored[1].actions![0].label).toBe('Create Post');
167
167
  });
168
168
  });
169
+
170
+ // ─── AgentProvider Types ───────────────────────────────────────
171
+
172
+ /** Mirror of AdminToolParameter from chatProvider.svelte.ts */
173
+ interface AdminToolParameter {
174
+ type: 'string' | 'number' | 'boolean' | 'object' | 'array';
175
+ description?: string;
176
+ enum?: string[];
177
+ required?: boolean;
178
+ }
179
+
180
+ /** Mirror of AdminTool from chatProvider.svelte.ts */
181
+ interface AdminTool {
182
+ name: string;
183
+ description: string;
184
+ parameters: Record<string, AdminToolParameter>;
185
+ needsApproval?: boolean;
186
+ execute(args: Record<string, unknown>): Promise<{ success: boolean; data?: unknown; error?: string }>;
187
+ }
188
+
189
+ /** Mirror of AgentEvent from chatProvider.svelte.ts */
190
+ type AgentEvent =
191
+ | { type: 'text'; content: string }
192
+ | { type: 'tool_call'; tool: string; args: Record<string, unknown> }
193
+ | { type: 'approval_request'; id: string; tool: string; args: Record<string, unknown>; description: string }
194
+ | { type: 'tool_result'; tool: string; result: { success: boolean; data?: unknown; error?: string } }
195
+ | { type: 'component'; name: string; props: Record<string, unknown> }
196
+ | { type: 'done' };
197
+
198
+ describe('AdminTool shape', () => {
199
+ test('tool with needsApproval serializes correctly', () => {
200
+ const tool: AdminTool = {
201
+ name: 'deletePosts',
202
+ description: 'Delete posts matching a filter',
203
+ parameters: {
204
+ status: { type: 'string', description: 'Filter by status', enum: ['draft', 'archived'] },
205
+ },
206
+ needsApproval: true,
207
+ execute: async (args) => ({ success: true, data: { deleted: args.ids } }),
208
+ };
209
+
210
+ expect(tool.name).toBe('deletePosts');
211
+ expect(tool.needsApproval).toBe(true);
212
+ expect(tool.parameters.status.enum).toEqual(['draft', 'archived']);
213
+ });
214
+
215
+ test('tool without approval defaults to undefined', () => {
216
+ const tool: AdminTool = {
217
+ name: 'getStats',
218
+ description: 'Fetch dashboard statistics',
219
+ parameters: {},
220
+ execute: async () => ({ success: true, data: { users: 100 } }),
221
+ };
222
+
223
+ expect(tool.needsApproval).toBeUndefined();
224
+ });
225
+
226
+ test('tool execute returns ToolResult', async () => {
227
+ const tool: AdminTool = {
228
+ name: 'createPost',
229
+ description: 'Create a new post',
230
+ parameters: {
231
+ title: { type: 'string', required: true },
232
+ },
233
+ execute: async (args) => ({
234
+ success: true,
235
+ data: { id: '1', title: args.title },
236
+ }),
237
+ };
238
+
239
+ const result = await tool.execute({ title: 'Hello World' });
240
+ expect(result.success).toBe(true);
241
+ expect(result.data).toEqual({ id: '1', title: 'Hello World' });
242
+ });
243
+
244
+ test('tool execute can return error', async () => {
245
+ const tool: AdminTool = {
246
+ name: 'failingTool',
247
+ description: 'A tool that fails',
248
+ parameters: {},
249
+ execute: async () => ({
250
+ success: false,
251
+ error: 'Permission denied',
252
+ }),
253
+ };
254
+
255
+ const result = await tool.execute({});
256
+ expect(result.success).toBe(false);
257
+ expect(result.error).toBe('Permission denied');
258
+ });
259
+ });
260
+
261
+ describe('AgentEvent stream', () => {
262
+ test('events can be collected from async generator', async () => {
263
+ async function* mockAgent(): AsyncGenerator<AgentEvent, void, unknown> {
264
+ yield { type: 'text', content: 'Searching...' };
265
+ yield { type: 'tool_call', tool: 'getList', args: { resource: 'posts' } };
266
+ yield { type: 'tool_result', tool: 'getList', result: { success: true, data: [{ id: 1 }] } };
267
+ yield { type: 'text', content: 'Found 1 post.' };
268
+ yield { type: 'done' };
269
+ }
270
+
271
+ const events: AgentEvent[] = [];
272
+ for await (const event of mockAgent()) {
273
+ events.push(event);
274
+ }
275
+
276
+ expect(events).toHaveLength(5);
277
+ expect(events[0].type).toBe('text');
278
+ expect(events[1].type).toBe('tool_call');
279
+ expect(events[2].type).toBe('tool_result');
280
+ expect(events[4].type).toBe('done');
281
+ });
282
+
283
+ test('approval_request event serializes correctly', () => {
284
+ const event: AgentEvent = {
285
+ type: 'approval_request',
286
+ id: 'req-42',
287
+ tool: 'deletePosts',
288
+ args: { ids: ['1', '2', '3'] },
289
+ description: 'Delete 3 draft posts',
290
+ };
291
+
292
+ const serialized = JSON.stringify(event);
293
+ const parsed = JSON.parse(serialized) as AgentEvent;
294
+
295
+ expect(parsed.type).toBe('approval_request');
296
+ if (parsed.type === 'approval_request') {
297
+ expect(parsed.id).toBe('req-42');
298
+ expect(parsed.tool).toBe('deletePosts');
299
+ expect(parsed.description).toBe('Delete 3 draft posts');
300
+ }
301
+ });
302
+
303
+ test('component event carries name and props', () => {
304
+ const event: AgentEvent = {
305
+ type: 'component',
306
+ name: 'SalesChart',
307
+ props: { period: 'monthly', data: [100, 200, 150] },
308
+ };
309
+
310
+ expect(event.type).toBe('component');
311
+ if (event.type === 'component') {
312
+ expect(event.name).toBe('SalesChart');
313
+ expect(event.props.period).toBe('monthly');
314
+ }
315
+ });
316
+ });
317
+
318
+ describe('Approval flow', () => {
319
+ test('registerApproval and resolveApproval work together', () => {
320
+ // Simulate the approval flow without importing rune-based module
321
+ const pending = new Map<string, (approved: boolean) => void>();
322
+
323
+ function register(id: string, cb: (approved: boolean) => void) {
324
+ pending.set(id, cb);
325
+ }
326
+
327
+ function resolve(id: string, approved: boolean): boolean {
328
+ const cb = pending.get(id);
329
+ if (!cb) return false;
330
+ cb(approved);
331
+ pending.delete(id);
332
+ return true;
333
+ }
334
+
335
+ let approvalResult = false;
336
+
337
+ register('req-1', (approved) => {
338
+ approvalResult = approved;
339
+ });
340
+
341
+ expect(pending.size).toBe(1);
342
+
343
+ // Resolve with approval
344
+ const resolved = resolve('req-1', true);
345
+ expect(resolved).toBe(true);
346
+ expect(approvalResult).toEqual(true);
347
+ expect(pending.size).toBe(0);
348
+ });
349
+
350
+ test('resolveApproval returns false for unknown id', () => {
351
+ const pending = new Map<string, (approved: boolean) => void>();
352
+
353
+ function resolve(id: string, approved: boolean): boolean {
354
+ const cb = pending.get(id);
355
+ if (!cb) return false;
356
+ cb(approved);
357
+ pending.delete(id);
358
+ return true;
359
+ }
360
+
361
+ expect(resolve('nonexistent', true)).toBe(false);
362
+ });
363
+
364
+ test('rejection flow works', () => {
365
+ const pending = new Map<string, (approved: boolean) => void>();
366
+
367
+ function register(id: string, cb: (approved: boolean) => void) {
368
+ pending.set(id, cb);
369
+ }
370
+
371
+ function resolve(id: string, approved: boolean): boolean {
372
+ const cb = pending.get(id);
373
+ if (!cb) return false;
374
+ cb(approved);
375
+ pending.delete(id);
376
+ return true;
377
+ }
378
+
379
+ let wasApproved = true;
380
+
381
+ register('req-2', (approved) => {
382
+ wasApproved = approved;
383
+ });
384
+
385
+ resolve('req-2', false);
386
+ expect(wasApproved).toEqual(false);
387
+ });
388
+ });
@@ -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,72 @@ 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': '变更快照',
187
+ // Editor
188
+ 'editor.bold': '加粗',
189
+ 'editor.italic': '斜体',
190
+ 'editor.underline': '下划线',
191
+ 'editor.strikethrough': '删除线',
192
+ 'editor.code': '行内代码',
193
+ 'editor.codeBlock': '代码块',
194
+ 'editor.h1': '一级标题',
195
+ 'editor.h2': '二级标题',
196
+ 'editor.h3': '三级标题',
197
+ 'editor.h4': '四级标题',
198
+ 'editor.bulletList': '无序列表',
199
+ 'editor.orderedList': '有序列表',
200
+ 'editor.taskList': '任务列表',
201
+ 'editor.blockquote': '引用',
202
+ 'editor.alignLeft': '左对齐',
203
+ 'editor.alignCenter': '居中对齐',
204
+ 'editor.alignRight': '右对齐',
205
+ 'editor.alignJustify': '两端对齐',
206
+ 'editor.link': '插入链接',
207
+ 'editor.image': '插入图片',
208
+ 'editor.table': '插入表格',
209
+ 'editor.color': '文本颜色',
210
+ 'editor.highlight': '高亮',
211
+ 'editor.clearFormat': '清除格式',
212
+ 'editor.undo': '撤销',
213
+ 'editor.redo': '重做',
214
+ 'editor.startWriting': '开始写作...',
215
+ 'editor.typeSlash': '输入 / 唤出菜单',
216
+ // Editor - Popovers
217
+ 'editor.link.url': '链接地址',
218
+ 'editor.link.openInNewTab': '在新标签页打开',
219
+ 'editor.link.apply': '应用',
220
+ 'editor.link.remove': '移除链接',
221
+ 'editor.image.url': '图片链接 (URL)',
222
+ 'editor.image.upload': '点击或拖拽图片到此处上传',
223
+ 'editor.image.uploading': '上传中...',
224
+ 'editor.table.insertRowAbove': '在上方插入行',
225
+ 'editor.table.insertRowBelow': '在下方插入行',
226
+ 'editor.table.deleteRow': '删除行',
227
+ 'editor.table.insertColumnLeft': '在左侧插入列',
228
+ 'editor.table.insertColumnRight': '在右侧插入列',
229
+ 'editor.table.deleteColumn': '删除列',
230
+ 'editor.table.deleteTable': '删除表格',
231
+ 'editor.table.mergeCells': '合并/拆分单元格',
166
232
  // Profile
167
233
  'profile.title': '个人资料',
168
234
  'profile.name': '姓名',
@@ -332,6 +398,72 @@ const locales: Record<string, Locale> = {
332
398
  'settings.dataProvider': 'Data Provider',
333
399
  'settings.currentEnvironment': 'Current Environment',
334
400
  'settings.settingsDescription': 'Manage your account and interface preferences.',
401
+ 'settings.rolesAndPermissions': 'Roles & Permissions',
402
+ 'settings.roles': 'Roles',
403
+ 'settings.rolesDescription': 'Manage system access control and resource permissions.',
404
+ 'settings.rbacNotSupported': 'RBAC management is not supported by the current data provider',
405
+ 'settings.selectRoleHint': 'Select a role on the left to view permissions',
406
+ 'settings.resource': 'Resource',
407
+ 'resource.posts': 'Posts',
408
+ 'resource.users': 'Users',
409
+ 'resource.categories': 'Categories',
410
+ // Audit
411
+ 'settings.auditLogs': 'Audit Logs',
412
+ 'settings.auditDescription': 'Track and review all system operations for compliance and debugging.',
413
+ 'settings.auditNotSupported': 'Audit logs are not supported by the current data provider',
414
+ 'settings.searchLogs': 'Search by user, action, or resource...',
415
+ 'settings.auditTime': 'Time',
416
+ 'settings.auditUser': 'User',
417
+ 'settings.auditAction': 'Action',
418
+ 'settings.auditResource': 'Resource',
419
+ 'settings.auditIp': 'IP Address',
420
+ 'settings.auditTotal': 'total records',
421
+ 'settings.auditSnapshot': 'Change Snapshot',
422
+ // Editor
423
+ 'editor.bold': 'Bold',
424
+ 'editor.italic': 'Italic',
425
+ 'editor.underline': 'Underline',
426
+ 'editor.strikethrough': 'Strikethrough',
427
+ 'editor.code': 'Inline Code',
428
+ 'editor.codeBlock': 'Code Block',
429
+ 'editor.h1': 'Heading 1',
430
+ 'editor.h2': 'Heading 2',
431
+ 'editor.h3': 'Heading 3',
432
+ 'editor.h4': 'Heading 4',
433
+ 'editor.bulletList': 'Bullet List',
434
+ 'editor.orderedList': 'Ordered List',
435
+ 'editor.taskList': 'Task List',
436
+ 'editor.blockquote': 'Blockquote',
437
+ 'editor.alignLeft': 'Align Left',
438
+ 'editor.alignCenter': 'Align Center',
439
+ 'editor.alignRight': 'Align Right',
440
+ 'editor.alignJustify': 'Justify',
441
+ 'editor.link': 'Insert Link',
442
+ 'editor.image': 'Insert Image',
443
+ 'editor.table': 'Insert Table',
444
+ 'editor.color': 'Text Color',
445
+ 'editor.highlight': 'Highlight',
446
+ 'editor.clearFormat': 'Clear Formatting',
447
+ 'editor.undo': 'Undo',
448
+ 'editor.redo': 'Redo',
449
+ 'editor.startWriting': 'Start writing...',
450
+ 'editor.typeSlash': 'Type / for commands',
451
+ // Editor - Popovers
452
+ 'editor.link.url': 'Link URL',
453
+ 'editor.link.openInNewTab': 'Open in new tab',
454
+ 'editor.link.apply': 'Apply',
455
+ 'editor.link.remove': 'Remove Link',
456
+ 'editor.image.url': 'Image URL',
457
+ 'editor.image.upload': 'Click or drag image here to upload',
458
+ 'editor.image.uploading': 'Uploading...',
459
+ 'editor.table.insertRowAbove': 'Insert row above',
460
+ 'editor.table.insertRowBelow': 'Insert row below',
461
+ 'editor.table.deleteRow': 'Delete row',
462
+ 'editor.table.insertColumnLeft': 'Insert column left',
463
+ 'editor.table.insertColumnRight': 'Insert column right',
464
+ 'editor.table.deleteColumn': 'Delete column',
465
+ 'editor.table.deleteTable': 'Delete table',
466
+ 'editor.table.mergeCells': 'Toggle merge cells',
335
467
  // Profile
336
468
  'profile.title': 'Profile',
337
469
  'profile.name': 'Name',
package/src/index.ts CHANGED
@@ -27,15 +27,15 @@ 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';
34
34
  export { t, setLocale, getLocale, getAvailableLocales, addTranslations, useTranslation } from './i18n.svelte';
35
35
  export { audit, setAuditHandler, setAuditLogProvider, getAuditLogProvider } from './audit';
36
36
  export type { AuditLogProvider } from './audit';
37
- export { setChatProvider, getChatProvider, setChatContext, getChatContext } from './chatProvider.svelte';
38
- export type { ChatProvider, ChatMessage, ChatContext, ChatAction } from './chatProvider.svelte';
37
+ export { setChatProvider, getChatProvider, setChatContext, getChatContext, setAgentProvider, getAgentProvider, registerApproval, resolveApproval, hasPendingApprovals } from './chatProvider.svelte';
38
+ export type { ChatProvider, ChatMessage, ChatContext, ChatAction, AgentProvider, AgentEvent, AgentOptions, AdminTool, AdminToolParameter, ToolResult } from './chatProvider.svelte';
39
39
  export { getTheme, setTheme, toggleTheme, getResolvedTheme, getColorTheme, setColorTheme, colorThemes, configureTheme, getThemeConfig, clearCssOverrides, builtinPresets, registerColorPreset, getColorPresets } from './theme.svelte';
40
40
  export type { ThemeMode, ColorTheme, ThemeStrategy, ThemeConfig, ColorPreset } from './theme.svelte';
41
41
  export { setUnsavedChanges, getUnsavedChanges, initUnsavedChangesNotifier } from './unsaved-changes.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
-