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
|
@@ -1,44 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useCurrentEntity - Share page entity data with navigation context
|
|
2
|
+
* useCurrentEntity - Share page entity data with navigation context (breadcrumb)
|
|
3
3
|
*
|
|
4
|
-
* When a page loads an entity
|
|
5
|
-
*
|
|
6
|
-
* This avoids a second fetch for breadcrumb display.
|
|
4
|
+
* When a page loads an entity, it calls setBreadcrumbEntity() to share
|
|
5
|
+
* the data with AppLayout for breadcrumb display.
|
|
7
6
|
*
|
|
8
7
|
* Usage in a detail page:
|
|
9
8
|
* ```js
|
|
10
|
-
* const {
|
|
9
|
+
* const { setBreadcrumbEntity } = useCurrentEntity()
|
|
11
10
|
*
|
|
12
11
|
* async function loadProduct() {
|
|
13
12
|
* product.value = await productsManager.get(productId)
|
|
14
|
-
*
|
|
13
|
+
* setBreadcrumbEntity(product.value) // Level 1 (main entity)
|
|
15
14
|
* }
|
|
16
15
|
* ```
|
|
17
16
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* For nested routes with parent/child entities:
|
|
18
|
+
* ```js
|
|
19
|
+
* // Parent page loaded first
|
|
20
|
+
* setBreadcrumbEntity(book, 1) // Level 1: the book
|
|
21
|
+
*
|
|
22
|
+
* // Child page
|
|
23
|
+
* setBreadcrumbEntity(loan, 2) // Level 2: the loan under the book
|
|
24
|
+
* ```
|
|
20
25
|
*/
|
|
21
26
|
import { inject } from 'vue'
|
|
22
27
|
|
|
23
28
|
/**
|
|
24
|
-
* Composable to share
|
|
25
|
-
* @returns {{
|
|
29
|
+
* Composable to share page entity data with breadcrumb
|
|
30
|
+
* @returns {{ setBreadcrumbEntity: (data: object, level?: number) => void }}
|
|
26
31
|
*/
|
|
27
32
|
export function useCurrentEntity() {
|
|
28
|
-
const
|
|
33
|
+
const setBreadcrumbEntityFn = inject('qdadmSetBreadcrumbEntity', null)
|
|
29
34
|
|
|
30
35
|
/**
|
|
31
|
-
* Set
|
|
32
|
-
* Call this after loading an entity to avoid double fetch
|
|
36
|
+
* Set entity data for breadcrumb at a specific level
|
|
33
37
|
* @param {object} data - Entity data
|
|
38
|
+
* @param {number} level - Breadcrumb level (1 = main entity, 2 = child, etc.)
|
|
34
39
|
*/
|
|
35
|
-
function
|
|
36
|
-
if (
|
|
37
|
-
|
|
40
|
+
function setBreadcrumbEntity(data, level = 1) {
|
|
41
|
+
if (setBreadcrumbEntityFn) {
|
|
42
|
+
setBreadcrumbEntityFn(data, level)
|
|
38
43
|
}
|
|
39
44
|
}
|
|
40
45
|
|
|
46
|
+
// Backwards compat alias
|
|
47
|
+
const setCurrentEntity = (data) => setBreadcrumbEntity(data, 1)
|
|
48
|
+
|
|
41
49
|
return {
|
|
42
|
-
|
|
50
|
+
setBreadcrumbEntity,
|
|
51
|
+
setCurrentEntity // deprecated alias
|
|
43
52
|
}
|
|
44
53
|
}
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
import { ref, computed, watch, onMounted, inject, provide } from 'vue'
|
|
66
66
|
import { useBareForm } from './useBareForm'
|
|
67
67
|
import { useHooks } from './useHooks'
|
|
68
|
+
import { useCurrentEntity } from './useCurrentEntity'
|
|
68
69
|
import { deepClone } from '../utils/transformers'
|
|
69
70
|
|
|
70
71
|
export function useForm(options = {}) {
|
|
@@ -93,6 +94,9 @@ export function useForm(options = {}) {
|
|
|
93
94
|
// Get HookRegistry for form:alter hook (optional, may not exist in tests)
|
|
94
95
|
const hooks = useHooks()
|
|
95
96
|
|
|
97
|
+
// Share entity data with navigation context (for breadcrumb)
|
|
98
|
+
const { setCurrentEntity } = useCurrentEntity()
|
|
99
|
+
|
|
96
100
|
// Read config from manager with option overrides
|
|
97
101
|
const routePrefix = options.routePrefix ?? manager.routePrefix
|
|
98
102
|
const entityName = options.entityName ?? manager.label
|
|
@@ -241,6 +245,9 @@ export function useForm(options = {}) {
|
|
|
241
245
|
originalData.value = deepClone(data)
|
|
242
246
|
takeSnapshot()
|
|
243
247
|
|
|
248
|
+
// Share with navigation context for breadcrumb
|
|
249
|
+
setCurrentEntity(data)
|
|
250
|
+
|
|
244
251
|
// Invoke form:alter hooks after data is loaded
|
|
245
252
|
await invokeFormAlterHook()
|
|
246
253
|
|
|
@@ -84,6 +84,7 @@ import { useConfirm } from 'primevue/useconfirm'
|
|
|
84
84
|
import { useDirtyState } from './useDirtyState'
|
|
85
85
|
import { useUnsavedChangesGuard } from './useUnsavedChangesGuard'
|
|
86
86
|
import { useBreadcrumb } from './useBreadcrumb'
|
|
87
|
+
import { useCurrentEntity } from './useCurrentEntity'
|
|
87
88
|
import { registerGuardDialog, unregisterGuardDialog } from './useGuardStore'
|
|
88
89
|
import { deepClone } from '../utils/transformers'
|
|
89
90
|
import { onUnmounted } from 'vue'
|
|
@@ -133,6 +134,9 @@ export function useFormPageBuilder(config = {}) {
|
|
|
133
134
|
// Provide entity context for child components (e.g., SeverityTag auto-discovery)
|
|
134
135
|
provide('mainEntity', entity)
|
|
135
136
|
|
|
137
|
+
// Share entity data with navigation context (for breadcrumb)
|
|
138
|
+
const { setCurrentEntity } = useCurrentEntity()
|
|
139
|
+
|
|
136
140
|
// Read config from manager with option overrides
|
|
137
141
|
const entityName = config.entityName ?? manager.label
|
|
138
142
|
const routePrefix = config.routePrefix ?? manager.routePrefix
|
|
@@ -258,6 +262,9 @@ export function useFormPageBuilder(config = {}) {
|
|
|
258
262
|
originalData.value = deepClone(transformed)
|
|
259
263
|
takeSnapshot()
|
|
260
264
|
|
|
265
|
+
// Share with navigation context for breadcrumb
|
|
266
|
+
setCurrentEntity(transformed)
|
|
267
|
+
|
|
261
268
|
if (onLoadSuccess) {
|
|
262
269
|
await onLoadSuccess(transformed)
|
|
263
270
|
}
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* Path: /books/:bookId/loans/:id/edit meta: { entity: 'loans', parent: { entity: 'books', param: 'bookId' } }
|
|
28
28
|
* → Home > Books > "Le Petit Prince" > Loans > "Loan #abc123"
|
|
29
29
|
*/
|
|
30
|
-
import { ref, computed, watch, inject
|
|
30
|
+
import { ref, computed, watch, inject } from 'vue'
|
|
31
31
|
import { useRoute, useRouter } from 'vue-router'
|
|
32
32
|
import { getSiblingRoutes } from '../module/moduleRegistry.js'
|
|
33
33
|
|
|
@@ -42,6 +42,12 @@ export function useNavContext(options = {}) {
|
|
|
42
42
|
const orchestrator = inject('qdadmOrchestrator', null)
|
|
43
43
|
const homeRouteName = inject('qdadmHomeRoute', null)
|
|
44
44
|
|
|
45
|
+
// Breadcrumb entity data - multi-level Map from AppLayout
|
|
46
|
+
// Updated by pages via setBreadcrumbEntity(data, level)
|
|
47
|
+
// Can be passed directly (for layout component that provides AND uses breadcrumb)
|
|
48
|
+
// or injected from parent (for child pages)
|
|
49
|
+
const breadcrumbEntities = options.breadcrumbEntities ?? inject('qdadmBreadcrumbEntities', null)
|
|
50
|
+
|
|
45
51
|
// Entity data cache
|
|
46
52
|
const entityDataCache = ref(new Map())
|
|
47
53
|
|
|
@@ -215,34 +221,42 @@ export function useNavContext(options = {}) {
|
|
|
215
221
|
*
|
|
216
222
|
* For PARENT items: always fetches from manager
|
|
217
223
|
*/
|
|
218
|
-
// Watch navChain and
|
|
219
|
-
|
|
220
|
-
watch
|
|
224
|
+
// Watch navChain and breadcrumbEntities to populate chainData
|
|
225
|
+
// breadcrumbEntities is a ref to Map: level -> entityData (set by pages via setBreadcrumbEntity)
|
|
226
|
+
// Note: watch ref directly, not () => ref.value, for proper reactivity tracking
|
|
227
|
+
watch([navChain, breadcrumbEntities], async ([chain, entitiesMap]) => {
|
|
221
228
|
// Build new Map (reassignment triggers Vue reactivity, Map.set() doesn't)
|
|
222
229
|
const newChainData = new Map()
|
|
223
230
|
|
|
231
|
+
// Count item segments to determine their level (1-based)
|
|
232
|
+
let itemLevel = 0
|
|
233
|
+
|
|
224
234
|
for (let i = 0; i < chain.length; i++) {
|
|
225
235
|
const segment = chain[i]
|
|
226
236
|
if (segment.type !== 'item') continue
|
|
227
237
|
|
|
238
|
+
itemLevel++
|
|
228
239
|
const isLastItem = !chain.slice(i + 1).some(s => s.type === 'item')
|
|
229
240
|
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
// If no externalData, breadcrumb will show "..." until page provides it
|
|
241
|
+
// Check if page provided data for this level via setBreadcrumbEntity
|
|
242
|
+
const providedData = entitiesMap?.get(itemLevel)
|
|
243
|
+
if (providedData) {
|
|
244
|
+
newChainData.set(i, providedData)
|
|
236
245
|
continue
|
|
237
246
|
}
|
|
238
247
|
|
|
239
|
-
// For
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
248
|
+
// For items without provided data:
|
|
249
|
+
// - Last item: show "..." (page should call setBreadcrumbEntity)
|
|
250
|
+
// - Parent items: fetch from manager
|
|
251
|
+
if (!isLastItem) {
|
|
252
|
+
try {
|
|
253
|
+
const data = await segment.manager.get(segment.id)
|
|
254
|
+
newChainData.set(i, data)
|
|
255
|
+
} catch (e) {
|
|
256
|
+
console.warn(`[useNavContext] Failed to fetch ${segment.entity}:${segment.id}`, e)
|
|
257
|
+
}
|
|
245
258
|
}
|
|
259
|
+
// Last item without data will show "..." in breadcrumb
|
|
246
260
|
}
|
|
247
261
|
|
|
248
262
|
// Assign new Map to trigger reactivity
|
package/src/core/index.js
CHANGED
|
@@ -40,9 +40,8 @@ export class AuthCollector extends Collector {
|
|
|
40
40
|
this._ctx = null
|
|
41
41
|
this._signalCleanups = []
|
|
42
42
|
// Activity tracking for login/logout events
|
|
43
|
-
//
|
|
43
|
+
// Events auto-expire after TTL (no max limit)
|
|
44
44
|
this._recentEvents = [] // Array of { type: 'login'|'logout', timestamp: Date, seen: boolean }
|
|
45
|
-
this._maxEvents = 5
|
|
46
45
|
this._eventTtl = options.eventTtl ?? 60000 // Events expire after 60s by default
|
|
47
46
|
this._expiryTimer = null
|
|
48
47
|
}
|
|
@@ -97,6 +96,11 @@ export class AuthCollector extends Collector {
|
|
|
97
96
|
this._addEvent('impersonate-stop', payload)
|
|
98
97
|
})
|
|
99
98
|
this._signalCleanups.push(impersonateStopCleanup)
|
|
99
|
+
|
|
100
|
+
const loginErrorCleanup = signals.on('auth:login:error', (payload) => {
|
|
101
|
+
this._addEvent('login-error', payload)
|
|
102
|
+
})
|
|
103
|
+
this._signalCleanups.push(loginErrorCleanup)
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
/**
|
|
@@ -113,23 +117,33 @@ export class AuthCollector extends Collector {
|
|
|
113
117
|
seen: false,
|
|
114
118
|
data
|
|
115
119
|
})
|
|
116
|
-
//
|
|
117
|
-
if (this._recentEvents.length > this._maxEvents) {
|
|
118
|
-
this._recentEvents.pop()
|
|
119
|
-
}
|
|
120
|
+
// Events auto-expire via TTL, no max limit
|
|
120
121
|
this._scheduleExpiry()
|
|
121
122
|
this.notifyChange()
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
/**
|
|
125
|
-
* Schedule event expiry check
|
|
126
|
+
* Schedule event expiry check based on oldest event
|
|
126
127
|
* @private
|
|
127
128
|
*/
|
|
128
129
|
_scheduleExpiry() {
|
|
129
|
-
|
|
130
|
+
// Clear existing timer
|
|
131
|
+
if (this._expiryTimer) {
|
|
132
|
+
clearTimeout(this._expiryTimer)
|
|
133
|
+
this._expiryTimer = null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (this._recentEvents.length === 0) return
|
|
137
|
+
|
|
138
|
+
// Find the oldest event and calculate when it expires
|
|
139
|
+
const now = Date.now()
|
|
140
|
+
const oldest = this._recentEvents[this._recentEvents.length - 1]
|
|
141
|
+
const age = now - oldest.timestamp.getTime()
|
|
142
|
+
const delay = Math.max(100, this._eventTtl - age) // At least 100ms
|
|
143
|
+
|
|
130
144
|
this._expiryTimer = setTimeout(() => {
|
|
131
145
|
this._expireOldEvents()
|
|
132
|
-
},
|
|
146
|
+
}, delay)
|
|
133
147
|
}
|
|
134
148
|
|
|
135
149
|
/**
|
|
@@ -225,32 +239,71 @@ export class AuthCollector extends Collector {
|
|
|
225
239
|
* @returns {Array<object>} Auth info as entries
|
|
226
240
|
*/
|
|
227
241
|
getEntries() {
|
|
228
|
-
|
|
242
|
+
// Always get fresh authAdapter from ctx (may have updated state)
|
|
243
|
+
const authAdapter = this._ctx?.auth || this._ctx?.authAdapter || this._authAdapter
|
|
244
|
+
if (!authAdapter) {
|
|
229
245
|
return [{ type: 'status', message: 'No auth adapter configured' }]
|
|
230
246
|
}
|
|
231
247
|
|
|
232
248
|
const entries = []
|
|
233
249
|
|
|
234
|
-
// User info
|
|
250
|
+
// User info with effective permissions
|
|
235
251
|
try {
|
|
236
|
-
const user =
|
|
252
|
+
const user = authAdapter.getUser?.()
|
|
253
|
+
// Always get fresh security (created after collector install)
|
|
254
|
+
const securityChecker = this._ctx?.security || this._securityChecker
|
|
255
|
+
|
|
237
256
|
if (user) {
|
|
257
|
+
// Check if impersonating (use fresh authAdapter)
|
|
258
|
+
const isImpersonating = authAdapter.isImpersonating?.() || false
|
|
259
|
+
const originalUser = isImpersonating ? authAdapter.getOriginalUser?.() : null
|
|
260
|
+
|
|
261
|
+
// Current User = the real logged in user (original when impersonating)
|
|
262
|
+
const realUser = originalUser || user
|
|
263
|
+
const realRoles = this._normalizeRoles(realUser.roles || realUser.role)
|
|
264
|
+
const realPermissions = this._getEffectivePermissions(securityChecker, realRoles)
|
|
265
|
+
|
|
238
266
|
entries.push({
|
|
239
267
|
type: 'user',
|
|
240
268
|
label: 'Current User',
|
|
241
269
|
data: {
|
|
242
|
-
id:
|
|
243
|
-
username:
|
|
244
|
-
email:
|
|
245
|
-
roles:
|
|
246
|
-
|
|
270
|
+
id: realUser.id || realUser.userId,
|
|
271
|
+
username: realUser.username || realUser.name || realUser.email,
|
|
272
|
+
email: realUser.email,
|
|
273
|
+
roles: realRoles,
|
|
274
|
+
permissions: realPermissions
|
|
247
275
|
}
|
|
248
276
|
})
|
|
277
|
+
|
|
278
|
+
// Impersonated User (when active) - shown separately with type 'impersonated'
|
|
279
|
+
if (isImpersonating) {
|
|
280
|
+
const impersonatedRoles = this._normalizeRoles(user.roles || user.role)
|
|
281
|
+
const impersonatedPermissions = this._getEffectivePermissions(securityChecker, impersonatedRoles)
|
|
282
|
+
|
|
283
|
+
entries.push({
|
|
284
|
+
type: 'impersonated',
|
|
285
|
+
label: 'Impersonated User',
|
|
286
|
+
data: {
|
|
287
|
+
id: user.id || user.userId,
|
|
288
|
+
username: user.username || user.name || user.email,
|
|
289
|
+
roles: impersonatedRoles,
|
|
290
|
+
permissions: impersonatedPermissions // These are the ACTIVE permissions!
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
}
|
|
249
294
|
} else {
|
|
295
|
+
// Show anonymous role when not authenticated
|
|
296
|
+
const anonymousRole = securityChecker?.roleGranter?.getAnonymousRole?.() || 'ROLE_ANONYMOUS'
|
|
297
|
+
const effectivePermissions = this._getEffectivePermissions(securityChecker, [anonymousRole])
|
|
298
|
+
|
|
250
299
|
entries.push({
|
|
251
|
-
type: '
|
|
252
|
-
label: '
|
|
253
|
-
|
|
300
|
+
type: 'user',
|
|
301
|
+
label: 'Anonymous',
|
|
302
|
+
data: {
|
|
303
|
+
role: anonymousRole,
|
|
304
|
+
authenticated: false,
|
|
305
|
+
permissions: effectivePermissions
|
|
306
|
+
}
|
|
254
307
|
})
|
|
255
308
|
}
|
|
256
309
|
} catch (e) {
|
|
@@ -263,7 +316,7 @@ export class AuthCollector extends Collector {
|
|
|
263
316
|
|
|
264
317
|
// Token info
|
|
265
318
|
try {
|
|
266
|
-
const token =
|
|
319
|
+
const token = authAdapter.getToken?.()
|
|
267
320
|
if (token) {
|
|
268
321
|
const decoded = this._decodeToken(token)
|
|
269
322
|
entries.push({
|
|
@@ -281,14 +334,14 @@ export class AuthCollector extends Collector {
|
|
|
281
334
|
// Token not available or decode failed
|
|
282
335
|
}
|
|
283
336
|
|
|
284
|
-
//
|
|
337
|
+
// User's effective permissions (from authAdapter)
|
|
285
338
|
try {
|
|
286
|
-
const
|
|
287
|
-
if (
|
|
339
|
+
const userPermissions = authAdapter.getPermissions?.()
|
|
340
|
+
if (userPermissions && userPermissions.length > 0) {
|
|
288
341
|
entries.push({
|
|
289
|
-
type: 'permissions',
|
|
290
|
-
label: 'Permissions',
|
|
291
|
-
data:
|
|
342
|
+
type: 'user-permissions',
|
|
343
|
+
label: 'User Permissions',
|
|
344
|
+
data: userPermissions
|
|
292
345
|
})
|
|
293
346
|
}
|
|
294
347
|
} catch (e) {
|
|
@@ -318,23 +371,112 @@ export class AuthCollector extends Collector {
|
|
|
318
371
|
// Security checker not available
|
|
319
372
|
}
|
|
320
373
|
|
|
374
|
+
// Registered permissions from PermissionRegistry (flat list of all permission keys)
|
|
375
|
+
try {
|
|
376
|
+
// Try multiple paths to find permissionRegistry
|
|
377
|
+
const permissionRegistry = this._ctx?.permissionRegistry
|
|
378
|
+
|| this._ctx?._kernel?.permissionRegistry
|
|
379
|
+
|| this._ctx?.orchestrator?.kernel?.permissionRegistry
|
|
380
|
+
if (permissionRegistry && permissionRegistry.size > 0) {
|
|
381
|
+
// Get flat list of permission keys (e.g., ['entity:books:read', 'auth:impersonate'])
|
|
382
|
+
const permissions = permissionRegistry.getKeys()
|
|
383
|
+
entries.push({
|
|
384
|
+
type: 'permissions',
|
|
385
|
+
label: 'Permissions',
|
|
386
|
+
data: permissions
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
} catch (e) {
|
|
390
|
+
// Permission registry not available
|
|
391
|
+
console.warn('[AuthCollector] Error accessing permissionRegistry:', e)
|
|
392
|
+
}
|
|
393
|
+
|
|
321
394
|
// Adapter info
|
|
322
395
|
entries.push({
|
|
323
396
|
type: 'adapter',
|
|
324
397
|
label: 'Auth Adapter',
|
|
325
398
|
data: {
|
|
326
|
-
type:
|
|
327
|
-
hasUser: !!
|
|
328
|
-
hasToken: !!
|
|
329
|
-
hasPermissions: !!
|
|
330
|
-
hasLogin: !!
|
|
331
|
-
hasLogout: !!
|
|
399
|
+
type: authAdapter.constructor?.name || 'Unknown',
|
|
400
|
+
hasUser: !!authAdapter.getUser,
|
|
401
|
+
hasToken: !!authAdapter.getToken,
|
|
402
|
+
hasPermissions: !!authAdapter.getPermissions,
|
|
403
|
+
hasLogin: !!authAdapter.login,
|
|
404
|
+
hasLogout: !!authAdapter.logout,
|
|
405
|
+
hasImpersonate: !!authAdapter.impersonate,
|
|
406
|
+
isImpersonating: authAdapter.isImpersonating?.() || false
|
|
332
407
|
}
|
|
333
408
|
})
|
|
334
409
|
|
|
335
410
|
return entries
|
|
336
411
|
}
|
|
337
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Get effective permissions for a set of roles
|
|
415
|
+
* Uses SecurityChecker.getUserPermissions() which resolves hierarchy
|
|
416
|
+
*
|
|
417
|
+
* @param {object} securityChecker - SecurityChecker instance
|
|
418
|
+
* @param {string[]} roles - User's roles
|
|
419
|
+
* @returns {string[]} Effective permissions (deduplicated, sorted)
|
|
420
|
+
* @private
|
|
421
|
+
*/
|
|
422
|
+
_getEffectivePermissions(securityChecker, roles) {
|
|
423
|
+
if (!securityChecker || !roles || roles.length === 0) {
|
|
424
|
+
return []
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
// Use SecurityChecker.getUserPermissions with a mock user
|
|
429
|
+
if (securityChecker.getUserPermissions) {
|
|
430
|
+
const mockUser = { roles }
|
|
431
|
+
const perms = securityChecker.getUserPermissions(mockUser)
|
|
432
|
+
return [...new Set(perms)].sort()
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Fallback: direct access to roleGranter
|
|
436
|
+
const roleGranter = securityChecker.roleGranter
|
|
437
|
+
if (!roleGranter) return []
|
|
438
|
+
|
|
439
|
+
const permissions = new Set()
|
|
440
|
+
for (const role of roles) {
|
|
441
|
+
// Get reachable roles (includes inherited)
|
|
442
|
+
const reachable = securityChecker.roleHierarchy?.getReachableRoles?.(role) || [role]
|
|
443
|
+
for (const r of reachable) {
|
|
444
|
+
const rolePerms = roleGranter.getPermissions?.(r) || []
|
|
445
|
+
for (const perm of rolePerms) {
|
|
446
|
+
permissions.add(perm)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return [...permissions].sort()
|
|
452
|
+
} catch (e) {
|
|
453
|
+
console.warn('[AuthCollector] Error getting effective permissions:', e)
|
|
454
|
+
return []
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Normalize roles to array with ROLE_ prefix
|
|
460
|
+
* Supports: 'admin' → ['ROLE_ADMIN'], ['user'] → ['ROLE_USER'], ['ROLE_ADMIN'] → ['ROLE_ADMIN']
|
|
461
|
+
*
|
|
462
|
+
* @param {string|string[]} roles - Role(s) to normalize
|
|
463
|
+
* @returns {string[]} Normalized roles array
|
|
464
|
+
* @private
|
|
465
|
+
*/
|
|
466
|
+
_normalizeRoles(roles) {
|
|
467
|
+
if (!roles) return []
|
|
468
|
+
|
|
469
|
+
// Convert to array
|
|
470
|
+
const arr = Array.isArray(roles) ? roles : [roles]
|
|
471
|
+
|
|
472
|
+
// Add ROLE_ prefix if missing and uppercase
|
|
473
|
+
return arr.map(role => {
|
|
474
|
+
if (!role) return null
|
|
475
|
+
const upper = role.toUpperCase()
|
|
476
|
+
return upper.startsWith('ROLE_') ? upper : `ROLE_${upper}`
|
|
477
|
+
}).filter(Boolean)
|
|
478
|
+
}
|
|
479
|
+
|
|
338
480
|
/**
|
|
339
481
|
* Sanitize user object - remove sensitive fields
|
|
340
482
|
* @param {object} user - User object
|
package/src/debug/Collector.js
CHANGED
|
@@ -55,6 +55,7 @@ export class Collector {
|
|
|
55
55
|
this._ctx = null
|
|
56
56
|
this._seenCount = 0 // Number of entries that have been "seen"
|
|
57
57
|
this._bridge = null // Set by DebugBridge when added
|
|
58
|
+
this._notifyCallbacks = [] // Direct notification subscribers
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/**
|
|
@@ -120,8 +121,8 @@ export class Collector {
|
|
|
120
121
|
this._seenCount--
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
|
-
// Notify bridge
|
|
124
|
-
this.
|
|
124
|
+
// Notify bridge and direct subscribers
|
|
125
|
+
this.notifyChange()
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
/**
|
|
@@ -130,6 +131,27 @@ export class Collector {
|
|
|
130
131
|
*/
|
|
131
132
|
notifyChange() {
|
|
132
133
|
this._bridge?.notify()
|
|
134
|
+
// Call direct subscribers
|
|
135
|
+
for (const cb of this._notifyCallbacks) {
|
|
136
|
+
try {
|
|
137
|
+
cb()
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.warn('[Collector] Notify callback error:', e)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Subscribe to change notifications
|
|
146
|
+
* @param {Function} callback - Called when collector state changes
|
|
147
|
+
* @returns {Function} Unsubscribe function
|
|
148
|
+
*/
|
|
149
|
+
onNotify(callback) {
|
|
150
|
+
this._notifyCallbacks.push(callback)
|
|
151
|
+
return () => {
|
|
152
|
+
const idx = this._notifyCallbacks.indexOf(callback)
|
|
153
|
+
if (idx >= 0) this._notifyCallbacks.splice(idx, 1)
|
|
154
|
+
}
|
|
133
155
|
}
|
|
134
156
|
|
|
135
157
|
/**
|
|
@@ -196,6 +196,13 @@ export class EntitiesCollector extends Collector {
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
// Sort: system entities first, then alphabetically
|
|
200
|
+
entries.sort((a, b) => {
|
|
201
|
+
if (a.system && !b.system) return -1
|
|
202
|
+
if (!a.system && b.system) return 1
|
|
203
|
+
return (a.name || '').localeCompare(b.name || '')
|
|
204
|
+
})
|
|
205
|
+
|
|
199
206
|
return entries
|
|
200
207
|
}
|
|
201
208
|
|
|
@@ -212,6 +219,7 @@ export class EntitiesCollector extends Collector {
|
|
|
212
219
|
|
|
213
220
|
return {
|
|
214
221
|
name,
|
|
222
|
+
system: manager.system ?? false,
|
|
215
223
|
hasActivity: this._activeEntities.has(name),
|
|
216
224
|
label: manager.label,
|
|
217
225
|
labelPlural: manager.labelPlural,
|
|
@@ -28,6 +28,13 @@ export class SignalCollector extends Collector {
|
|
|
28
28
|
*/
|
|
29
29
|
static name = 'signals'
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Signals to skip recording (internal kernel signals with non-serializable data)
|
|
33
|
+
* @type {Set<string>}
|
|
34
|
+
* @private
|
|
35
|
+
*/
|
|
36
|
+
static _skipSignals = new Set(['kernel:ready', 'kernel:shutdown'])
|
|
37
|
+
|
|
31
38
|
/**
|
|
32
39
|
* Internal install - subscribe to all signals
|
|
33
40
|
*
|
|
@@ -45,14 +52,65 @@ export class SignalCollector extends Collector {
|
|
|
45
52
|
// QuarKernel supports wildcards with the configured delimiter (:)
|
|
46
53
|
// '**' matches all signals including multi-segment (entity:data-invalidate)
|
|
47
54
|
this._unsubscribe = ctx.signals.on('**', (event) => {
|
|
55
|
+
// Skip internal signals with non-serializable data (kernel, orchestrator)
|
|
56
|
+
if (SignalCollector._skipSignals.has(event.name)) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Sanitize data to avoid cyclic references
|
|
61
|
+
const data = this._sanitizeData(event.data)
|
|
62
|
+
|
|
48
63
|
this.record({
|
|
49
64
|
name: event.name,
|
|
50
|
-
data
|
|
51
|
-
source:
|
|
65
|
+
data,
|
|
66
|
+
source: data?.source ?? null
|
|
52
67
|
})
|
|
53
68
|
})
|
|
54
69
|
}
|
|
55
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Sanitize event data to remove non-serializable objects
|
|
73
|
+
*
|
|
74
|
+
* Handles cases where data contains references to Kernel, Orchestrator,
|
|
75
|
+
* or other complex objects that would cause cyclic reference errors.
|
|
76
|
+
*
|
|
77
|
+
* @param {*} data - Raw event data
|
|
78
|
+
* @returns {*} Sanitized data safe for recording
|
|
79
|
+
* @private
|
|
80
|
+
*/
|
|
81
|
+
_sanitizeData(data) {
|
|
82
|
+
if (data === null || data === undefined) return data
|
|
83
|
+
if (typeof data !== 'object') return data
|
|
84
|
+
|
|
85
|
+
// Try to create a simple clone, fallback to description if cyclic
|
|
86
|
+
try {
|
|
87
|
+
// Quick check for obviously problematic properties
|
|
88
|
+
if (data.kernel || data.orchestrator || data._kernel) {
|
|
89
|
+
// Extract only safe properties
|
|
90
|
+
const safe = {}
|
|
91
|
+
for (const [key, value] of Object.entries(data)) {
|
|
92
|
+
if (key !== 'kernel' && key !== 'orchestrator' && key !== '_kernel') {
|
|
93
|
+
if (typeof value !== 'object' || value === null) {
|
|
94
|
+
safe[key] = value
|
|
95
|
+
} else if (Array.isArray(value)) {
|
|
96
|
+
safe[key] = `[Array(${value.length})]`
|
|
97
|
+
} else {
|
|
98
|
+
safe[key] = '[Object]'
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return safe
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// For other objects, try JSON roundtrip to detect cycles
|
|
106
|
+
JSON.stringify(data)
|
|
107
|
+
return data
|
|
108
|
+
} catch {
|
|
109
|
+
// If serialization fails, return a safe representation
|
|
110
|
+
return { _type: 'unserializable', keys: Object.keys(data) }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
56
114
|
/**
|
|
57
115
|
* Internal uninstall - cleanup subscription
|
|
58
116
|
* @protected
|