@quazardous/qdadm 2.20.0 → 2.22.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/dist/node/vite/qdadmDebugPlugin.d.ts +9 -4
- package/dist/node/vite/qdadmDebugPlugin.js +36 -10
- package/dist/node/vite/qdadmPlugin.js +2 -0
- package/dist/styles/qdadm.css +1 -0
- package/package.json +10 -4
- package/src/components/edit/FormField.vue +51 -4
- package/src/components/edit/FormInput.vue +44 -2
- package/src/components/edit/formFieldIds.ts +11 -0
- package/src/components/editors/RoleGrantsEditor.vue +355 -0
- package/src/components/index.ts +1 -0
- package/src/components/show/ShowPage.vue +4 -2
- package/src/composables/confirmDelete.ts +35 -0
- package/src/composables/index.ts +1 -0
- package/src/composables/useEntityItemFormPage.ts +2 -3
- package/src/composables/useEntityItemFormPage.types.ts +1 -0
- package/src/composables/useEntityItemShowPage.ts +3 -3
- package/src/composables/useListPage.ts +3 -6
- package/src/composables/useListPage.types.ts +1 -0
- package/src/composables/useSecurity.ts +41 -0
- package/src/entity/auth/SecurityChecker.ts +88 -2
- package/src/entity/auth/index.ts +2 -0
- package/src/i18n/defaults/DefaultCoreProvider.ts +4 -2
- package/src/i18n/defaults/__tests__/generated-sync.test.ts +9 -0
- package/src/i18n/defaults/core.en.generated.ts +20 -0
- package/src/i18n/defaults/core.en.yml +20 -0
- package/src/i18n/defaults/core.fr.generated.ts +20 -0
- package/src/i18n/defaults/core.fr.yml +20 -0
- package/src/index.ts +1 -0
- package/src/kernel/Kernel.auth.ts +4 -0
- package/src/kernel/Kernel.registries.ts +24 -5
- package/src/kernel/Kernel.routing.ts +20 -4
- package/src/kernel/Kernel.ts +3 -0
- package/src/kernel/Kernel.types.ts +7 -1
- package/src/kernel/Kernel.vue.ts +3 -0
- package/src/kernel/SignalBus.ts +6 -0
- package/src/kernel/chunkReload.ts +110 -0
- package/src/kernel/vitePluginCheck.ts +61 -0
- package/src/modules/debug/AuthCollector.ts +15 -1
- package/src/modules/debug/DebugModule.ts +9 -0
- package/src/modules/debug/components/panels/AuthPanel.vue +1 -0
- package/src/notifications/NotificationListener.vue +2 -1
- package/src/security/RolesProvider.ts +8 -0
- package/src/security/index.ts +17 -0
- package/src/security/pages/RoleForm.vue +42 -191
- package/src/security/roleGrants.ts +210 -0
- package/src/styles/_forms.scss +10 -2
- package/src/styles/index.d.ts +3 -4
- package/src/toast/ToastListener.vue +2 -1
- package/src/types/index.ts +10 -0
- package/src/vite/qdadmDebugPlugin.ts +46 -10
- package/src/vite/qdadmPlugin.ts +2 -0
|
@@ -15,6 +15,43 @@ export interface RoleProvider {
|
|
|
15
15
|
readonly canPersist: boolean
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* What an application's judge receives at install time (#2225).
|
|
20
|
+
*/
|
|
21
|
+
export interface GrantJudgeContext {
|
|
22
|
+
orchestrator?: unknown
|
|
23
|
+
signals?: unknown
|
|
24
|
+
/**
|
|
25
|
+
* The live permission registry — a REFERENCE, not a snapshot.
|
|
26
|
+
*
|
|
27
|
+
* `install` runs while the Kernel is still being built, before modules
|
|
28
|
+
* register their entities, so `getKeys()` called here returns only the
|
|
29
|
+
* framework's own namespaces. Read the keys when you pre-warm (after login,
|
|
30
|
+
* before mount), not in `install`.
|
|
31
|
+
*/
|
|
32
|
+
permissionRegistry?: { getKeys(): string[] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* An application-provided judgement of permissions (#2225).
|
|
37
|
+
*
|
|
38
|
+
* For apps whose backend decides who may do what: the judge answers from its
|
|
39
|
+
* own cache instead of qdadm's role matrix. It is consulted FIRST, for roles
|
|
40
|
+
* and permissions alike.
|
|
41
|
+
*
|
|
42
|
+
* - `true` / `false` is the verdict.
|
|
43
|
+
* - Anything else (`undefined`, `null`, a cache miss) falls through to the
|
|
44
|
+
* built-in judgement, which stays the default.
|
|
45
|
+
* - A judge that THROWS denies — see `SecurityChecker._judge`.
|
|
46
|
+
*
|
|
47
|
+
* `isGranted` is called synchronously on every check, so answer from memory.
|
|
48
|
+
* Fetch in bulk beforehand and emit `security:changed` when answers change.
|
|
49
|
+
*/
|
|
50
|
+
export interface GrantJudge {
|
|
51
|
+
isGranted(attribute: string, subject: unknown, user: AuthUser): boolean | undefined
|
|
52
|
+
install?(ctx: GrantJudgeContext): void
|
|
53
|
+
}
|
|
54
|
+
|
|
18
55
|
/**
|
|
19
56
|
* SecurityChecker constructor options
|
|
20
57
|
*/
|
|
@@ -22,6 +59,8 @@ export interface SecurityCheckerOptions {
|
|
|
22
59
|
rolesProvider?: RoleProvider
|
|
23
60
|
roleHierarchy?: RoleHierarchy | RoleHierarchyConfig
|
|
24
61
|
rolePermissions?: Record<string, string[]>
|
|
62
|
+
/** Application-provided judgement, consulted first (#2225). */
|
|
63
|
+
grant?: GrantJudge
|
|
25
64
|
getCurrentUser: () => AuthUser | null
|
|
26
65
|
}
|
|
27
66
|
|
|
@@ -32,6 +71,8 @@ export interface SecurityCheckerConfig {
|
|
|
32
71
|
rolesProvider?: RoleProvider
|
|
33
72
|
role_hierarchy?: RoleHierarchyConfig
|
|
34
73
|
role_permissions?: Record<string, string[]>
|
|
74
|
+
/** Application-provided judgement, consulted first (#2225). */
|
|
75
|
+
grant?: GrantJudge
|
|
35
76
|
getCurrentUser: () => AuthUser | null
|
|
36
77
|
}
|
|
37
78
|
|
|
@@ -55,8 +96,12 @@ export class SecurityChecker {
|
|
|
55
96
|
readonly getCurrentUser: () => AuthUser | null
|
|
56
97
|
/** Roles already warned about (warn-once, #1388) */
|
|
57
98
|
protected _warnedRoles = new Set<string>()
|
|
99
|
+
/** Application-provided judgement, consulted before the role matrix (#2225) */
|
|
100
|
+
protected _grant: GrantJudge | null
|
|
101
|
+
/** Attributes whose judge already threw (report-once) */
|
|
102
|
+
protected _judgeFailures = new Set<string>()
|
|
58
103
|
|
|
59
|
-
constructor({ rolesProvider, roleHierarchy, rolePermissions, getCurrentUser }: SecurityCheckerOptions) {
|
|
104
|
+
constructor({ rolesProvider, roleHierarchy, rolePermissions, grant, getCurrentUser }: SecurityCheckerOptions) {
|
|
60
105
|
if (rolesProvider) {
|
|
61
106
|
this._rolesProvider = rolesProvider
|
|
62
107
|
} else {
|
|
@@ -75,6 +120,12 @@ export class SecurityChecker {
|
|
|
75
120
|
}
|
|
76
121
|
|
|
77
122
|
this.getCurrentUser = getCurrentUser
|
|
123
|
+
this._grant = grant ?? null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The application's judge, when one is configured (#2225). */
|
|
127
|
+
get grant(): GrantJudge | null {
|
|
128
|
+
return this._grant
|
|
78
129
|
}
|
|
79
130
|
|
|
80
131
|
/**
|
|
@@ -108,10 +159,15 @@ export class SecurityChecker {
|
|
|
108
159
|
*
|
|
109
160
|
* This is the main contract method, similar to Symfony's isGranted().
|
|
110
161
|
*/
|
|
111
|
-
isGranted(attribute: string,
|
|
162
|
+
isGranted(attribute: string, subject: unknown = null): boolean {
|
|
112
163
|
const user = this.getCurrentUser()
|
|
113
164
|
if (!user) return false
|
|
114
165
|
|
|
166
|
+
// 0. The application's own judgement comes first (#2225). A boolean is a
|
|
167
|
+
// verdict; anything else falls through to the built-in matrix below.
|
|
168
|
+
const verdict = this._judge(attribute, subject, user)
|
|
169
|
+
if (verdict !== undefined) return verdict
|
|
170
|
+
|
|
115
171
|
// 1. Check if it's a role (ROLE_*)
|
|
116
172
|
if (attribute.startsWith('ROLE_')) {
|
|
117
173
|
return this.roleHierarchy.isGrantedRole(user.roles || [user.role || ''], attribute)
|
|
@@ -122,6 +178,34 @@ export class SecurityChecker {
|
|
|
122
178
|
return PermissionMatcher.any(userPerms, attribute)
|
|
123
179
|
}
|
|
124
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Ask the application's judge, if any.
|
|
183
|
+
*
|
|
184
|
+
* A judge that THROWS denies. Falling through to the built-in matrix would
|
|
185
|
+
* quietly substitute a different authority for the one the app configured,
|
|
186
|
+
* and failing open would grant whatever the backend never got to answer —
|
|
187
|
+
* the worst direction a security check can fail in (the rule the route
|
|
188
|
+
* guard already follows, #1190). Reported once per attribute.
|
|
189
|
+
*/
|
|
190
|
+
protected _judge(attribute: string, subject: unknown, user: AuthUser): boolean | undefined {
|
|
191
|
+
if (!this._grant) return undefined
|
|
192
|
+
let answer: unknown
|
|
193
|
+
try {
|
|
194
|
+
answer = this._grant.isGranted(attribute, subject, user)
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (!this._judgeFailures.has(attribute)) {
|
|
197
|
+
this._judgeFailures.add(attribute)
|
|
198
|
+
console.error(
|
|
199
|
+
`[qdadm] security.grant threw while judging "${attribute}" — DENIED. ` +
|
|
200
|
+
`A failing judge denies rather than falling back to the role matrix.`,
|
|
201
|
+
error
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
return typeof answer === 'boolean' ? answer : undefined
|
|
207
|
+
}
|
|
208
|
+
|
|
125
209
|
/**
|
|
126
210
|
* Get all permissions for a user (resolved from roles + user overrides)
|
|
127
211
|
*/
|
|
@@ -203,6 +287,7 @@ export function createSecurityChecker(config: SecurityCheckerConfig): SecurityCh
|
|
|
203
287
|
if (config.rolesProvider) {
|
|
204
288
|
return new SecurityChecker({
|
|
205
289
|
rolesProvider: config.rolesProvider,
|
|
290
|
+
grant: config.grant,
|
|
206
291
|
getCurrentUser: config.getCurrentUser,
|
|
207
292
|
})
|
|
208
293
|
}
|
|
@@ -213,6 +298,7 @@ export function createSecurityChecker(config: SecurityCheckerConfig): SecurityCh
|
|
|
213
298
|
role_hierarchy: config.role_hierarchy || {},
|
|
214
299
|
role_permissions: config.role_permissions || {},
|
|
215
300
|
}),
|
|
301
|
+
grant: config.grant,
|
|
216
302
|
getCurrentUser: config.getCurrentUser,
|
|
217
303
|
})
|
|
218
304
|
}
|
package/src/entity/auth/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Default core provider — ships qdadm's
|
|
3
|
-
* `LazyTranslationProvider` configured with a YAML loader
|
|
2
|
+
* Default core provider — ships qdadm's own defaults (EN + FR) as a
|
|
3
|
+
* `LazyTranslationProvider` configured with a YAML loader: the `core.*` tree,
|
|
4
|
+
* plus the `breadcrumb.*` keys the breadcrumb's View↔Edit toggle reads (#2270).
|
|
5
|
+
* It comes first in the provider chain, so an app's own translations win.
|
|
4
6
|
*
|
|
5
7
|
* The YAML text is imported from `core.<locale>.generated.ts` — plain TS
|
|
6
8
|
* modules that `export default` the raw YAML string, generated from the
|
|
@@ -39,4 +39,13 @@ describe('i18n default providers — generated/.yml sync', () => {
|
|
|
39
39
|
expect(en.core.actions.save).toBe('Save')
|
|
40
40
|
expect(fr.core.actions.save).toBe('Enregistrer')
|
|
41
41
|
})
|
|
42
|
+
|
|
43
|
+
it('ships the breadcrumb View↔Edit labels next to core.* (#2270)', async () => {
|
|
44
|
+
type BreadcrumbBundle = { breadcrumb: { view: string; edit: string } }
|
|
45
|
+
const provider = createDefaultCoreProvider()
|
|
46
|
+
const en = (await provider.load('en')) as BreadcrumbBundle
|
|
47
|
+
const fr = (await provider.load('fr')) as BreadcrumbBundle
|
|
48
|
+
expect(en.breadcrumb).toEqual({ view: 'View', edit: 'Edit' })
|
|
49
|
+
expect(fr.breadcrumb).toEqual({ view: 'Voir', edit: 'Modifier' })
|
|
50
|
+
})
|
|
42
51
|
})
|
|
@@ -55,4 +55,24 @@ export default `core:
|
|
|
55
55
|
invalid: Invalid value
|
|
56
56
|
notFound: Not found
|
|
57
57
|
unknown: An unknown error occurred
|
|
58
|
+
# RoleGrantsEditor (#2313)
|
|
59
|
+
roles:
|
|
60
|
+
inherits: Inherits from
|
|
61
|
+
inheritsHint: A role gets every grant of the roles it inherits from.
|
|
62
|
+
noRoles: No other role to inherit from
|
|
63
|
+
brings: "brings {count}"
|
|
64
|
+
entities: Entities
|
|
65
|
+
entity: Entity
|
|
66
|
+
all: All
|
|
67
|
+
ownRecords: own records
|
|
68
|
+
others: Other grants
|
|
69
|
+
othersHint: Wildcards and keys the registry does not know
|
|
70
|
+
covers: "covers {count}"
|
|
71
|
+
notRegistered: not registered
|
|
72
|
+
remove: Remove
|
|
73
|
+
via: "via {origin}"
|
|
74
|
+
# The breadcrumb's View↔Edit toggle (useNavContext).
|
|
75
|
+
breadcrumb:
|
|
76
|
+
view: View
|
|
77
|
+
edit: Edit
|
|
58
78
|
`
|
|
@@ -53,3 +53,23 @@ core:
|
|
|
53
53
|
invalid: Invalid value
|
|
54
54
|
notFound: Not found
|
|
55
55
|
unknown: An unknown error occurred
|
|
56
|
+
# RoleGrantsEditor (#2313)
|
|
57
|
+
roles:
|
|
58
|
+
inherits: Inherits from
|
|
59
|
+
inheritsHint: A role gets every grant of the roles it inherits from.
|
|
60
|
+
noRoles: No other role to inherit from
|
|
61
|
+
brings: "brings {count}"
|
|
62
|
+
entities: Entities
|
|
63
|
+
entity: Entity
|
|
64
|
+
all: All
|
|
65
|
+
ownRecords: own records
|
|
66
|
+
others: Other grants
|
|
67
|
+
othersHint: Wildcards and keys the registry does not know
|
|
68
|
+
covers: "covers {count}"
|
|
69
|
+
notRegistered: not registered
|
|
70
|
+
remove: Remove
|
|
71
|
+
via: "via {origin}"
|
|
72
|
+
# The breadcrumb's View↔Edit toggle (useNavContext).
|
|
73
|
+
breadcrumb:
|
|
74
|
+
view: View
|
|
75
|
+
edit: Edit
|
|
@@ -55,4 +55,24 @@ export default `core:
|
|
|
55
55
|
invalid: Valeur invalide
|
|
56
56
|
notFound: Introuvable
|
|
57
57
|
unknown: Une erreur inconnue est survenue
|
|
58
|
+
# RoleGrantsEditor (#2313)
|
|
59
|
+
roles:
|
|
60
|
+
inherits: Hérite de
|
|
61
|
+
inheritsHint: Un rôle reçoit toutes les permissions des rôles dont il hérite.
|
|
62
|
+
noRoles: Aucun autre rôle dont hériter
|
|
63
|
+
brings: "apporte {count}"
|
|
64
|
+
entities: Entités
|
|
65
|
+
entity: Entité
|
|
66
|
+
all: Tout
|
|
67
|
+
ownRecords: ses propres enregistrements
|
|
68
|
+
others: Autres permissions
|
|
69
|
+
othersHint: Jokers et clés que le registre ne connaît pas
|
|
70
|
+
covers: "couvre {count}"
|
|
71
|
+
notRegistered: non enregistrée
|
|
72
|
+
remove: Retirer
|
|
73
|
+
via: "via {origin}"
|
|
74
|
+
# The breadcrumb's View↔Edit toggle (useNavContext).
|
|
75
|
+
breadcrumb:
|
|
76
|
+
view: Voir
|
|
77
|
+
edit: Modifier
|
|
58
78
|
`
|
|
@@ -53,3 +53,23 @@ core:
|
|
|
53
53
|
invalid: Valeur invalide
|
|
54
54
|
notFound: Introuvable
|
|
55
55
|
unknown: Une erreur inconnue est survenue
|
|
56
|
+
# RoleGrantsEditor (#2313)
|
|
57
|
+
roles:
|
|
58
|
+
inherits: Hérite de
|
|
59
|
+
inheritsHint: Un rôle reçoit toutes les permissions des rôles dont il hérite.
|
|
60
|
+
noRoles: Aucun autre rôle dont hériter
|
|
61
|
+
brings: "apporte {count}"
|
|
62
|
+
entities: Entités
|
|
63
|
+
entity: Entité
|
|
64
|
+
all: Tout
|
|
65
|
+
ownRecords: ses propres enregistrements
|
|
66
|
+
others: Autres permissions
|
|
67
|
+
othersHint: Jokers et clés que le registre ne connaît pas
|
|
68
|
+
covers: "couvre {count}"
|
|
69
|
+
notRegistered: non enregistrée
|
|
70
|
+
remove: Retirer
|
|
71
|
+
via: "via {origin}"
|
|
72
|
+
# The breadcrumb's View↔Edit toggle (useNavContext).
|
|
73
|
+
breadcrumb:
|
|
74
|
+
view: Voir
|
|
75
|
+
edit: Modifier
|
package/src/index.ts
CHANGED
|
@@ -262,6 +262,7 @@ export {
|
|
|
262
262
|
} from './composables/usePageTitle'
|
|
263
263
|
export { useApp, type UseAppReturn } from './composables/useApp'
|
|
264
264
|
export { useAuth, type UseAuthReturn } from './composables/useAuth'
|
|
265
|
+
export { useSecurity, type UseSecurityReturn } from './composables/useSecurity'
|
|
265
266
|
export {
|
|
266
267
|
useEntityItemPage,
|
|
267
268
|
type UseEntityItemPageOptions,
|
|
@@ -120,11 +120,15 @@ export function applyAuthMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
120
120
|
*/
|
|
121
121
|
proto._setupAuthInvalidation = function (this: Self): void {
|
|
122
122
|
const debug = this.options.debug ?? false
|
|
123
|
+
// `security:changed` (#2225) rides the same path: nothing on screen reads
|
|
124
|
+
// permissions reactively (canCreate, menu filtering and the route guard
|
|
125
|
+
// all ask synchronously), so a remount is what makes new answers show.
|
|
123
126
|
const authSignals = [
|
|
124
127
|
'auth:login',
|
|
125
128
|
'auth:logout',
|
|
126
129
|
'auth:impersonate',
|
|
127
130
|
'auth:impersonate:stop',
|
|
131
|
+
'security:changed',
|
|
128
132
|
]
|
|
129
133
|
|
|
130
134
|
for (const signal of authSignals) {
|
|
@@ -141,6 +141,15 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
141
141
|
)
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
// A `grant` that cannot judge would be silently skipped, leaving the role
|
|
145
|
+
// matrix to decide alone while the app believes its backend does (#2225).
|
|
146
|
+
if (security.grant && typeof security.grant.isGranted !== 'function') {
|
|
147
|
+
throw new Error(
|
|
148
|
+
'[qdadm] security.grant must provide isGranted(attribute, subject, user). ' +
|
|
149
|
+
'Without it the application judgement would be skipped and the role matrix would decide alone.'
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
144
153
|
let rolesProvider = security.rolesProvider
|
|
145
154
|
if (!rolesProvider && (security.role_permissions || security.role_hierarchy)) {
|
|
146
155
|
rolesProvider = new StaticRoleProvider({
|
|
@@ -152,6 +161,7 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
152
161
|
|
|
153
162
|
this.securityChecker = createSecurityChecker({
|
|
154
163
|
rolesProvider: rolesProvider || undefined,
|
|
164
|
+
grant: security.grant,
|
|
155
165
|
getCurrentUser: () =>
|
|
156
166
|
(entityAuthAdapter as EntityAuthAdapter | null)?.getCurrentUser?.() || null,
|
|
157
167
|
})
|
|
@@ -165,14 +175,22 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
165
175
|
adapter.setSecurityChecker(this.securityChecker)
|
|
166
176
|
}
|
|
167
177
|
|
|
178
|
+
// One context for both installers. `permissionRegistry` lets a provider or
|
|
179
|
+
// a judge pre-warm in one batch (#2225) — but modules register their
|
|
180
|
+
// entities AFTER this point, so the keys must be read at fetch time, not
|
|
181
|
+
// inside install().
|
|
182
|
+
const ctx = {
|
|
183
|
+
orchestrator: this.orchestrator,
|
|
184
|
+
signals: this.signals,
|
|
185
|
+
permissionRegistry: this.permissionRegistry,
|
|
186
|
+
}
|
|
168
187
|
if (rolesProvider?.install) {
|
|
169
|
-
const ctx = {
|
|
170
|
-
orchestrator: this.orchestrator,
|
|
171
|
-
signals: this.signals,
|
|
172
|
-
}
|
|
173
188
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
174
189
|
rolesProvider.install(ctx as any)
|
|
175
190
|
}
|
|
191
|
+
if (security.grant?.install) {
|
|
192
|
+
security.grant.install(ctx)
|
|
193
|
+
}
|
|
176
194
|
}
|
|
177
195
|
|
|
178
196
|
/**
|
|
@@ -388,12 +406,13 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
388
406
|
this._warnUnknownKeys(
|
|
389
407
|
'security.',
|
|
390
408
|
security,
|
|
391
|
-
new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider']),
|
|
409
|
+
new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider', 'grant']),
|
|
392
410
|
{
|
|
393
411
|
role_hierarchy: 'roles will not inherit from one another',
|
|
394
412
|
role_permissions: 'no role will carry any permission',
|
|
395
413
|
entity_permissions: 'per-entity permissions will not be generated',
|
|
396
414
|
rolesProvider: "the current user's roles will not be resolved",
|
|
415
|
+
grant: "the application's judgement will not be consulted — the role matrix decides alone",
|
|
397
416
|
}
|
|
398
417
|
)
|
|
399
418
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type RouteRecordNormalized,
|
|
8
8
|
} from 'vue-router'
|
|
9
9
|
import { getRoutes } from '../module/moduleRegistry'
|
|
10
|
+
import { installChunkReload } from './chunkReload'
|
|
10
11
|
import type { Kernel } from './Kernel'
|
|
11
12
|
// #1196 Phase B — this-typing against the real Kernel shape (was Self = any)
|
|
12
13
|
type Self = Kernel
|
|
@@ -148,6 +149,20 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
148
149
|
})
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
/**
|
|
153
|
+
* A lazy page whose chunk is gone, typically after a deploy (#2295): reload once, then say so.
|
|
154
|
+
*/
|
|
155
|
+
proto._setupChunkReload = function (this: Self): void {
|
|
156
|
+
installChunkReload(this.router!, {
|
|
157
|
+
notify: () =>
|
|
158
|
+
this.orchestrator!.toast.error(
|
|
159
|
+
'A new version is available',
|
|
160
|
+
'This page could not be loaded. Reload the page to get the new version.',
|
|
161
|
+
{ life: 0, emitter: 'Kernel' }
|
|
162
|
+
),
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
151
166
|
/**
|
|
152
167
|
* Setup auth guard on router
|
|
153
168
|
*/
|
|
@@ -190,7 +205,9 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
190
205
|
)
|
|
191
206
|
|
|
192
207
|
if (requiresAuth && !authAdapter.isAuthenticated()) {
|
|
193
|
-
|
|
208
|
+
// Only a session this tab had can be lost: a first visit goes to login without the flag (#2292).
|
|
209
|
+
const sessionLost = wasEverAuthenticated
|
|
210
|
+
if (sessionLost) {
|
|
194
211
|
const debug = this.options.debug ?? false
|
|
195
212
|
if (debug) {
|
|
196
213
|
console.warn(
|
|
@@ -204,9 +221,8 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
204
221
|
wasEverAuthenticated = false
|
|
205
222
|
}
|
|
206
223
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
: '/'
|
|
224
|
+
if (!this.router!.hasRoute('login')) return '/'
|
|
225
|
+
return sessionLost ? { name: 'login', query: { session_lost: '1' } } : { name: 'login' }
|
|
210
226
|
}
|
|
211
227
|
|
|
212
228
|
const entity = to.meta?.entity as string | undefined
|
package/src/kernel/Kernel.ts
CHANGED
|
@@ -228,6 +228,7 @@ export class Kernel {
|
|
|
228
228
|
this._createRouter()
|
|
229
229
|
this._setupStackSync()
|
|
230
230
|
this._setupAuthGuard()
|
|
231
|
+
this._setupChunkReload()
|
|
231
232
|
this._setupAuthExpiredHandler()
|
|
232
233
|
this._setupAuthImpersonation()
|
|
233
234
|
this._setupAuthInvalidation()
|
|
@@ -262,6 +263,7 @@ export class Kernel {
|
|
|
262
263
|
this._createRouter()
|
|
263
264
|
this._setupStackSync()
|
|
264
265
|
this._setupAuthGuard()
|
|
266
|
+
this._setupChunkReload()
|
|
265
267
|
this._setupAuthExpiredHandler()
|
|
266
268
|
this._setupAuthImpersonation()
|
|
267
269
|
this._setupAuthInvalidation()
|
|
@@ -467,6 +469,7 @@ export interface Kernel {
|
|
|
467
469
|
// Routing (Kernel.routing.ts)
|
|
468
470
|
_createRouter(): void
|
|
469
471
|
_setupAuthGuard(): void
|
|
472
|
+
_setupChunkReload(): void
|
|
470
473
|
_setupStackSync(): void
|
|
471
474
|
_rebuildActiveStack(route: RouteLocationNormalized): void
|
|
472
475
|
|
|
@@ -10,6 +10,7 @@ import type { RoutesConfig } from './EventRouter'
|
|
|
10
10
|
import type { EntityManager } from '../entity/EntityManager'
|
|
11
11
|
import type { EntityAuthAdapter } from '../entity/auth/EntityAuthAdapter'
|
|
12
12
|
import type { RoleProvider } from '../security/RolesProvider'
|
|
13
|
+
import type { GrantJudge } from '../entity/auth/SecurityChecker'
|
|
13
14
|
import type { I18nOptions } from '../i18n/types'
|
|
14
15
|
import type { ApiClientSource } from '../api/apiClient'
|
|
15
16
|
import type { RouteStatePersister } from '../routeState'
|
|
@@ -98,7 +99,12 @@ export interface SecurityConfig {
|
|
|
98
99
|
role_permissions?: Record<string, string[]>
|
|
99
100
|
role_labels?: Record<string, string>
|
|
100
101
|
entity_permissions?: boolean | string[]
|
|
101
|
-
rolesProvider?: RoleProvider
|
|
102
|
+
rolesProvider?: RoleProvider /**
|
|
103
|
+
* Application-provided judgement (#2225), consulted before the role matrix.
|
|
104
|
+
* `true`/`false` is the verdict, anything else falls through; a judge that
|
|
105
|
+
* throws denies. See docs/security.md.
|
|
106
|
+
*/
|
|
107
|
+
grant?: GrantJudge
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
/**
|
package/src/kernel/Kernel.vue.ts
CHANGED
|
@@ -17,6 +17,7 @@ import ToastListener from '../toast/ToastListener.vue'
|
|
|
17
17
|
import { createQdadm } from '../plugin.js'
|
|
18
18
|
import { createNotificationStore, NOTIFICATION_KEY } from '../notifications/NotificationStore'
|
|
19
19
|
import { I18N_INJECTION_KEY } from '../i18n/useI18n'
|
|
20
|
+
import { warnWithoutQdadmVitePlugin } from './vitePluginCheck'
|
|
20
21
|
import type { Kernel } from './Kernel'
|
|
21
22
|
// #1196 Phase B — this-typing against the real Kernel shape (was Self = any)
|
|
22
23
|
type Self = Kernel
|
|
@@ -197,6 +198,8 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
197
198
|
* ConfirmationService.
|
|
198
199
|
*/
|
|
199
200
|
proto._installPlugins = function (this: Self): void {
|
|
201
|
+
// Before PrimeVue: without the vite plugin, PrimeVue is what fails, without naming qdadm (#2259).
|
|
202
|
+
warnWithoutQdadmVitePlugin()
|
|
200
203
|
const app = this.vueApp!
|
|
201
204
|
const { authAdapter, features, primevue, existingApp } = this.options
|
|
202
205
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
package/src/kernel/SignalBus.ts
CHANGED
|
@@ -43,6 +43,12 @@ export const SIGNALS = {
|
|
|
43
43
|
AUTH_LOGOUT: 'auth:logout',
|
|
44
44
|
AUTH_EXPIRED: 'auth:expired', // Emitted on 401/403 API responses
|
|
45
45
|
|
|
46
|
+
// Security signals
|
|
47
|
+
// The application's permission answers changed — every screen re-evaluates
|
|
48
|
+
// (#2225). Emit it only when an answer actually differs: it remounts the app,
|
|
49
|
+
// which discards unsaved form state.
|
|
50
|
+
SECURITY_CHANGED: 'security:changed',
|
|
51
|
+
|
|
46
52
|
// API error signals
|
|
47
53
|
API_ERROR: 'api:error', // Emitted on any API error { status, message, url }
|
|
48
54
|
} as const
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A lazy page whose chunk no longer exists (#2295), typically in a tab left open across a deploy: the build renamed
|
|
3
|
+
* its hashed chunks, the tab still runs the old bundle, and the import 404s. The router aborts the navigation and
|
|
4
|
+
* nothing tells the user; on a first load the screen stays blank.
|
|
5
|
+
*
|
|
6
|
+
* The page reloads once, at the URL the user was going to, which fetches the new index.html. A flag in
|
|
7
|
+
* sessionStorage keeps a chunk that is really missing from reloading in a loop: a second failure is reported
|
|
8
|
+
* instead. The flag is cleared by the next navigation that succeeds.
|
|
9
|
+
*/
|
|
10
|
+
import type { Router } from 'vue-router'
|
|
11
|
+
|
|
12
|
+
export const CHUNK_RELOAD_KEY = 'qdadm:chunk-reload'
|
|
13
|
+
|
|
14
|
+
// Chrome, Firefox, Safari, Vite's preload helper, webpack.
|
|
15
|
+
const CHUNK_ERROR =
|
|
16
|
+
/Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed|Unable to preload CSS|ChunkLoadError|Loading chunk \S+ failed/i
|
|
17
|
+
|
|
18
|
+
export function isChunkLoadError(error: unknown): boolean {
|
|
19
|
+
if (!error) return false
|
|
20
|
+
const { name, message } = error as { name?: unknown; message?: unknown }
|
|
21
|
+
return CHUNK_ERROR.test(`${String(name ?? '')} ${typeof message === 'string' ? message : String(error)}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type FlagStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
|
25
|
+
|
|
26
|
+
export interface ChunkReloadOptions {
|
|
27
|
+
/** A second failure, or no way to guard a reload: tell the user. */
|
|
28
|
+
notify(error: unknown): void
|
|
29
|
+
/** Default: sessionStorage. Without one, nothing reloads: a loop could not be stopped. */
|
|
30
|
+
storage?: FlagStorage | null
|
|
31
|
+
/** Default: load `href` for real. */
|
|
32
|
+
reload?(href: string): void
|
|
33
|
+
/** Where Vite dispatches `vite:preloadError`. Default: window. */
|
|
34
|
+
target?: EventTarget | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sessionFlags(): FlagStorage | null {
|
|
38
|
+
try {
|
|
39
|
+
return typeof sessionStorage === 'undefined' ? null : sessionStorage
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function reloadAt(href: string): void {
|
|
46
|
+
const url = new URL(href, window.location.href).href
|
|
47
|
+
// The failed navigation never changed the URL; the reload must land where the user was going.
|
|
48
|
+
if (url !== window.location.href) window.history.pushState(null, '', url)
|
|
49
|
+
window.location.reload()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Returns a function that removes the handlers. */
|
|
53
|
+
export function installChunkReload(router: Router, options: ChunkReloadOptions): () => void {
|
|
54
|
+
const storage = options.storage === undefined ? sessionFlags() : options.storage
|
|
55
|
+
const reload = options.reload ?? reloadAt
|
|
56
|
+
const target = options.target === undefined ? (typeof window === 'undefined' ? null : window) : options.target
|
|
57
|
+
let reloading = false
|
|
58
|
+
|
|
59
|
+
const failed = (error: unknown, href: string | null) => {
|
|
60
|
+
if (reloading) return
|
|
61
|
+
let tried = true
|
|
62
|
+
try {
|
|
63
|
+
tried = !storage || storage.getItem(CHUNK_RELOAD_KEY) !== null
|
|
64
|
+
if (!tried) storage!.setItem(CHUNK_RELOAD_KEY, String(Date.now()))
|
|
65
|
+
} catch {
|
|
66
|
+
tried = true
|
|
67
|
+
}
|
|
68
|
+
if (tried) {
|
|
69
|
+
console.error('[qdadm] a page could not be loaded, and reloading did not help', error)
|
|
70
|
+
options.notify(error)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
reloading = true
|
|
74
|
+
console.warn('[qdadm] a page could not be loaded, probably a new deploy: reloading once', error)
|
|
75
|
+
reload(href ?? (typeof window === 'undefined' ? '/' : window.location.href))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let routerSaw = false
|
|
79
|
+
const removeOnError = router.onError((error, to) => {
|
|
80
|
+
if (!isChunkLoadError(error)) return
|
|
81
|
+
routerSaw = true
|
|
82
|
+
failed(error, to ? router.resolve(to).href : null)
|
|
83
|
+
})
|
|
84
|
+
const removeAfterEach = router.afterEach((_to, _from, failure) => {
|
|
85
|
+
if (failure || reloading) return
|
|
86
|
+
try {
|
|
87
|
+
storage?.removeItem(CHUNK_RELOAD_KEY)
|
|
88
|
+
} catch {
|
|
89
|
+
/* nothing to clear */
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// Vite dispatches this before the import rejects. When the import belongs to a navigation, the router hears the
|
|
94
|
+
// rejection a few microtasks later and handles it with the page the user wanted; waiting one task lets it go
|
|
95
|
+
// first, and the failure is then not handled twice.
|
|
96
|
+
const onPreloadError = (event: Event) => {
|
|
97
|
+
const error = (event as Event & { payload?: unknown }).payload ?? event
|
|
98
|
+
routerSaw = false
|
|
99
|
+
setTimeout(() => {
|
|
100
|
+
if (!routerSaw) failed(error, null)
|
|
101
|
+
}, 0)
|
|
102
|
+
}
|
|
103
|
+
target?.addEventListener('vite:preloadError', onPreloadError)
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
removeOnError()
|
|
107
|
+
removeAfterEach()
|
|
108
|
+
target?.removeEventListener('vite:preloadError', onPreloadError)
|
|
109
|
+
}
|
|
110
|
+
}
|