@quazardous/qdadm 2.21.0 → 2.22.1

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 (46) 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/FormPage.vue +7 -2
  9. package/src/components/edit/formFieldIds.ts +11 -0
  10. package/src/components/editors/RoleGrantsEditor.vue +355 -0
  11. package/src/components/index.ts +1 -0
  12. package/src/components/layout/AppLayout.vue +3 -1
  13. package/src/components/layout/BaseLayout.vue +3 -1
  14. package/src/components/show/ShowPage.vue +4 -2
  15. package/src/composables/confirmDelete.ts +35 -0
  16. package/src/composables/index.ts +1 -0
  17. package/src/composables/useEntityItemFormPage.ts +2 -3
  18. package/src/composables/useEntityItemFormPage.types.ts +1 -0
  19. package/src/composables/useEntityItemShowPage.ts +3 -3
  20. package/src/composables/useGuardStore.ts +7 -1
  21. package/src/composables/useListPage.ts +3 -6
  22. package/src/composables/useListPage.types.ts +1 -0
  23. package/src/composables/useSecurity.ts +41 -0
  24. package/src/i18n/defaults/DefaultCoreProvider.ts +4 -2
  25. package/src/i18n/defaults/__tests__/generated-sync.test.ts +9 -0
  26. package/src/i18n/defaults/core.en.generated.ts +20 -0
  27. package/src/i18n/defaults/core.en.yml +20 -0
  28. package/src/i18n/defaults/core.fr.generated.ts +20 -0
  29. package/src/i18n/defaults/core.fr.yml +20 -0
  30. package/src/index.ts +1 -0
  31. package/src/kernel/Kernel.routing.ts +20 -4
  32. package/src/kernel/Kernel.ts +3 -0
  33. package/src/kernel/Kernel.vue.ts +3 -0
  34. package/src/kernel/chunkReload.ts +110 -0
  35. package/src/kernel/vitePluginCheck.ts +61 -0
  36. package/src/modules/debug/DebugModule.ts +9 -0
  37. package/src/notifications/NotificationListener.vue +2 -1
  38. package/src/security/index.ts +17 -0
  39. package/src/security/pages/RoleForm.vue +42 -191
  40. package/src/security/roleGrants.ts +210 -0
  41. package/src/styles/_forms.scss +10 -2
  42. package/src/styles/index.d.ts +3 -4
  43. package/src/toast/ToastListener.vue +2 -1
  44. package/src/types/index.ts +10 -0
  45. package/src/vite/qdadmDebugPlugin.ts +46 -10
  46. package/src/vite/qdadmPlugin.ts +2 -0
@@ -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,
@@ -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
 
