@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 +1 -1
- package/src/chatProvider.svelte.ts +165 -1
- package/src/chatProvider.test.ts +220 -0
- package/src/context.svelte.ts +1 -1
- package/src/form-hooks.svelte.ts +3 -3
- package/src/hook-utils.svelte.ts +1 -0
- package/src/i18n.svelte.ts +132 -0
- package/src/index.ts +4 -4
- package/src/permissions.test.ts +1 -7
- package/src/permissions.ts +1 -11
- package/src/query-hooks.svelte.ts +1 -1
- package/src/router.ts +2 -2
- package/src/routing-hooks.svelte.ts +0 -1
- package/src/table-hooks.svelte.ts +5 -5
- package/src/types.ts +30 -2
- package/src/useCan.ts +12 -20
package/package.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
// Chat provider — headless interface for AI
|
|
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>({});
|
package/src/chatProvider.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/context.svelte.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { RouterProvider } from './router-provider';
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Accepts either:
|
|
11
|
-
* - A single DataProvider
|
|
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>;
|
package/src/form-hooks.svelte.ts
CHANGED
|
@@ -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:
|
|
129
|
-
readonly mutation:
|
|
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:
|
|
426
|
+
mutation: action === 'edit' ? updateMut : createMut,
|
|
427
427
|
};
|
|
428
428
|
}
|
package/src/hook-utils.svelte.ts
CHANGED
|
@@ -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);
|
package/src/i18n.svelte.ts
CHANGED
|
@@ -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,
|
|
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';
|
package/src/permissions.test.ts
CHANGED
|
@@ -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,
|
|
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 }) => {
|
package/src/permissions.ts
CHANGED
|
@@ -73,17 +73,7 @@ export function getAccessControlOptions() {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
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
|
|
79
|
-
sorters
|
|
80
|
-
filters
|
|
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
|
|
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
|
-
*
|
|
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(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|