@quazardous/qdadm 2.23.0 → 2.25.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/components/item/FieldGroups.vue +44 -3
- package/src/components/show/ShowPage.vue +32 -3
- package/src/entity/EntityManager.ts +2 -1
- package/src/entity/EntityManager.types.ts +9 -0
- package/src/entity/auth/SecurityChecker.ts +21 -4
- package/src/kernel/Kernel.registries.ts +2 -1
- package/src/kernel/Kernel.routing.ts +44 -3
- package/src/kernel/Kernel.types.ts +5 -0
- package/src/security/RolesManager.ts +22 -2
package/package.json
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* </FieldGroups>
|
|
21
21
|
* ```
|
|
22
22
|
*/
|
|
23
|
-
import { computed, type PropType } from 'vue'
|
|
23
|
+
import { computed, ref, watch, type PropType } from 'vue'
|
|
24
24
|
import Tabs from 'primevue/tabs'
|
|
25
25
|
import TabList from 'primevue/tablist'
|
|
26
26
|
import Tab from 'primevue/tab'
|
|
@@ -84,8 +84,21 @@ const props = defineProps({
|
|
|
84
84
|
type: String as PropType<LayoutMode>,
|
|
85
85
|
default: 'sections',
|
|
86
86
|
},
|
|
87
|
+
/**
|
|
88
|
+
* The active group (`tabs`: a group name) or the open ones (`accordion`:
|
|
89
|
+
* names), for `v-model:active` (#2435). Optional: without it the component
|
|
90
|
+
* keeps its own, starting on the first group.
|
|
91
|
+
*/
|
|
92
|
+
active: {
|
|
93
|
+
type: [String, Array] as PropType<string | string[] | null>,
|
|
94
|
+
default: undefined,
|
|
95
|
+
},
|
|
87
96
|
})
|
|
88
97
|
|
|
98
|
+
const emit = defineEmits<{
|
|
99
|
+
'update:active': [value: string | string[]]
|
|
100
|
+
}>()
|
|
101
|
+
|
|
89
102
|
// Expose slots for field rendering
|
|
90
103
|
defineSlots<{
|
|
91
104
|
field: (props: { field: BaseFieldConfig; value: unknown; groupName: string }) => unknown
|
|
@@ -105,6 +118,34 @@ const displayGroups = computed(() => {
|
|
|
105
118
|
})
|
|
106
119
|
})
|
|
107
120
|
|
|
121
|
+
// The active tab / open panels are state, not a constant: a re-render must not
|
|
122
|
+
// send the user back to the first group (#2435). An app owning it passes
|
|
123
|
+
// v-model:active; otherwise this is the only copy.
|
|
124
|
+
const localActive = ref<string | string[] | null | undefined>(props.active)
|
|
125
|
+
watch(
|
|
126
|
+
() => props.active,
|
|
127
|
+
(value) => {
|
|
128
|
+
if (value !== undefined) localActive.value = value
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
const firstGroupName = () => displayGroups.value[0]?.name || '0'
|
|
132
|
+
const activeTab = computed(() => (typeof localActive.value === 'string' ? localActive.value : firstGroupName()))
|
|
133
|
+
const openPanels = computed(() => {
|
|
134
|
+
const value = localActive.value
|
|
135
|
+
if (Array.isArray(value)) return value
|
|
136
|
+
return typeof value === 'string' ? [value] : [firstGroupName()]
|
|
137
|
+
})
|
|
138
|
+
function setActive(value: string | string[]) {
|
|
139
|
+
localActive.value = value
|
|
140
|
+
emit('update:active', value)
|
|
141
|
+
}
|
|
142
|
+
function onTabChange(value: string | number) {
|
|
143
|
+
setActive(String(value))
|
|
144
|
+
}
|
|
145
|
+
function onPanelsChange(value: unknown) {
|
|
146
|
+
setActive(Array.isArray(value) ? value.map(String) : value == null ? [] : [String(value)])
|
|
147
|
+
}
|
|
148
|
+
|
|
108
149
|
// Get value from data
|
|
109
150
|
function getValue(fieldName: string): unknown {
|
|
110
151
|
return props.data?.[fieldName]
|
|
@@ -227,7 +268,7 @@ function getBadgeValue(group: FieldGroup): string | number | null {
|
|
|
227
268
|
|
|
228
269
|
<!-- Tabs layout -->
|
|
229
270
|
<template v-else-if="layout === 'tabs'">
|
|
230
|
-
<Tabs :value="
|
|
271
|
+
<Tabs :value="activeTab" @update:value="onTabChange">
|
|
231
272
|
<TabList>
|
|
232
273
|
<Tab
|
|
233
274
|
v-for="group in displayGroups"
|
|
@@ -275,7 +316,7 @@ function getBadgeValue(group: FieldGroup): string | number | null {
|
|
|
275
316
|
|
|
276
317
|
<!-- Accordion layout -->
|
|
277
318
|
<template v-else-if="layout === 'accordion'">
|
|
278
|
-
<Accordion :value="
|
|
319
|
+
<Accordion :value="openPanels" multiple @update:value="onPanelsChange">
|
|
279
320
|
<AccordionPanel
|
|
280
321
|
v-for="group in displayGroups"
|
|
281
322
|
:key="group.name"
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
* - #fields: Field content (required) - renders in right column (or full width if no media)
|
|
36
36
|
* - #footer: Custom footer (replaces ShowActions if provided)
|
|
37
37
|
* - #error: Custom error display
|
|
38
|
-
* - #loading: Custom
|
|
38
|
+
* - #loading: Custom display for the first load. A reload of a record already
|
|
39
|
+
* on screen keeps it there, marked busy (#2435).
|
|
39
40
|
*/
|
|
40
41
|
import { computed, type PropType } from 'vue'
|
|
41
42
|
import PageHeader from '../layout/PageHeader.vue'
|
|
@@ -203,8 +204,11 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
|
|
|
203
204
|
<!-- Toolbar slot (between header and content) -->
|
|
204
205
|
<slot name="toolbar" />
|
|
205
206
|
|
|
206
|
-
<!--
|
|
207
|
-
|
|
207
|
+
<!-- First load only. A reload of a record already shown keeps the content
|
|
208
|
+
mounted (#2435): unmounting it threw away the active tab, open panels,
|
|
209
|
+
scroll and any local state — on every action reload and every live
|
|
210
|
+
update of the record. -->
|
|
211
|
+
<template v-if="loading && !data">
|
|
208
212
|
<slot name="loading">
|
|
209
213
|
<div class="loading-state">
|
|
210
214
|
<i class="pi pi-spin pi-spinner" style="font-size: 2rem"></i>
|
|
@@ -223,6 +227,7 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
|
|
|
223
227
|
|
|
224
228
|
<!-- Content -->
|
|
225
229
|
<template v-else>
|
|
230
|
+
<div v-if="loading" class="show-refreshing" role="progressbar" aria-label="Refreshing" />
|
|
226
231
|
<!-- Single content body; Card wrapper is conditional (#1193) -->
|
|
227
232
|
<CardShell :card="cardWrapper">
|
|
228
233
|
<!-- Grid layout with optional media zone -->
|
|
@@ -230,6 +235,7 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
|
|
|
230
235
|
class="show-content"
|
|
231
236
|
:class="{ 'show-content--with-media': slots.media }"
|
|
232
237
|
:style="slots.media ? { '--media-width': mediaWidth } : {}"
|
|
238
|
+
:aria-busy="loading ? 'true' : undefined"
|
|
233
239
|
>
|
|
234
240
|
<!-- Media zone (optional) -->
|
|
235
241
|
<div v-if="slots.media" class="show-media">
|
|
@@ -304,6 +310,29 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
|
|
|
304
310
|
padding: 3rem;
|
|
305
311
|
}
|
|
306
312
|
|
|
313
|
+
/* A record being refreshed stays on screen: a thin bar says so (#2435). */
|
|
314
|
+
.show-refreshing {
|
|
315
|
+
height: 3px;
|
|
316
|
+
margin-bottom: -1rem; /* inside the page gap: the content does not move */
|
|
317
|
+
background: linear-gradient(90deg, transparent, var(--p-primary-color, #3b82f6), transparent);
|
|
318
|
+
background-size: 40% 100%;
|
|
319
|
+
background-repeat: no-repeat;
|
|
320
|
+
animation: show-refreshing 1.1s ease-in-out infinite;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
@keyframes show-refreshing {
|
|
324
|
+
from { background-position: -40% 0; }
|
|
325
|
+
to { background-position: 140% 0; }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
@media (prefers-reduced-motion: reduce) {
|
|
329
|
+
.show-refreshing {
|
|
330
|
+
animation: none;
|
|
331
|
+
background: var(--p-primary-color, #3b82f6);
|
|
332
|
+
opacity: 0.4;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
307
336
|
.show-error-message {
|
|
308
337
|
margin: 1rem 0;
|
|
309
338
|
}
|
|
@@ -654,7 +654,8 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
654
654
|
}
|
|
655
655
|
|
|
656
656
|
/**
|
|
657
|
-
*
|
|
657
|
+
* Whether this entity is one the framework provides (roles, users). Owned
|
|
658
|
+
* by qdadm — see `EntityManagerOptions.system`.
|
|
658
659
|
*/
|
|
659
660
|
get system(): boolean {
|
|
660
661
|
return this._system
|
|
@@ -271,6 +271,15 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
|
|
|
271
271
|
* drop the stale cache but leave the screen alone until the user asks.
|
|
272
272
|
*/
|
|
273
273
|
live?: LiveEntityPolicy
|
|
274
|
+
/**
|
|
275
|
+
* Marks an entity the framework provides (`roles`, `users`). Owned by
|
|
276
|
+
* qdadm: set by its system managers (`RolesManager`, `UsersManager`), not
|
|
277
|
+
* by applications. Behaviour may be attached to it in any release.
|
|
278
|
+
*
|
|
279
|
+
* To keep a system entity on your own storage, use its manager with your
|
|
280
|
+
* storage — `new RolesManager({ storage })` — rather than setting the flag
|
|
281
|
+
* on a plain entity.
|
|
282
|
+
*/
|
|
274
283
|
system?: boolean
|
|
275
284
|
scopeWhitelist?: string[] | null
|
|
276
285
|
isOwn?: ((record: T, user: AuthUser) => boolean) | null
|
|
@@ -26,8 +26,8 @@ export interface GrantJudgeContext {
|
|
|
26
26
|
*
|
|
27
27
|
* `install` runs while the Kernel is still being built, before modules
|
|
28
28
|
* register their entities, so `getKeys()` called here returns only the
|
|
29
|
-
* framework's own namespaces. Read the keys when you
|
|
30
|
-
*
|
|
29
|
+
* framework's own namespaces. Read the keys when you fetch (on `kernel:ready`
|
|
30
|
+
* or `auth:login`), not in `install`.
|
|
31
31
|
*/
|
|
32
32
|
permissionRegistry?: { getKeys(): string[] }
|
|
33
33
|
}
|
|
@@ -44,12 +44,29 @@ export interface GrantJudgeContext {
|
|
|
44
44
|
* built-in judgement, which stays the default.
|
|
45
45
|
* - A judge that THROWS denies — see `SecurityChecker._judge`.
|
|
46
46
|
*
|
|
47
|
-
* `isGranted` is called synchronously on every check, so answer from memory
|
|
48
|
-
*
|
|
47
|
+
* `isGranted` is called synchronously on every check, so answer from memory,
|
|
48
|
+
* and emit `security:changed` when answers change.
|
|
49
|
+
*
|
|
50
|
+
* A judge whose answers come from a request cannot have them before the first
|
|
51
|
+
* navigation: it starts inside `createApp()`. Such a judge implements `ready()`
|
|
52
|
+
* (#2412), and the route guard waits for it instead of deciding early.
|
|
49
53
|
*/
|
|
50
54
|
export interface GrantJudge {
|
|
51
55
|
isGranted(attribute: string, subject: unknown, user: AuthUser): boolean | undefined
|
|
52
56
|
install?(ctx: GrantJudgeContext): void
|
|
57
|
+
/**
|
|
58
|
+
* Settles once the answers for the current user are loaded (#2412).
|
|
59
|
+
*
|
|
60
|
+
* Return the promise of the load in progress — the one started on
|
|
61
|
+
* `kernel:ready`, or the new one after `auth:login` — so the route guard
|
|
62
|
+
* waits for this user's answers, not an earlier user's. A resolved promise
|
|
63
|
+
* means "decide now". A rejection, or no settling within
|
|
64
|
+
* `security.readyTimeoutMs`, denies the navigation: the unknown is never
|
|
65
|
+
* allowed, only no longer decided before the answers can exist.
|
|
66
|
+
*
|
|
67
|
+
* Optional: a judge that answers from memory from the start needs none.
|
|
68
|
+
*/
|
|
69
|
+
ready?(): Promise<void>
|
|
53
70
|
}
|
|
54
71
|
|
|
55
72
|
/**
|
|
@@ -406,13 +406,14 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
406
406
|
this._warnUnknownKeys(
|
|
407
407
|
'security.',
|
|
408
408
|
security,
|
|
409
|
-
new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider', 'grant']),
|
|
409
|
+
new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider', 'grant', 'readyTimeoutMs']),
|
|
410
410
|
{
|
|
411
411
|
role_hierarchy: 'roles will not inherit from one another',
|
|
412
412
|
role_permissions: 'no role will carry any permission',
|
|
413
413
|
entity_permissions: 'per-entity permissions will not be generated',
|
|
414
414
|
rolesProvider: "the current user's roles will not be resolved",
|
|
415
415
|
grant: "the application's judgement will not be consulted — the role matrix decides alone",
|
|
416
|
+
readyTimeoutMs: 'the route guard waits the default 10 s for grant.ready()',
|
|
416
417
|
}
|
|
417
418
|
)
|
|
418
419
|
}
|
|
@@ -15,6 +15,28 @@ type Self = Kernel
|
|
|
15
15
|
/**
|
|
16
16
|
* Patch Kernel prototype with routing-related methods.
|
|
17
17
|
*/
|
|
18
|
+
/**
|
|
19
|
+
* `judge.ready()`, bounded (#2412): a judge that never settles must not hold a
|
|
20
|
+
* navigation forever. A synchronous throw from `ready()` counts as a rejection.
|
|
21
|
+
*/
|
|
22
|
+
function waitForJudge(judge: { ready(): Promise<void> }, timeoutMs: number): Promise<void> {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const timer = setTimeout(() => reject(new Error(`no answer within ${timeoutMs} ms`)), timeoutMs)
|
|
25
|
+
Promise.resolve()
|
|
26
|
+
.then(() => judge.ready())
|
|
27
|
+
.then(
|
|
28
|
+
() => {
|
|
29
|
+
clearTimeout(timer)
|
|
30
|
+
resolve()
|
|
31
|
+
},
|
|
32
|
+
(e: unknown) => {
|
|
33
|
+
clearTimeout(timer)
|
|
34
|
+
reject(e instanceof Error ? e : new Error(String(e)))
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
18
40
|
export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
|
|
19
41
|
const proto = KernelClass.prototype
|
|
20
42
|
|
|
@@ -230,14 +252,17 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
230
252
|
// declared before its manager loads) — allow. This is the ONLY
|
|
231
253
|
// allowed pass-through; a failing access check must fail CLOSED,
|
|
232
254
|
// not silently allow (#1190).
|
|
233
|
-
|
|
255
|
+
const orchestrator = this.orchestrator
|
|
256
|
+
if (!entity || !orchestrator || !orchestrator.isRegistered(entity)) return
|
|
257
|
+
|
|
258
|
+
const checkEntity = (): { path: string } | undefined => {
|
|
234
259
|
try {
|
|
235
|
-
const manager =
|
|
260
|
+
const manager = orchestrator.get(entity)
|
|
236
261
|
if (manager && !manager.canRead()) {
|
|
237
262
|
console.warn(
|
|
238
263
|
`[qdadm] Access denied to ${to.path} (entity: ${entity})`
|
|
239
264
|
)
|
|
240
|
-
|
|
265
|
+
orchestrator.toast.error(
|
|
241
266
|
'Access Denied',
|
|
242
267
|
`You don't have permission to access ${manager.labelPlural || entity}`,
|
|
243
268
|
'Kernel'
|
|
@@ -256,7 +281,23 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
256
281
|
)
|
|
257
282
|
return { path: '/' }
|
|
258
283
|
}
|
|
284
|
+
return undefined
|
|
259
285
|
}
|
|
286
|
+
|
|
287
|
+
// A judge whose answers come from a request cannot have them yet on the
|
|
288
|
+
// first navigation, which starts inside createApp() (#2412). Its cache
|
|
289
|
+
// miss would fall through to an empty role matrix and read as a refusal:
|
|
290
|
+
// wait for its answers instead of deciding early. Still fail closed — a
|
|
291
|
+
// judge that rejects or never settles denies.
|
|
292
|
+
const judge = this.securityChecker?.grant
|
|
293
|
+
if (typeof judge?.ready !== 'function') return checkEntity()
|
|
294
|
+
const timeoutMs = this.options.security?.readyTimeoutMs ?? 10_000
|
|
295
|
+
return waitForJudge(judge as { ready(): Promise<void> }, timeoutMs).then(checkEntity, (reason: Error) => {
|
|
296
|
+
console.error(
|
|
297
|
+
`[qdadm] security.grant never became ready for ${to.path} (entity: ${entity}): ${reason.message} — denying navigation`
|
|
298
|
+
)
|
|
299
|
+
return { path: '/' }
|
|
300
|
+
})
|
|
260
301
|
})
|
|
261
302
|
}
|
|
262
303
|
|
|
@@ -105,6 +105,11 @@ export interface SecurityConfig {
|
|
|
105
105
|
* throws denies. See docs/security.md.
|
|
106
106
|
*/
|
|
107
107
|
grant?: GrantJudge
|
|
108
|
+
/**
|
|
109
|
+
* How long the route guard waits for `grant.ready()` before denying (#2412).
|
|
110
|
+
* Default 10000 ms. Only used when the judge implements `ready()`.
|
|
111
|
+
*/
|
|
112
|
+
readyTimeoutMs?: number
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
/**
|
|
@@ -40,6 +40,22 @@ interface EntityManagerInstance {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The `roles` system entity.
|
|
45
|
+
*
|
|
46
|
+
* It is a system entity because this manager makes it one — apps do not set
|
|
47
|
+
* `system` themselves. To keep roles behind your own API, give it your
|
|
48
|
+
* storage; it stays a system entity:
|
|
49
|
+
*
|
|
50
|
+
* ```ts
|
|
51
|
+
* ctx.entity('roles', new RolesManager({ storage: myApiStorage }))
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* Two things change compared with a plain entity, and both are deliberate:
|
|
55
|
+
* every `can*` goes through ONE permission (`adminPermission`), and `fields`
|
|
56
|
+
* / `idField` assume qdadm's shape (`name`, `label`, `permissions`,
|
|
57
|
+
* `inherits`) — override them when your API serves another one.
|
|
58
|
+
*/
|
|
43
59
|
export class RolesManager extends EntityManager<RoleRecord> {
|
|
44
60
|
private _rolesProvider: RoleProvider | null
|
|
45
61
|
private _permissionRegistry: PermissionRegistry | null
|
|
@@ -128,8 +144,12 @@ export class RolesManager extends EntityManager<RoleRecord> {
|
|
|
128
144
|
canDelete(item?: RoleRecord | null): boolean {
|
|
129
145
|
if (!this._isAdmin()) return false
|
|
130
146
|
if (item) {
|
|
131
|
-
// Protected system roles cannot be deleted
|
|
132
|
-
|
|
147
|
+
// Protected system roles cannot be deleted. The role code is `name` in qdadm's shape, but an
|
|
148
|
+
// app whose API keys roles differently overrides `idField` (#2406): read both, or the guard
|
|
149
|
+
// silently stops matching.
|
|
150
|
+
const record = item as unknown as Record<string, unknown>
|
|
151
|
+
const codes = [record.name, record[this.idField]]
|
|
152
|
+
return !codes.some((code) => typeof code === 'string' && RolesManager.PROTECTED_ROLES.includes(code))
|
|
133
153
|
}
|
|
134
154
|
return true
|
|
135
155
|
}
|