qdadm 0.15.1 → 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/README.md +153 -1
- package/package.json +15 -2
- package/src/components/BoolCell.vue +11 -6
- package/src/components/forms/FormField.vue +64 -6
- package/src/components/forms/FormPage.vue +276 -0
- package/src/components/index.js +11 -0
- package/src/components/layout/AppLayout.vue +18 -9
- package/src/components/layout/BaseLayout.vue +183 -0
- package/src/components/layout/DashboardLayout.vue +100 -0
- package/src/components/layout/FormLayout.vue +261 -0
- package/src/components/layout/ListLayout.vue +334 -0
- package/src/components/layout/PageHeader.vue +6 -9
- package/src/components/layout/PageNav.vue +15 -0
- package/src/components/layout/Zone.vue +165 -0
- package/src/components/layout/defaults/DefaultBreadcrumb.vue +140 -0
- package/src/components/layout/defaults/DefaultFooter.vue +56 -0
- package/src/components/layout/defaults/DefaultFormActions.vue +53 -0
- package/src/components/layout/defaults/DefaultHeader.vue +69 -0
- package/src/components/layout/defaults/DefaultMenu.vue +197 -0
- package/src/components/layout/defaults/DefaultPagination.vue +79 -0
- package/src/components/layout/defaults/DefaultTable.vue +130 -0
- package/src/components/layout/defaults/DefaultToaster.vue +16 -0
- package/src/components/layout/defaults/DefaultUserInfo.vue +96 -0
- package/src/components/layout/defaults/index.js +17 -0
- package/src/composables/index.js +8 -6
- package/src/composables/useBreadcrumb.js +9 -5
- package/src/composables/useForm.js +135 -0
- package/src/composables/useFormPageBuilder.js +1154 -0
- package/src/composables/useHooks.js +53 -0
- package/src/composables/useLayoutResolver.js +260 -0
- package/src/composables/useListPageBuilder.js +336 -52
- package/src/composables/useNavContext.js +372 -0
- package/src/composables/useNavigation.js +38 -2
- package/src/composables/usePageTitle.js +59 -0
- package/src/composables/useSignals.js +49 -0
- package/src/composables/useZoneRegistry.js +162 -0
- package/src/core/bundles.js +406 -0
- package/src/core/decorator.js +322 -0
- package/src/core/extension.js +386 -0
- package/src/core/index.js +28 -0
- package/src/entity/EntityManager.js +314 -16
- package/src/entity/auth/AuthAdapter.js +125 -0
- package/src/entity/auth/PermissiveAdapter.js +64 -0
- package/src/entity/auth/index.js +11 -0
- package/src/entity/index.js +3 -0
- package/src/entity/storage/MockApiStorage.js +349 -0
- package/src/entity/storage/SdkStorage.js +478 -0
- package/src/entity/storage/index.js +2 -0
- package/src/hooks/HookRegistry.js +411 -0
- package/src/hooks/index.js +12 -0
- package/src/index.js +12 -0
- package/src/kernel/Kernel.js +141 -4
- package/src/kernel/SignalBus.js +180 -0
- package/src/kernel/index.js +7 -0
- package/src/module/moduleRegistry.js +124 -6
- package/src/orchestrator/Orchestrator.js +73 -1
- package/src/plugin.js +5 -0
- package/src/zones/ZoneRegistry.js +821 -0
- package/src/zones/index.js +16 -0
- package/src/zones/zones.js +189 -0
- package/src/composables/useEntityTitle.js +0 -121
- package/src/composables/useManager.js +0 -20
- package/src/composables/usePageBuilder.js +0 -334
- package/src/composables/useStatus.js +0 -146
- package/src/composables/useSubEditor.js +0 -165
- package/src/composables/useTabSync.js +0 -110
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Module
|
|
3
|
+
*
|
|
4
|
+
* Extension and helper utilities for qdadm modules.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
extendModule,
|
|
9
|
+
ExtensionBuilder,
|
|
10
|
+
} from './extension.js'
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
createDecoratedManager,
|
|
14
|
+
withAuditLog as withAuditLogDecorator,
|
|
15
|
+
withSoftDelete as withSoftDeleteDecorator,
|
|
16
|
+
withTimestamps as withTimestampsDecorator,
|
|
17
|
+
withValidation,
|
|
18
|
+
} from './decorator.js'
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
createHookBundle,
|
|
22
|
+
applyBundle,
|
|
23
|
+
applyBundles,
|
|
24
|
+
withSoftDelete,
|
|
25
|
+
withTimestamps,
|
|
26
|
+
withVersioning,
|
|
27
|
+
withAuditLog,
|
|
28
|
+
} from './bundles.js'
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { PermissiveAuthAdapter } from './auth/PermissiveAdapter.js'
|
|
2
|
+
import { AuthActions } from './auth/AuthAdapter.js'
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* EntityManager - Base class for entity CRUD operations
|
|
3
6
|
*
|
|
@@ -59,7 +62,9 @@ export class EntityManager {
|
|
|
59
62
|
// Relations
|
|
60
63
|
children = {}, // { roles: { entity: 'roles', endpoint?: ':id/roles' } }
|
|
61
64
|
parent = null, // { entity: 'users', foreignKey: 'user_id' }
|
|
62
|
-
relations = {}
|
|
65
|
+
relations = {}, // { groups: { entity: 'groups', through: 'user_groups' } }
|
|
66
|
+
// Auth adapter (for permission checks)
|
|
67
|
+
authAdapter = null // AuthAdapter instance or null (uses PermissiveAuthAdapter)
|
|
63
68
|
} = options
|
|
64
69
|
|
|
65
70
|
this.name = name
|
|
@@ -86,6 +91,12 @@ export class EntityManager {
|
|
|
86
91
|
this._relations = relations
|
|
87
92
|
this._orchestrator = null // Set when registered
|
|
88
93
|
|
|
94
|
+
// Auth adapter (fallback to permissive if not provided)
|
|
95
|
+
this._authAdapter = authAdapter
|
|
96
|
+
|
|
97
|
+
// HookRegistry reference for lifecycle hooks (set by Orchestrator)
|
|
98
|
+
this._hooks = null
|
|
99
|
+
|
|
89
100
|
// Severity maps for status fields (field → value → severity)
|
|
90
101
|
this._severityMaps = {}
|
|
91
102
|
|
|
@@ -97,6 +108,153 @@ export class EntityManager {
|
|
|
97
108
|
valid: false // Is cache currently valid?
|
|
98
109
|
}
|
|
99
110
|
this._cacheLoading = null // Promise when cache is being loaded
|
|
111
|
+
|
|
112
|
+
// SignalBus reference for event emission (set by Orchestrator)
|
|
113
|
+
this._signals = null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ============ SIGNALS ============
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Set the SignalBus reference (called by Orchestrator during registration)
|
|
120
|
+
* @param {SignalBus} signals
|
|
121
|
+
*/
|
|
122
|
+
setSignals(signals) {
|
|
123
|
+
this._signals = signals
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Set the HookRegistry reference (called by Orchestrator during registration)
|
|
128
|
+
* @param {HookRegistry} hooks
|
|
129
|
+
*/
|
|
130
|
+
setHooks(hooks) {
|
|
131
|
+
this._hooks = hooks
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get the HookRegistry reference
|
|
136
|
+
* @returns {HookRegistry|null}
|
|
137
|
+
*/
|
|
138
|
+
get hooks() {
|
|
139
|
+
return this._hooks
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Emit entity signals after CRUD operations
|
|
144
|
+
* Emits both entity-specific and generic signals via SignalBus.emitEntity
|
|
145
|
+
* @param {string} action - 'created', 'updated', or 'deleted'
|
|
146
|
+
* @param {object} data - Signal payload data { entity, id, ... }
|
|
147
|
+
* @private
|
|
148
|
+
*/
|
|
149
|
+
_emitSignal(action, data) {
|
|
150
|
+
if (!this._signals) return
|
|
151
|
+
// Use SignalBus.emitEntity for proper dual-signal emission
|
|
152
|
+
this._signals.emitEntity(this.name, action, data)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ============ LIFECYCLE HOOKS ============
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Invoke a lifecycle hook for this entity
|
|
159
|
+
*
|
|
160
|
+
* Invokes both entity-specific hook (e.g., 'books:presave') and
|
|
161
|
+
* generic hook (e.g., 'entity:presave'). Entity-specific hooks run first.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} hookName - Hook name without entity prefix (e.g., 'presave')
|
|
164
|
+
* @param {object} context - Hook context passed to handlers
|
|
165
|
+
* @private
|
|
166
|
+
*/
|
|
167
|
+
async _invokeHook(hookName, context) {
|
|
168
|
+
if (!this._hooks) return
|
|
169
|
+
|
|
170
|
+
// Invoke entity-specific hook first (e.g., 'books:presave')
|
|
171
|
+
await this._hooks.invoke(`${this.name}:${hookName}`, context)
|
|
172
|
+
|
|
173
|
+
// Invoke generic hook (e.g., 'entity:presave')
|
|
174
|
+
await this._hooks.invoke(`entity:${hookName}`, context)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build hook context for presave operations
|
|
179
|
+
*
|
|
180
|
+
* @typedef {object} PresaveContext
|
|
181
|
+
* @property {string} entity - Entity name
|
|
182
|
+
* @property {object} record - Record data (can be mutated by handlers)
|
|
183
|
+
* @property {boolean} isNew - True for create, false for update
|
|
184
|
+
* @property {string|number} [id] - Record ID (only for update)
|
|
185
|
+
* @property {EntityManager} manager - This manager instance
|
|
186
|
+
*
|
|
187
|
+
* @param {object} data - Record data
|
|
188
|
+
* @param {boolean} isNew - True for create, false for update
|
|
189
|
+
* @param {string|number} [id] - Record ID (only for update)
|
|
190
|
+
* @returns {PresaveContext}
|
|
191
|
+
* @private
|
|
192
|
+
*/
|
|
193
|
+
_buildPresaveContext(data, isNew, id = null) {
|
|
194
|
+
const context = {
|
|
195
|
+
entity: this.name,
|
|
196
|
+
record: data,
|
|
197
|
+
isNew,
|
|
198
|
+
manager: this
|
|
199
|
+
}
|
|
200
|
+
if (!isNew && id !== null) {
|
|
201
|
+
context.id = id
|
|
202
|
+
}
|
|
203
|
+
return context
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Build hook context for postsave operations
|
|
208
|
+
*
|
|
209
|
+
* @typedef {object} PostsaveContext
|
|
210
|
+
* @property {string} entity - Entity name
|
|
211
|
+
* @property {object} record - Original record data
|
|
212
|
+
* @property {object} result - Saved entity returned from storage
|
|
213
|
+
* @property {boolean} isNew - True for create, false for update
|
|
214
|
+
* @property {string|number} [id] - Record ID
|
|
215
|
+
* @property {EntityManager} manager - This manager instance
|
|
216
|
+
*
|
|
217
|
+
* @param {object} data - Original record data
|
|
218
|
+
* @param {object} result - Saved entity from storage
|
|
219
|
+
* @param {boolean} isNew - True for create, false for update
|
|
220
|
+
* @param {string|number} [id] - Record ID
|
|
221
|
+
* @returns {PostsaveContext}
|
|
222
|
+
* @private
|
|
223
|
+
*/
|
|
224
|
+
_buildPostsaveContext(data, result, isNew, id = null) {
|
|
225
|
+
const context = {
|
|
226
|
+
entity: this.name,
|
|
227
|
+
record: data,
|
|
228
|
+
result,
|
|
229
|
+
isNew,
|
|
230
|
+
manager: this
|
|
231
|
+
}
|
|
232
|
+
if (id !== null) {
|
|
233
|
+
context.id = id
|
|
234
|
+
} else if (result?.[this.idField]) {
|
|
235
|
+
context.id = result[this.idField]
|
|
236
|
+
}
|
|
237
|
+
return context
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Build hook context for predelete operations
|
|
242
|
+
*
|
|
243
|
+
* @typedef {object} PredeleteContext
|
|
244
|
+
* @property {string} entity - Entity name
|
|
245
|
+
* @property {string|number} id - Record ID to be deleted
|
|
246
|
+
* @property {EntityManager} manager - This manager instance
|
|
247
|
+
*
|
|
248
|
+
* @param {string|number} id - Record ID to delete
|
|
249
|
+
* @returns {PredeleteContext}
|
|
250
|
+
* @private
|
|
251
|
+
*/
|
|
252
|
+
_buildPredeleteContext(id) {
|
|
253
|
+
return {
|
|
254
|
+
entity: this.name,
|
|
255
|
+
id,
|
|
256
|
+
manager: this
|
|
257
|
+
}
|
|
100
258
|
}
|
|
101
259
|
|
|
102
260
|
// ============ METADATA ACCESSORS ============
|
|
@@ -159,9 +317,71 @@ export class EntityManager {
|
|
|
159
317
|
|
|
160
318
|
// ============ PERMISSIONS ============
|
|
161
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Get the auth adapter (lazy-initialized PermissiveAuthAdapter if not set)
|
|
322
|
+
* @returns {AuthAdapter}
|
|
323
|
+
*/
|
|
324
|
+
get authAdapter() {
|
|
325
|
+
if (!this._authAdapter) {
|
|
326
|
+
this._authAdapter = new PermissiveAuthAdapter()
|
|
327
|
+
}
|
|
328
|
+
return this._authAdapter
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Set the auth adapter
|
|
333
|
+
* @param {AuthAdapter|null} adapter
|
|
334
|
+
*/
|
|
335
|
+
set authAdapter(adapter) {
|
|
336
|
+
this._authAdapter = adapter
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Check if the current user can perform an action, optionally on a specific record
|
|
341
|
+
*
|
|
342
|
+
* This is the primary permission check method. It combines:
|
|
343
|
+
* 1. Local restrictions (readOnly, scopeWhitelist)
|
|
344
|
+
* 2. Scope check via AuthAdapter.canPerform() - can user do this action type?
|
|
345
|
+
* 3. Silo check via AuthAdapter.canAccessRecord() - can user access this record?
|
|
346
|
+
*
|
|
347
|
+
* @param {string} action - Action to check: 'read', 'create', 'update', 'delete', 'list'
|
|
348
|
+
* @param {object} [record] - Optional: specific record to check (for silo validation)
|
|
349
|
+
* @returns {boolean} - true if action is allowed
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* // Scope-only checks (no record)
|
|
353
|
+
* manager.canAccess('create') // Can user create new items?
|
|
354
|
+
* manager.canAccess('list') // Can user see the list?
|
|
355
|
+
*
|
|
356
|
+
* @example
|
|
357
|
+
* // Scope + silo checks (with record)
|
|
358
|
+
* manager.canAccess('read', item) // Can user see this specific item?
|
|
359
|
+
* manager.canAccess('update', item) // Can user edit this specific item?
|
|
360
|
+
* manager.canAccess('delete', item) // Can user delete this specific item?
|
|
361
|
+
*/
|
|
362
|
+
canAccess(action, record = null) {
|
|
363
|
+
// 1. Check readOnly restriction for write actions
|
|
364
|
+
if (this._readOnly && action !== AuthActions.READ && action !== AuthActions.LIST) {
|
|
365
|
+
return false
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 2. Scope check: can user perform this action on this entity type?
|
|
369
|
+
const canPerformAction = this.authAdapter.canPerform(this.name, action)
|
|
370
|
+
if (!canPerformAction) {
|
|
371
|
+
return false
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 3. Silo check: if record provided, can user access this specific record?
|
|
375
|
+
if (record !== null) {
|
|
376
|
+
return this.authAdapter.canAccessRecord(this.name, record)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return true
|
|
380
|
+
}
|
|
381
|
+
|
|
162
382
|
/**
|
|
163
383
|
* Check if user can read entities
|
|
164
|
-
*
|
|
384
|
+
* Delegates to canAccess('read', entity)
|
|
165
385
|
*
|
|
166
386
|
* @param {object} [entity] - Optional: specific entity to check
|
|
167
387
|
* @returns {boolean} - true if user can read
|
|
@@ -170,8 +390,7 @@ export class EntityManager {
|
|
|
170
390
|
* With entity: specific read permission (e.g., can see this item)
|
|
171
391
|
*/
|
|
172
392
|
canRead(entity = null) {
|
|
173
|
-
|
|
174
|
-
return true
|
|
393
|
+
return this.canAccess(AuthActions.READ, entity)
|
|
175
394
|
}
|
|
176
395
|
|
|
177
396
|
/**
|
|
@@ -184,18 +403,17 @@ export class EntityManager {
|
|
|
184
403
|
|
|
185
404
|
/**
|
|
186
405
|
* Check if user can create new entities
|
|
187
|
-
*
|
|
406
|
+
* Delegates to canAccess('create')
|
|
188
407
|
*
|
|
189
408
|
* @returns {boolean} - true if user can create
|
|
190
409
|
*/
|
|
191
410
|
canCreate() {
|
|
192
|
-
|
|
193
|
-
return true
|
|
411
|
+
return this.canAccess(AuthActions.CREATE)
|
|
194
412
|
}
|
|
195
413
|
|
|
196
414
|
/**
|
|
197
415
|
* Check if user can update entities
|
|
198
|
-
*
|
|
416
|
+
* Delegates to canAccess('update', entity)
|
|
199
417
|
*
|
|
200
418
|
* @param {object} [entity] - Optional: specific entity to check
|
|
201
419
|
* @returns {boolean} - true if user can update
|
|
@@ -204,20 +422,28 @@ export class EntityManager {
|
|
|
204
422
|
* With entity: specific update permission (e.g., can edit this item)
|
|
205
423
|
*/
|
|
206
424
|
canUpdate(entity = null) {
|
|
207
|
-
|
|
208
|
-
return true
|
|
425
|
+
return this.canAccess(AuthActions.UPDATE, entity)
|
|
209
426
|
}
|
|
210
427
|
|
|
211
428
|
/**
|
|
212
429
|
* Check if user can delete entities
|
|
213
|
-
*
|
|
430
|
+
* Delegates to canAccess('delete', entity)
|
|
214
431
|
*
|
|
215
432
|
* @param {object} [entity] - Optional: specific entity to check
|
|
216
433
|
* @returns {boolean} - true if user can delete
|
|
217
434
|
*/
|
|
218
435
|
canDelete(entity = null) {
|
|
219
|
-
|
|
220
|
-
|
|
436
|
+
return this.canAccess(AuthActions.DELETE, entity)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Check if user can list entities
|
|
441
|
+
* Delegates to canAccess('list')
|
|
442
|
+
*
|
|
443
|
+
* @returns {boolean} - true if user can list
|
|
444
|
+
*/
|
|
445
|
+
canList() {
|
|
446
|
+
return this.canAccess(AuthActions.LIST)
|
|
221
447
|
}
|
|
222
448
|
|
|
223
449
|
/**
|
|
@@ -465,13 +691,33 @@ export class EntityManager {
|
|
|
465
691
|
|
|
466
692
|
/**
|
|
467
693
|
* Create a new entity
|
|
694
|
+
*
|
|
695
|
+
* Lifecycle hooks invoked:
|
|
696
|
+
* - presave: Before storage.create(), can modify data or throw to abort
|
|
697
|
+
* - postsave: After successful storage.create(), for side effects
|
|
698
|
+
*
|
|
468
699
|
* @param {object} data
|
|
469
700
|
* @returns {Promise<object>} - The created entity
|
|
470
701
|
*/
|
|
471
702
|
async create(data) {
|
|
472
703
|
if (this.storage) {
|
|
473
|
-
|
|
704
|
+
// Invoke presave hooks (can modify data or throw to abort)
|
|
705
|
+
const presaveContext = this._buildPresaveContext(data, true)
|
|
706
|
+
await this._invokeHook('presave', presaveContext)
|
|
707
|
+
|
|
708
|
+
// Use potentially modified data from context
|
|
709
|
+
const result = await this.storage.create(presaveContext.record)
|
|
474
710
|
this.invalidateCache()
|
|
711
|
+
|
|
712
|
+
// Invoke postsave hooks (for side effects)
|
|
713
|
+
const postsaveContext = this._buildPostsaveContext(data, result, true)
|
|
714
|
+
await this._invokeHook('postsave', postsaveContext)
|
|
715
|
+
|
|
716
|
+
this._emitSignal('created', {
|
|
717
|
+
entity: result,
|
|
718
|
+
manager: this.name,
|
|
719
|
+
id: result?.[this.idField]
|
|
720
|
+
})
|
|
475
721
|
return result
|
|
476
722
|
}
|
|
477
723
|
throw new Error(`[EntityManager:${this.name}] create() not implemented`)
|
|
@@ -479,14 +725,34 @@ export class EntityManager {
|
|
|
479
725
|
|
|
480
726
|
/**
|
|
481
727
|
* Update an entity (PUT - full replacement)
|
|
728
|
+
*
|
|
729
|
+
* Lifecycle hooks invoked:
|
|
730
|
+
* - presave: Before storage.update(), can modify data or throw to abort
|
|
731
|
+
* - postsave: After successful storage.update(), for side effects
|
|
732
|
+
*
|
|
482
733
|
* @param {string|number} id
|
|
483
734
|
* @param {object} data
|
|
484
735
|
* @returns {Promise<object>}
|
|
485
736
|
*/
|
|
486
737
|
async update(id, data) {
|
|
487
738
|
if (this.storage) {
|
|
488
|
-
|
|
739
|
+
// Invoke presave hooks (can modify data or throw to abort)
|
|
740
|
+
const presaveContext = this._buildPresaveContext(data, false, id)
|
|
741
|
+
await this._invokeHook('presave', presaveContext)
|
|
742
|
+
|
|
743
|
+
// Use potentially modified data from context
|
|
744
|
+
const result = await this.storage.update(id, presaveContext.record)
|
|
489
745
|
this.invalidateCache()
|
|
746
|
+
|
|
747
|
+
// Invoke postsave hooks (for side effects)
|
|
748
|
+
const postsaveContext = this._buildPostsaveContext(data, result, false, id)
|
|
749
|
+
await this._invokeHook('postsave', postsaveContext)
|
|
750
|
+
|
|
751
|
+
this._emitSignal('updated', {
|
|
752
|
+
entity: result,
|
|
753
|
+
manager: this.name,
|
|
754
|
+
id
|
|
755
|
+
})
|
|
490
756
|
return result
|
|
491
757
|
}
|
|
492
758
|
throw new Error(`[EntityManager:${this.name}] update() not implemented`)
|
|
@@ -494,14 +760,34 @@ export class EntityManager {
|
|
|
494
760
|
|
|
495
761
|
/**
|
|
496
762
|
* Partially update an entity (PATCH)
|
|
763
|
+
*
|
|
764
|
+
* Lifecycle hooks invoked:
|
|
765
|
+
* - presave: Before storage.patch(), can modify data or throw to abort
|
|
766
|
+
* - postsave: After successful storage.patch(), for side effects
|
|
767
|
+
*
|
|
497
768
|
* @param {string|number} id
|
|
498
769
|
* @param {object} data
|
|
499
770
|
* @returns {Promise<object>}
|
|
500
771
|
*/
|
|
501
772
|
async patch(id, data) {
|
|
502
773
|
if (this.storage) {
|
|
503
|
-
|
|
774
|
+
// Invoke presave hooks (can modify data or throw to abort)
|
|
775
|
+
const presaveContext = this._buildPresaveContext(data, false, id)
|
|
776
|
+
await this._invokeHook('presave', presaveContext)
|
|
777
|
+
|
|
778
|
+
// Use potentially modified data from context
|
|
779
|
+
const result = await this.storage.patch(id, presaveContext.record)
|
|
504
780
|
this.invalidateCache()
|
|
781
|
+
|
|
782
|
+
// Invoke postsave hooks (for side effects)
|
|
783
|
+
const postsaveContext = this._buildPostsaveContext(data, result, false, id)
|
|
784
|
+
await this._invokeHook('postsave', postsaveContext)
|
|
785
|
+
|
|
786
|
+
this._emitSignal('updated', {
|
|
787
|
+
entity: result,
|
|
788
|
+
manager: this.name,
|
|
789
|
+
id
|
|
790
|
+
})
|
|
505
791
|
return result
|
|
506
792
|
}
|
|
507
793
|
throw new Error(`[EntityManager:${this.name}] patch() not implemented`)
|
|
@@ -509,13 +795,25 @@ export class EntityManager {
|
|
|
509
795
|
|
|
510
796
|
/**
|
|
511
797
|
* Delete an entity
|
|
798
|
+
*
|
|
799
|
+
* Lifecycle hooks invoked:
|
|
800
|
+
* - predelete: Before storage.delete(), can throw to abort (for cascade checks)
|
|
801
|
+
*
|
|
512
802
|
* @param {string|number} id
|
|
513
803
|
* @returns {Promise<void>}
|
|
514
804
|
*/
|
|
515
805
|
async delete(id) {
|
|
516
806
|
if (this.storage) {
|
|
807
|
+
// Invoke predelete hooks (can throw to abort, e.g., for cascade checks)
|
|
808
|
+
const predeleteContext = this._buildPredeleteContext(id)
|
|
809
|
+
await this._invokeHook('predelete', predeleteContext)
|
|
810
|
+
|
|
517
811
|
const result = await this.storage.delete(id)
|
|
518
812
|
this.invalidateCache()
|
|
813
|
+
this._emitSignal('deleted', {
|
|
814
|
+
manager: this.name,
|
|
815
|
+
id
|
|
816
|
+
})
|
|
519
817
|
return result
|
|
520
818
|
}
|
|
521
819
|
throw new Error(`[EntityManager:${this.name}] delete() not implemented`)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthAdapter - Interface for scope and silo permission checks
|
|
3
|
+
*
|
|
4
|
+
* Applications implement this interface to plug their authentication/authorization
|
|
5
|
+
* system into qdadm's EntityManager. The adapter provides two levels of permission checks:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Scopes** (action-level): Can the user perform this action on this entity type?
|
|
8
|
+
* Example: Can user create invoices? Can user delete users?
|
|
9
|
+
*
|
|
10
|
+
* 2. **Silos** (record-level): Can the user access this specific record?
|
|
11
|
+
* Example: Can user see invoice #123? (ownership, team membership, etc.)
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* ```js
|
|
15
|
+
* class MyAuthAdapter extends AuthAdapter {
|
|
16
|
+
* canPerform(entity, action) {
|
|
17
|
+
* const user = this.getCurrentUser()
|
|
18
|
+
* return user?.permissions?.includes(`${entity}:${action}`)
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* canAccessRecord(entity, record) {
|
|
22
|
+
* const user = this.getCurrentUser()
|
|
23
|
+
* // Check ownership or team membership
|
|
24
|
+
* return record.owner_id === user?.id || record.team_id === user?.team_id
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* getCurrentUser() {
|
|
28
|
+
* return this._userStore.currentUser
|
|
29
|
+
* }
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* @interface
|
|
34
|
+
*/
|
|
35
|
+
export class AuthAdapter {
|
|
36
|
+
/**
|
|
37
|
+
* Check if the current user can perform an action on an entity type (scope check)
|
|
38
|
+
*
|
|
39
|
+
* This is the coarse-grained permission check. It determines if the user has
|
|
40
|
+
* the right to perform a category of actions, regardless of which specific
|
|
41
|
+
* record they want to act on.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} entity - Entity name (e.g., 'users', 'invoices', 'products')
|
|
44
|
+
* @param {string} action - Action to check: 'read', 'create', 'update', 'delete', 'list'
|
|
45
|
+
* @returns {boolean} True if user can perform the action on this entity type
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* // Check if user can create invoices
|
|
49
|
+
* adapter.canPerform('invoices', 'create') // true/false
|
|
50
|
+
*
|
|
51
|
+
* // Check if user can delete users
|
|
52
|
+
* adapter.canPerform('users', 'delete') // true/false
|
|
53
|
+
*/
|
|
54
|
+
canPerform(entity, action) {
|
|
55
|
+
throw new Error('[AuthAdapter] canPerform() must be implemented by subclass')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if the current user can access a specific record (silo check)
|
|
60
|
+
*
|
|
61
|
+
* This is the fine-grained permission check. After the scope check passes,
|
|
62
|
+
* this determines if the user can access a particular record based on
|
|
63
|
+
* ownership, team membership, or other business rules.
|
|
64
|
+
*
|
|
65
|
+
* Called during:
|
|
66
|
+
* - get() operations
|
|
67
|
+
* - update() / patch() operations
|
|
68
|
+
* - delete() operations
|
|
69
|
+
* - list() result filtering (optional)
|
|
70
|
+
*
|
|
71
|
+
* @param {string} entity - Entity name (e.g., 'users', 'invoices')
|
|
72
|
+
* @param {object} record - The full entity record to check access for
|
|
73
|
+
* @returns {boolean} True if user can access this specific record
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* // Check if user can access a specific invoice
|
|
77
|
+
* adapter.canAccessRecord('invoices', { id: 123, owner_id: 456, ... })
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* // Common implementations:
|
|
81
|
+
* // 1. Ownership: record.owner_id === user.id
|
|
82
|
+
* // 2. Team: record.team_id === user.team_id
|
|
83
|
+
* // 3. Role: user.role === 'admin' (admins see all)
|
|
84
|
+
* // 4. Hierarchical: record.organization_id in user.organizations
|
|
85
|
+
*/
|
|
86
|
+
canAccessRecord(entity, record) {
|
|
87
|
+
throw new Error('[AuthAdapter] canAccessRecord() must be implemented by subclass')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get the current authenticated user
|
|
92
|
+
*
|
|
93
|
+
* Returns the user object or null if not authenticated. The user object
|
|
94
|
+
* should contain whatever information is needed for permission checks
|
|
95
|
+
* (id, role, team_id, permissions array, etc.).
|
|
96
|
+
*
|
|
97
|
+
* @returns {object|null} Current user object or null if not authenticated
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* // Typical user object:
|
|
101
|
+
* {
|
|
102
|
+
* id: 123,
|
|
103
|
+
* email: 'user@example.com',
|
|
104
|
+
* role: 'manager',
|
|
105
|
+
* team_id: 456,
|
|
106
|
+
* permissions: ['invoices:create', 'invoices:read', 'users:read']
|
|
107
|
+
* }
|
|
108
|
+
*/
|
|
109
|
+
getCurrentUser() {
|
|
110
|
+
throw new Error('[AuthAdapter] getCurrentUser() must be implemented by subclass')
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Valid action types for scope checks
|
|
116
|
+
* @readonly
|
|
117
|
+
* @enum {string}
|
|
118
|
+
*/
|
|
119
|
+
export const AuthActions = Object.freeze({
|
|
120
|
+
READ: 'read',
|
|
121
|
+
CREATE: 'create',
|
|
122
|
+
UPDATE: 'update',
|
|
123
|
+
DELETE: 'delete',
|
|
124
|
+
LIST: 'list'
|
|
125
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PermissiveAuthAdapter - Default adapter that allows all operations
|
|
3
|
+
*
|
|
4
|
+
* This adapter is used when no custom AuthAdapter is provided to EntityManager.
|
|
5
|
+
* It returns `true` for all permission checks, effectively disabling authorization.
|
|
6
|
+
*
|
|
7
|
+
* Use cases:
|
|
8
|
+
* - Development/prototyping without auth setup
|
|
9
|
+
* - Backward compatibility with existing apps that don't use auth
|
|
10
|
+
* - Internal admin tools with implicit trust
|
|
11
|
+
* - Testing environments
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // Explicit usage (rarely needed)
|
|
15
|
+
* const adapter = new PermissiveAuthAdapter()
|
|
16
|
+
*
|
|
17
|
+
* adapter.canPerform('users', 'delete') // true
|
|
18
|
+
* adapter.canAccessRecord('invoices', { id: 123, secret: true }) // true
|
|
19
|
+
* adapter.getCurrentUser() // null
|
|
20
|
+
*
|
|
21
|
+
* @extends AuthAdapter
|
|
22
|
+
*/
|
|
23
|
+
import { AuthAdapter } from './AuthAdapter.js'
|
|
24
|
+
|
|
25
|
+
export class PermissiveAuthAdapter extends AuthAdapter {
|
|
26
|
+
/**
|
|
27
|
+
* Always allows any action on any entity type
|
|
28
|
+
*
|
|
29
|
+
* @param {string} entity - Entity name (ignored)
|
|
30
|
+
* @param {string} action - Action to check (ignored)
|
|
31
|
+
* @returns {boolean} Always returns true
|
|
32
|
+
*/
|
|
33
|
+
canPerform(entity, action) {
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Always allows access to any record
|
|
39
|
+
*
|
|
40
|
+
* @param {string} entity - Entity name (ignored)
|
|
41
|
+
* @param {object} record - The record (ignored)
|
|
42
|
+
* @returns {boolean} Always returns true
|
|
43
|
+
*/
|
|
44
|
+
canAccessRecord(entity, record) {
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns null (no authenticated user in permissive mode)
|
|
50
|
+
*
|
|
51
|
+
* @returns {null} Always returns null
|
|
52
|
+
*/
|
|
53
|
+
getCurrentUser() {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Factory function to create a PermissiveAuthAdapter
|
|
60
|
+
* @returns {PermissiveAuthAdapter}
|
|
61
|
+
*/
|
|
62
|
+
export function createPermissiveAdapter() {
|
|
63
|
+
return new PermissiveAuthAdapter()
|
|
64
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Module
|
|
3
|
+
*
|
|
4
|
+
* AuthAdapter interface and implementations for scope/silo permission checks.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Interface
|
|
8
|
+
export { AuthAdapter, AuthActions } from './AuthAdapter.js'
|
|
9
|
+
|
|
10
|
+
// Implementations
|
|
11
|
+
export { PermissiveAuthAdapter, createPermissiveAdapter } from './PermissiveAdapter.js'
|