@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.
Files changed (51) hide show
  1. package/dist/node/vite/qdadmDebugPlugin.d.ts +9 -4
  2. package/dist/node/vite/qdadmDebugPlugin.js +36 -10
  3. package/dist/node/vite/qdadmPlugin.js +2 -0
  4. package/dist/styles/qdadm.css +1 -0
  5. package/package.json +10 -4
  6. package/src/components/edit/FormField.vue +51 -4
  7. package/src/components/edit/FormInput.vue +44 -2
  8. package/src/components/edit/formFieldIds.ts +11 -0
  9. package/src/components/editors/RoleGrantsEditor.vue +355 -0
  10. package/src/components/index.ts +1 -0
  11. package/src/components/show/ShowPage.vue +4 -2
  12. package/src/composables/confirmDelete.ts +35 -0
  13. package/src/composables/index.ts +1 -0
  14. package/src/composables/useEntityItemFormPage.ts +2 -3
  15. package/src/composables/useEntityItemFormPage.types.ts +1 -0
  16. package/src/composables/useEntityItemShowPage.ts +3 -3
  17. package/src/composables/useListPage.ts +3 -6
  18. package/src/composables/useListPage.types.ts +1 -0
  19. package/src/composables/useSecurity.ts +41 -0
  20. package/src/entity/auth/SecurityChecker.ts +88 -2
  21. package/src/entity/auth/index.ts +2 -0
  22. package/src/i18n/defaults/DefaultCoreProvider.ts +4 -2
  23. package/src/i18n/defaults/__tests__/generated-sync.test.ts +9 -0
  24. package/src/i18n/defaults/core.en.generated.ts +20 -0
  25. package/src/i18n/defaults/core.en.yml +20 -0
  26. package/src/i18n/defaults/core.fr.generated.ts +20 -0
  27. package/src/i18n/defaults/core.fr.yml +20 -0
  28. package/src/index.ts +1 -0
  29. package/src/kernel/Kernel.auth.ts +4 -0
  30. package/src/kernel/Kernel.registries.ts +24 -5
  31. package/src/kernel/Kernel.routing.ts +20 -4
  32. package/src/kernel/Kernel.ts +3 -0
  33. package/src/kernel/Kernel.types.ts +7 -1
  34. package/src/kernel/Kernel.vue.ts +3 -0
  35. package/src/kernel/SignalBus.ts +6 -0
  36. package/src/kernel/chunkReload.ts +110 -0
  37. package/src/kernel/vitePluginCheck.ts +61 -0
  38. package/src/modules/debug/AuthCollector.ts +15 -1
  39. package/src/modules/debug/DebugModule.ts +9 -0
  40. package/src/modules/debug/components/panels/AuthPanel.vue +1 -0
  41. package/src/notifications/NotificationListener.vue +2 -1
  42. package/src/security/RolesProvider.ts +8 -0
  43. package/src/security/index.ts +17 -0
  44. package/src/security/pages/RoleForm.vue +42 -191
  45. package/src/security/roleGrants.ts +210 -0
  46. package/src/styles/_forms.scss +10 -2
  47. package/src/styles/index.d.ts +3 -4
  48. package/src/toast/ToastListener.vue +2 -1
  49. package/src/types/index.ts +10 -0
  50. package/src/vite/qdadmDebugPlugin.ts +46 -10
  51. 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, _subject: unknown = null): boolean {
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
  }
@@ -23,6 +23,8 @@ export type {
23
23
  RoleProvider,
24
24
  SecurityCheckerOptions,
25
25
  SecurityCheckerConfig,
26
+ GrantJudge,
27
+ GrantJudgeContext,
26
28
  } from './SecurityChecker'
27
29
 
28
30
  // Implementations
@@ -1,6 +1,8 @@
1
1
  /**
2
- * Default core provider — ships qdadm's `core.*` defaults (EN + FR) as a
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
- if (wasEverAuthenticated) {
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
- return this.router!.hasRoute('login')
208
- ? { name: 'login', query: { session_lost: '1' } }
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
@@ -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
  /**
@@ -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
@@ -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
+ }