@@ -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
@@ -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
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Is `qdadmVitePlugin()` in the app's vite config? (#2259)
3
+ *
4
+ * qdadm ships raw sources (ADR 0003), and `qdadmVitePlugin()` is what makes the app and qdadm share one copy
5
+ * of PrimeVue. Without it the app dies at boot on PrimeVue's own `No PrimeVue Toast provided!`, which never
6
+ * names qdadm. The plugin defines `__QDADM_VITE_PLUGIN__`; the kernel checks it once, in dev, and says what to
7
+ * add — a log, not a throw, so an app that wires the same config by hand still boots.
8
+ */
9
+ declare const __QDADM_VITE_PLUGIN__: boolean | undefined
10
+
11
+ export const MISSING_VITE_PLUGIN_HINT =
12
+ 'qdadmVitePlugin() is missing from vite.config: without it the app and qdadm can load two copies of PrimeVue, ' +
13
+ "and the app then fails at boot. Add it — import { qdadmVitePlugin } from '@quazardous/qdadm/vite', then " +
14
+ 'plugins: [vue(), qdadmVitePlugin()] — see https://github.com/quazardous/qdadm#quick-start'
15
+
16
+ /** Whether the app was built with the plugin: the define it sets. */
17
+ export function hasQdadmVitePlugin(): boolean {
18
+ try {
19
+ return typeof __QDADM_VITE_PLUGIN__ !== 'undefined' && __QDADM_VITE_PLUGIN__ === true
20
+ } catch {
21
+ return false
22
+ }
23
+ }
24
+
25
+ const isDev = (): boolean => (import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV === true
26
+
27
+ let warned = false
28
+
29
+ export interface VitePluginCheckOptions {
30
+ /** Test seams: what the build says, and where the hint goes. */
31
+ present?: boolean
32
+ dev?: boolean
33
+ log?: (message: string) => void
34
+ }
35
+
36
+ /** Log the hint once, in dev, when the plugin is missing. True when it did. */
37
+ export function warnWithoutQdadmVitePlugin(options: VitePluginCheckOptions = {}): boolean {
38
+ const present = options.present ?? hasQdadmVitePlugin()
39
+ const dev = options.dev ?? isDev()
40
+ if (present || !dev || warned) return false
41
+ warned = true
42
+ ;(options.log ?? console.error)(`[qdadm] ${MISSING_VITE_PLUGIN_HINT}`)
43
+ return true
44
+ }
45
+
46
+ /** Test seam: forget that the hint was logged. */
47
+ export function resetVitePluginWarning(): void {
48
+ warned = false
49
+ }
50
+
51
+ /** PrimeVue's "No PrimeVue Toast provided!", with its usual cause named. Any other error passes through. */
52
+ export function explainMissingToast<T>(use: () => T): T {
53
+ try {
54
+ return use()
55
+ } catch (e) {
56
+ if (e instanceof Error && /No PrimeVue Toast provided/i.test(e.message)) {
57
+ throw new Error(`${e.message} The usual cause: ${MISSING_VITE_PLUGIN_HINT}`)
58
+ }
59
+ throw e
60
+ }
61
+ }
@@ -22,6 +22,7 @@ import { AuthCollector } from './AuthCollector'
22
22
  import { EntitiesCollector } from './EntitiesCollector'
23
23
  import { RouterCollector } from './RouterCollector'
24
24
  import { I18nCollector } from './I18nCollector'
25
+ import { RelayCollector, findRelayController } from '@quazardous/qddebug'
25
26
  import DebugBar from './components/DebugBar.vue'
26
27
  import type { KernelContext } from '../../kernel/KernelContext'
27
28
 
@@ -67,6 +68,8 @@ export interface DebugModuleOptions extends ModuleOptions {
67
68
  entitiesCollector?: boolean
68
69
  routerCollector?: boolean
69
70
  i18nCollector?: boolean
71
+ /** MCP tab (#2231) — only shown where the relay connector is installed */
72
+ relayCollector?: boolean
70
73
  _kernelManaged?: boolean
71
74
  /**
72
75
  * Existing DebugBridge to register collectors onto. When provided,
@@ -152,6 +155,12 @@ export class DebugModule extends Module {
152
155
  this._bridge.addCollector(new I18nCollector(collectorOptions))
153
156
  }
154
157
 
158
+ // The MCP tab (#2231), after i18n — and only where the app installed the
159
+ // relay connector: no connector, nothing to pair, no tab.
160
+ if (this.options.relayCollector !== false && findRelayController()) {
161
+ this._bridge.addCollector(new RelayCollector(collectorOptions))
162
+ }
163
+
155
164
  // Install collectors with context — only when WE own the bridge.
156
165
  // External bridges are installed by the host later with a merged
157
166
  // context that may include data from other frameworks (e.g. cms).
@@ -12,6 +12,7 @@ import { useToast } from 'primevue/usetoast'
12
12
  import type { SignalBus } from '../kernel/SignalBus'
13
13
  import { useNotifications } from './NotificationStore'
14
14
  import type { NotificationSeverity } from './NotificationStore'
15
+ import { explainMissingToast } from '../kernel/vitePluginCheck'
15
16
 
16
17
  interface ToastEventData {
17
18
  summary?: string
@@ -21,7 +22,7 @@ interface ToastEventData {
21
22
  forceToast?: boolean
22
23
  }
23
24
 
24
- const toast = useToast()
25
+ const toast = explainMissingToast(useToast)
25
26
  const signals = inject<SignalBus | null>('qdadmSignals', null)
26
27
  const store = useNotifications()
27
28
 
@@ -69,6 +69,23 @@ export {
69
69
  type RoleListResult,
70
70
  type RoleInput,
71
71
  } from './RolesProviderStorage'
72
+ export {
73
+ composeGrants,
74
+ inheritedGrants,
75
+ originOf,
76
+ setGrant,
77
+ type GrantDefinition,
78
+ type InheritedGrant,
79
+ type RoleComposition,
80
+ type InheritableRole,
81
+ type GrantOrigin,
82
+ type GrantCell,
83
+ type GrantRow,
84
+ type GrantGroup,
85
+ type OtherGrant,
86
+ type GrantsModel,
87
+ type RoleHierarchySource,
88
+ } from './roleGrants'
72
89
 
73
90
  /**
74
91
  * Standard entity actions for CRUD operations