qdadm 0.36.0 → 0.38.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 +27 -174
- package/package.json +2 -1
- package/src/auth/SessionAuthAdapter.js +114 -3
- package/src/components/editors/PermissionEditor.vue +535 -0
- package/src/components/forms/FormField.vue +1 -11
- package/src/components/index.js +1 -0
- package/src/components/layout/AppLayout.vue +20 -8
- package/src/components/layout/defaults/DefaultToaster.vue +3 -3
- package/src/components/pages/LoginPage.vue +26 -5
- package/src/composables/useCurrentEntity.js +26 -17
- package/src/composables/useForm.js +7 -0
- package/src/composables/useFormPageBuilder.js +7 -0
- package/src/composables/useNavContext.js +30 -16
- package/src/core/index.js +0 -3
- package/src/debug/AuthCollector.js +175 -33
- package/src/debug/Collector.js +24 -2
- package/src/debug/EntitiesCollector.js +8 -0
- package/src/debug/SignalCollector.js +60 -2
- package/src/debug/components/panels/AuthPanel.vue +157 -27
- package/src/debug/components/panels/EntitiesPanel.vue +17 -1
- package/src/entity/EntityManager.js +183 -34
- package/src/entity/auth/EntityAuthAdapter.js +54 -46
- package/src/entity/auth/SecurityChecker.js +110 -42
- package/src/entity/auth/factory.js +11 -2
- package/src/entity/auth/factory.test.js +29 -0
- package/src/entity/storage/factory.test.js +6 -5
- package/src/index.js +3 -0
- package/src/kernel/Kernel.js +132 -21
- package/src/kernel/KernelContext.js +158 -0
- package/src/security/EntityRoleGranterAdapter.js +350 -0
- package/src/security/PermissionMatcher.js +148 -0
- package/src/security/PermissionRegistry.js +263 -0
- package/src/security/PersistableRoleGranterAdapter.js +618 -0
- package/src/security/RoleGranterAdapter.js +123 -0
- package/src/security/RoleGranterStorage.js +161 -0
- package/src/security/RolesManager.js +81 -0
- package/src/security/SecurityModule.js +73 -0
- package/src/security/StaticRoleGranterAdapter.js +114 -0
- package/src/security/UsersManager.js +122 -0
- package/src/security/index.js +45 -0
- package/src/security/pages/RoleForm.vue +212 -0
- package/src/security/pages/RoleList.vue +106 -0
- package/src/styles/main.css +62 -2
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PersistableRoleGranterAdapter - Role granter with load/persist callbacks
|
|
3
|
+
*
|
|
4
|
+
* Flexible adapter that can load role→permissions mapping from any source
|
|
5
|
+
* (localStorage, API, entity) and persist changes back.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Fixed permissions (incompressible, always applied, never overridden)
|
|
9
|
+
* - Default mapping as fallback
|
|
10
|
+
* - Async load/persist callbacks
|
|
11
|
+
* - Merge strategy for defaults + loaded data
|
|
12
|
+
* - Cache invalidation on persist
|
|
13
|
+
*
|
|
14
|
+
* Permission priority (highest to lowest):
|
|
15
|
+
* 1. fixed - System permissions, always present, never overridden
|
|
16
|
+
* 2. loaded - Data from load() callback
|
|
17
|
+
* 3. defaults - Fallback when load returns null/fails
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* // With fixed system permissions + API loading
|
|
21
|
+
* const adapter = new PersistableRoleGranterAdapter({
|
|
22
|
+
* // Fixed: ALWAYS present, even if API returns different data
|
|
23
|
+
* fixed: {
|
|
24
|
+
* role_permissions: {
|
|
25
|
+
* ROLE_ANONYMOUS: ['auth:login', 'auth:register'],
|
|
26
|
+
* ROLE_USER: ['auth:authenticated', 'auth:logout', 'profile:read']
|
|
27
|
+
* }
|
|
28
|
+
* },
|
|
29
|
+
* // Defaults: used before load() or if load fails
|
|
30
|
+
* defaults: {
|
|
31
|
+
* role_permissions: {
|
|
32
|
+
* ROLE_USER: ['entity:*:read'],
|
|
33
|
+
* ROLE_ADMIN: ['entity:**']
|
|
34
|
+
* }
|
|
35
|
+
* },
|
|
36
|
+
* // Load from API (requires auth, called after login)
|
|
37
|
+
* load: async () => {
|
|
38
|
+
* const res = await fetch('/api/config/roles')
|
|
39
|
+
* return res.json()
|
|
40
|
+
* },
|
|
41
|
+
* autoLoad: false // Load manually after auth
|
|
42
|
+
* })
|
|
43
|
+
*
|
|
44
|
+
* // After user authenticates
|
|
45
|
+
* signals.on('auth:login', () => adapter.load())
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* // With localStorage
|
|
49
|
+
* const adapter = new PersistableRoleGranterAdapter({
|
|
50
|
+
* load: () => JSON.parse(localStorage.getItem('roles') || 'null'),
|
|
51
|
+
* persist: (data) => localStorage.setItem('roles', JSON.stringify(data)),
|
|
52
|
+
* defaults: {
|
|
53
|
+
* role_permissions: {
|
|
54
|
+
* ROLE_USER: ['entity:*:read', 'entity:*:list'],
|
|
55
|
+
* ROLE_ADMIN: ['entity:**', 'admin:**']
|
|
56
|
+
* }
|
|
57
|
+
* }
|
|
58
|
+
* })
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import { RoleGranterAdapter } from './RoleGranterAdapter.js'
|
|
62
|
+
|
|
63
|
+
export class PersistableRoleGranterAdapter extends RoleGranterAdapter {
|
|
64
|
+
/**
|
|
65
|
+
* Create a persistable role granter
|
|
66
|
+
*
|
|
67
|
+
* @param {Object} options
|
|
68
|
+
* @param {Function} [options.load] - Load callback: () => Promise<RoleConfig>|RoleConfig|null
|
|
69
|
+
* @param {Function} [options.persist] - Persist callback: (data: RoleConfig) => Promise<void>|void
|
|
70
|
+
* @param {Object} [options.fixed={}] - Fixed/system configuration (incompressible, never overridden)
|
|
71
|
+
* @param {Object} [options.fixed.role_hierarchy={}] - Fixed role hierarchy
|
|
72
|
+
* @param {Object} [options.fixed.role_permissions={}] - Fixed role permissions (e.g., auth:login)
|
|
73
|
+
* @param {Object} [options.fixed.role_labels={}] - Fixed role labels
|
|
74
|
+
* @param {Object} [options.defaults={}] - Default configuration (fallback)
|
|
75
|
+
* @param {Object} [options.defaults.role_hierarchy={}] - Default role hierarchy
|
|
76
|
+
* @param {Object} [options.defaults.role_permissions={}] - Default role permissions
|
|
77
|
+
* @param {Object} [options.defaults.role_labels={}] - Default role labels
|
|
78
|
+
* @param {string} [options.mergeStrategy='extend'] - How to merge defaults with loaded data
|
|
79
|
+
* - 'extend': Loaded data extends defaults (loaded takes priority)
|
|
80
|
+
* - 'replace': Loaded data replaces defaults entirely
|
|
81
|
+
* - 'defaults-only': Use defaults, ignore loaded (for fallback mode)
|
|
82
|
+
* @param {boolean} [options.autoLoad=true] - Auto-load on first access
|
|
83
|
+
*/
|
|
84
|
+
constructor(options = {}) {
|
|
85
|
+
super()
|
|
86
|
+
|
|
87
|
+
this._loadFn = options.load || null
|
|
88
|
+
this._persistFn = options.persist || null
|
|
89
|
+
this._mergeStrategy = options.mergeStrategy || 'extend'
|
|
90
|
+
this._autoLoad = options.autoLoad ?? true
|
|
91
|
+
|
|
92
|
+
// Fixed (incompressible, always applied on top)
|
|
93
|
+
this._fixed = {
|
|
94
|
+
role_hierarchy: options.fixed?.role_hierarchy || {},
|
|
95
|
+
role_permissions: options.fixed?.role_permissions || {},
|
|
96
|
+
role_labels: options.fixed?.role_labels || {}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Defaults (fallback)
|
|
100
|
+
this._defaults = {
|
|
101
|
+
role_hierarchy: options.defaults?.role_hierarchy || {},
|
|
102
|
+
role_permissions: options.defaults?.role_permissions || {},
|
|
103
|
+
role_labels: options.defaults?.role_labels || {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Current state (starts with defaults)
|
|
107
|
+
this._hierarchy = { ...this._defaults.role_hierarchy }
|
|
108
|
+
this._permissions = { ...this._defaults.role_permissions }
|
|
109
|
+
this._labels = { ...this._defaults.role_labels }
|
|
110
|
+
|
|
111
|
+
// Loading state
|
|
112
|
+
this._loaded = false
|
|
113
|
+
this._loading = null // Promise when loading
|
|
114
|
+
this._dirty = false // Has unsaved changes
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Load configuration from source
|
|
119
|
+
*
|
|
120
|
+
* @returns {Promise<void>}
|
|
121
|
+
*/
|
|
122
|
+
async load() {
|
|
123
|
+
if (!this._loadFn) {
|
|
124
|
+
this._loaded = true
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Prevent concurrent loads
|
|
129
|
+
if (this._loading) {
|
|
130
|
+
return this._loading
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this._loading = this._doLoad()
|
|
134
|
+
try {
|
|
135
|
+
await this._loading
|
|
136
|
+
} finally {
|
|
137
|
+
this._loading = null
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Internal load implementation
|
|
143
|
+
* @private
|
|
144
|
+
*/
|
|
145
|
+
async _doLoad() {
|
|
146
|
+
try {
|
|
147
|
+
const data = await this._loadFn()
|
|
148
|
+
|
|
149
|
+
if (data) {
|
|
150
|
+
this._applyData(data)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this._loaded = true
|
|
154
|
+
this._dirty = false
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.error('[PersistableRoleGranterAdapter] Load failed:', err)
|
|
157
|
+
// Keep defaults on error
|
|
158
|
+
this._loaded = true
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Apply loaded data according to merge strategy
|
|
164
|
+
*
|
|
165
|
+
* @param {Object} data - Loaded data
|
|
166
|
+
* @private
|
|
167
|
+
*/
|
|
168
|
+
_applyData(data) {
|
|
169
|
+
switch (this._mergeStrategy) {
|
|
170
|
+
case 'replace':
|
|
171
|
+
// Loaded data replaces everything
|
|
172
|
+
this._hierarchy = data.role_hierarchy || {}
|
|
173
|
+
this._permissions = data.role_permissions || {}
|
|
174
|
+
this._labels = data.role_labels || {}
|
|
175
|
+
break
|
|
176
|
+
|
|
177
|
+
case 'defaults-only':
|
|
178
|
+
// Ignore loaded data, use defaults
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
case 'extend':
|
|
182
|
+
default:
|
|
183
|
+
// Loaded extends defaults (loaded takes priority)
|
|
184
|
+
this._hierarchy = {
|
|
185
|
+
...this._defaults.role_hierarchy,
|
|
186
|
+
...(data.role_hierarchy || {})
|
|
187
|
+
}
|
|
188
|
+
this._permissions = {
|
|
189
|
+
...this._defaults.role_permissions,
|
|
190
|
+
...(data.role_permissions || {})
|
|
191
|
+
}
|
|
192
|
+
this._labels = {
|
|
193
|
+
...this._defaults.role_labels,
|
|
194
|
+
...(data.role_labels || {})
|
|
195
|
+
}
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Persist current configuration to source
|
|
202
|
+
*
|
|
203
|
+
* @returns {Promise<void>}
|
|
204
|
+
*/
|
|
205
|
+
async persist() {
|
|
206
|
+
if (!this._persistFn) {
|
|
207
|
+
console.warn('[PersistableRoleGranterAdapter] No persist function configured')
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const data = {
|
|
212
|
+
role_hierarchy: this._hierarchy,
|
|
213
|
+
role_permissions: this._permissions,
|
|
214
|
+
role_labels: this._labels
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
await this._persistFn(data)
|
|
219
|
+
this._dirty = false
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.error('[PersistableRoleGranterAdapter] Persist failed:', err)
|
|
222
|
+
throw err
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Ensure data is loaded (auto-load if needed)
|
|
228
|
+
*
|
|
229
|
+
* @returns {Promise<void>}
|
|
230
|
+
* @private
|
|
231
|
+
*/
|
|
232
|
+
async _ensureLoaded() {
|
|
233
|
+
if (!this._loaded && this._autoLoad) {
|
|
234
|
+
await this.load()
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
239
|
+
// RoleGranterAdapter interface
|
|
240
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Get permissions for a role
|
|
244
|
+
*
|
|
245
|
+
* Merges: current permissions + fixed permissions (fixed always wins)
|
|
246
|
+
*
|
|
247
|
+
* @param {string} role - Role name
|
|
248
|
+
* @returns {string[]} Permission patterns
|
|
249
|
+
*/
|
|
250
|
+
getPermissions(role) {
|
|
251
|
+
// Note: This is synchronous. For async sources, call load() first
|
|
252
|
+
// or use autoLoad + await ensureReady() before first use
|
|
253
|
+
const base = this._permissions[role] || []
|
|
254
|
+
const fixed = this._fixed.role_permissions[role] || []
|
|
255
|
+
|
|
256
|
+
// Merge: base + fixed (deduplicated)
|
|
257
|
+
if (fixed.length === 0) return base
|
|
258
|
+
if (base.length === 0) return fixed
|
|
259
|
+
|
|
260
|
+
const merged = [...base]
|
|
261
|
+
for (const perm of fixed) {
|
|
262
|
+
if (!merged.includes(perm)) {
|
|
263
|
+
merged.push(perm)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return merged
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Get all defined roles
|
|
271
|
+
*
|
|
272
|
+
* Includes roles from: current + fixed
|
|
273
|
+
*
|
|
274
|
+
* @returns {string[]} Role names
|
|
275
|
+
*/
|
|
276
|
+
getRoles() {
|
|
277
|
+
const roles = new Set([
|
|
278
|
+
...Object.keys(this._permissions),
|
|
279
|
+
...Object.keys(this._fixed.role_permissions)
|
|
280
|
+
])
|
|
281
|
+
return [...roles]
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Get role hierarchy
|
|
286
|
+
*
|
|
287
|
+
* Merges: current hierarchy + fixed hierarchy
|
|
288
|
+
*
|
|
289
|
+
* @returns {Object} Hierarchy map { role: [inheritedRoles] }
|
|
290
|
+
*/
|
|
291
|
+
getHierarchy() {
|
|
292
|
+
return {
|
|
293
|
+
...this._hierarchy,
|
|
294
|
+
...this._fixed.role_hierarchy
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Get role labels for display
|
|
300
|
+
*
|
|
301
|
+
* Merges: current labels + fixed labels
|
|
302
|
+
*
|
|
303
|
+
* @returns {Object} Labels map { role: label }
|
|
304
|
+
*/
|
|
305
|
+
getLabels() {
|
|
306
|
+
return {
|
|
307
|
+
...this._labels,
|
|
308
|
+
...this._fixed.role_labels
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// getAnonymousRole() inherited from RoleGranterAdapter (returns 'ROLE_ANONYMOUS')
|
|
313
|
+
|
|
314
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
315
|
+
// Role query methods
|
|
316
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Check if a role exists (in current or fixed)
|
|
320
|
+
*
|
|
321
|
+
* @param {string} role - Role name
|
|
322
|
+
* @returns {boolean}
|
|
323
|
+
*/
|
|
324
|
+
roleExists(role) {
|
|
325
|
+
return this._permissions[role] !== undefined ||
|
|
326
|
+
this._fixed.role_permissions[role] !== undefined
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Get complete role object
|
|
331
|
+
*
|
|
332
|
+
* @param {string} role - Role name
|
|
333
|
+
* @returns {RoleData|null} Role data or null if not found
|
|
334
|
+
*/
|
|
335
|
+
getRole(role) {
|
|
336
|
+
if (!this.roleExists(role)) {
|
|
337
|
+
return null
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
name: role,
|
|
341
|
+
label: this._labels[role] || this._fixed.role_labels[role] || role,
|
|
342
|
+
permissions: this.getPermissions(role),
|
|
343
|
+
inherits: this._hierarchy[role] || this._fixed.role_hierarchy[role] || []
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
348
|
+
// Mutation methods
|
|
349
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Create a new role (async, auto-persists)
|
|
353
|
+
*
|
|
354
|
+
* @param {string} name - Role name
|
|
355
|
+
* @param {Object} [options]
|
|
356
|
+
* @param {string} [options.label] - Display label
|
|
357
|
+
* @param {string[]} [options.permissions=[]] - Permission patterns
|
|
358
|
+
* @param {string[]} [options.inherits=[]] - Roles to inherit from
|
|
359
|
+
* @returns {Promise<RoleData>} Created role data
|
|
360
|
+
* @throws {Error} If role already exists
|
|
361
|
+
*/
|
|
362
|
+
async createRole(name, { label, permissions = [], inherits = [] } = {}) {
|
|
363
|
+
if (this.roleExists(name)) {
|
|
364
|
+
throw new Error(`Role '${name}' already exists`)
|
|
365
|
+
}
|
|
366
|
+
this._permissions[name] = [...permissions]
|
|
367
|
+
if (label) this._labels[name] = label
|
|
368
|
+
if (inherits.length > 0) this._hierarchy[name] = [...inherits]
|
|
369
|
+
this._dirty = true
|
|
370
|
+
|
|
371
|
+
if (this._persistFn) {
|
|
372
|
+
await this.persist()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return this.getRole(name)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Update an existing role (async, auto-persists)
|
|
380
|
+
*
|
|
381
|
+
* @param {string} name - Role name
|
|
382
|
+
* @param {Object} [options] - Only provided properties are updated
|
|
383
|
+
* @param {string} [options.label] - Display label
|
|
384
|
+
* @param {string[]} [options.permissions] - Permission patterns
|
|
385
|
+
* @param {string[]} [options.inherits] - Roles to inherit from
|
|
386
|
+
* @returns {Promise<RoleData>} Updated role data
|
|
387
|
+
* @throws {Error} If role doesn't exist
|
|
388
|
+
*/
|
|
389
|
+
async updateRole(name, { label, permissions, inherits } = {}) {
|
|
390
|
+
if (!this.roleExists(name)) {
|
|
391
|
+
throw new Error(`Role '${name}' does not exist`)
|
|
392
|
+
}
|
|
393
|
+
if (permissions !== undefined) this._permissions[name] = [...permissions]
|
|
394
|
+
if (label !== undefined) this._labels[name] = label
|
|
395
|
+
if (inherits !== undefined) this._hierarchy[name] = [...inherits]
|
|
396
|
+
this._dirty = true
|
|
397
|
+
|
|
398
|
+
if (this._persistFn) {
|
|
399
|
+
await this.persist()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return this.getRole(name)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Set permissions for a role
|
|
407
|
+
*
|
|
408
|
+
* @param {string} role - Role name
|
|
409
|
+
* @param {string[]} permissions - Permission patterns
|
|
410
|
+
* @returns {this}
|
|
411
|
+
*/
|
|
412
|
+
setRolePermissions(role, permissions) {
|
|
413
|
+
this._permissions[role] = [...permissions]
|
|
414
|
+
this._dirty = true
|
|
415
|
+
return this
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Add permissions to a role
|
|
420
|
+
*
|
|
421
|
+
* @param {string} role - Role name
|
|
422
|
+
* @param {string[]} permissions - Permissions to add
|
|
423
|
+
* @returns {this}
|
|
424
|
+
*/
|
|
425
|
+
addRolePermissions(role, permissions) {
|
|
426
|
+
const current = this._permissions[role] || []
|
|
427
|
+
const newPerms = permissions.filter(p => !current.includes(p))
|
|
428
|
+
this._permissions[role] = [...current, ...newPerms]
|
|
429
|
+
this._dirty = true
|
|
430
|
+
return this
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Remove permissions from a role
|
|
435
|
+
*
|
|
436
|
+
* @param {string} role - Role name
|
|
437
|
+
* @param {string[]} permissions - Permissions to remove
|
|
438
|
+
* @returns {this}
|
|
439
|
+
*/
|
|
440
|
+
removeRolePermissions(role, permissions) {
|
|
441
|
+
const current = this._permissions[role] || []
|
|
442
|
+
this._permissions[role] = current.filter(p => !permissions.includes(p))
|
|
443
|
+
this._dirty = true
|
|
444
|
+
return this
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Set role hierarchy
|
|
449
|
+
*
|
|
450
|
+
* @param {string} role - Role name
|
|
451
|
+
* @param {string[]} inherits - Roles this role inherits from
|
|
452
|
+
* @returns {this}
|
|
453
|
+
*/
|
|
454
|
+
setRoleHierarchy(role, inherits) {
|
|
455
|
+
this._hierarchy[role] = [...inherits]
|
|
456
|
+
this._dirty = true
|
|
457
|
+
return this
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Set role label
|
|
462
|
+
*
|
|
463
|
+
* @param {string} role - Role name
|
|
464
|
+
* @param {string} label - Display label
|
|
465
|
+
* @returns {this}
|
|
466
|
+
*/
|
|
467
|
+
setRoleLabel(role, label) {
|
|
468
|
+
this._labels[role] = label
|
|
469
|
+
this._dirty = true
|
|
470
|
+
return this
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Delete a role entirely (async, auto-persists)
|
|
475
|
+
*
|
|
476
|
+
* @param {string} name - Role name
|
|
477
|
+
* @returns {Promise<void>}
|
|
478
|
+
* @throws {Error} If role doesn't exist
|
|
479
|
+
*/
|
|
480
|
+
async deleteRole(name) {
|
|
481
|
+
if (!this.roleExists(name)) {
|
|
482
|
+
throw new Error(`Role '${name}' does not exist`)
|
|
483
|
+
}
|
|
484
|
+
delete this._permissions[name]
|
|
485
|
+
delete this._hierarchy[name]
|
|
486
|
+
delete this._labels[name]
|
|
487
|
+
this._dirty = true
|
|
488
|
+
|
|
489
|
+
if (this._persistFn) {
|
|
490
|
+
await this.persist()
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Reset to defaults
|
|
496
|
+
*
|
|
497
|
+
* @returns {this}
|
|
498
|
+
*/
|
|
499
|
+
reset() {
|
|
500
|
+
this._hierarchy = { ...this._defaults.role_hierarchy }
|
|
501
|
+
this._permissions = { ...this._defaults.role_permissions }
|
|
502
|
+
this._labels = { ...this._defaults.role_labels }
|
|
503
|
+
this._dirty = true
|
|
504
|
+
return this
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
508
|
+
// State inspection
|
|
509
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Check if data has been loaded
|
|
513
|
+
* @returns {boolean}
|
|
514
|
+
*/
|
|
515
|
+
get isLoaded() {
|
|
516
|
+
return this._loaded
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Check if there are unsaved changes
|
|
521
|
+
* @returns {boolean}
|
|
522
|
+
*/
|
|
523
|
+
get isDirty() {
|
|
524
|
+
return this._dirty
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Check if adapter supports persistence
|
|
529
|
+
* Returns true if a persist function was provided
|
|
530
|
+
* @returns {boolean}
|
|
531
|
+
*/
|
|
532
|
+
get canPersist() {
|
|
533
|
+
return !!this._persistFn
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Get current configuration as object
|
|
538
|
+
*
|
|
539
|
+
* @returns {Object} Current config
|
|
540
|
+
*/
|
|
541
|
+
toJSON() {
|
|
542
|
+
return {
|
|
543
|
+
role_hierarchy: this._hierarchy,
|
|
544
|
+
role_permissions: this._permissions,
|
|
545
|
+
role_labels: this._labels
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Get a ready promise (resolves when loaded)
|
|
551
|
+
*
|
|
552
|
+
* @returns {Promise<this>}
|
|
553
|
+
*/
|
|
554
|
+
async ensureReady() {
|
|
555
|
+
await this._ensureLoaded()
|
|
556
|
+
return this
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Create a localStorage-backed role granter
|
|
562
|
+
*
|
|
563
|
+
* Unlike async sources, localStorage is synchronous so data is loaded
|
|
564
|
+
* immediately at construction time. No need to call ensureReady().
|
|
565
|
+
*
|
|
566
|
+
* @param {Object} options
|
|
567
|
+
* @param {string} [options.key='qdadm_roles'] - localStorage key
|
|
568
|
+
* @param {Object} [options.fixed] - Fixed/system configuration (incompressible)
|
|
569
|
+
* @param {Object} [options.defaults] - Default configuration
|
|
570
|
+
* @param {string} [options.mergeStrategy] - Merge strategy
|
|
571
|
+
* @returns {PersistableRoleGranterAdapter}
|
|
572
|
+
*/
|
|
573
|
+
export function createLocalStorageRoleGranter(options = {}) {
|
|
574
|
+
const key = options.key || 'qdadm_roles'
|
|
575
|
+
|
|
576
|
+
// Load data synchronously from localStorage (localStorage is sync)
|
|
577
|
+
let initialData = null
|
|
578
|
+
try {
|
|
579
|
+
const stored = localStorage.getItem(key)
|
|
580
|
+
initialData = stored ? JSON.parse(stored) : null
|
|
581
|
+
} catch {
|
|
582
|
+
// Ignore parse errors, will use defaults
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const adapter = new PersistableRoleGranterAdapter({
|
|
586
|
+
load: () => {
|
|
587
|
+
try {
|
|
588
|
+
const stored = localStorage.getItem(key)
|
|
589
|
+
return stored ? JSON.parse(stored) : null
|
|
590
|
+
} catch {
|
|
591
|
+
return null
|
|
592
|
+
}
|
|
593
|
+
},
|
|
594
|
+
persist: (data) => {
|
|
595
|
+
localStorage.setItem(key, JSON.stringify(data))
|
|
596
|
+
},
|
|
597
|
+
fixed: options.fixed,
|
|
598
|
+
defaults: options.defaults,
|
|
599
|
+
mergeStrategy: options.mergeStrategy,
|
|
600
|
+
autoLoad: false // We load synchronously below
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
// Apply initial data immediately (synchronous load)
|
|
604
|
+
if (initialData) {
|
|
605
|
+
adapter._applyData(initialData)
|
|
606
|
+
}
|
|
607
|
+
adapter._loaded = true
|
|
608
|
+
|
|
609
|
+
return adapter
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* @typedef {Object} RoleData
|
|
614
|
+
* @property {string} name - Role name
|
|
615
|
+
* @property {string} label - Display label
|
|
616
|
+
* @property {string[]} permissions - Permission patterns (includes fixed)
|
|
617
|
+
* @property {string[]} inherits - Roles this role inherits from
|
|
618
|
+
*/
|