@quazardous/qdadm 2.21.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 (42) 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/i18n/defaults/DefaultCoreProvider.ts +4 -2
  21. package/src/i18n/defaults/__tests__/generated-sync.test.ts +9 -0
  22. package/src/i18n/defaults/core.en.generated.ts +20 -0
  23. package/src/i18n/defaults/core.en.yml +20 -0
  24. package/src/i18n/defaults/core.fr.generated.ts +20 -0
  25. package/src/i18n/defaults/core.fr.yml +20 -0
  26. package/src/index.ts +1 -0
  27. package/src/kernel/Kernel.routing.ts +20 -4
  28. package/src/kernel/Kernel.ts +3 -0
  29. package/src/kernel/Kernel.vue.ts +3 -0
  30. package/src/kernel/chunkReload.ts +110 -0
  31. package/src/kernel/vitePluginCheck.ts +61 -0
  32. package/src/modules/debug/DebugModule.ts +9 -0
  33. package/src/notifications/NotificationListener.vue +2 -1
  34. package/src/security/index.ts +17 -0
  35. package/src/security/pages/RoleForm.vue +42 -191
  36. package/src/security/roleGrants.ts +210 -0
  37. package/src/styles/_forms.scss +10 -2
  38. package/src/styles/index.d.ts +3 -4
  39. package/src/toast/ToastListener.vue +2 -1
  40. package/src/types/index.ts +10 -0
  41. package/src/vite/qdadmDebugPlugin.ts +46 -10
  42. package/src/vite/qdadmPlugin.ts +2 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * A role's composition, as RoleGrantsEditor draws it (#2313): which registered grants the role holds, and where
3
+ * each one comes from.
4
+ *
5
+ * Pure and judge-free. What inherited roles bring is given, never resolved here (the app's server may be the one
6
+ * that judges); wildcards are read with PermissionMatcher, `*` being one segment and `**` any number.
7
+ */
8
+ import { PermissionMatcher } from './PermissionMatcher'
9
+
10
+ /** A registry entry, or anything shaped like one (an app may pass its server's list). */
11
+ export interface GrantDefinition {
12
+ key: string
13
+ namespace: string
14
+ action: string
15
+ label?: string
16
+ description?: string | null
17
+ }
18
+
19
+ /** A grant an inherited role brings, and the role that declares it. */
20
+ export interface InheritedGrant {
21
+ permission: string
22
+ via: string
23
+ }
24
+
25
+ /** What RoleGrantsEditor edits: the role's own composition, never what it inherits. */
26
+ export interface RoleComposition {
27
+ inherits: string[]
28
+ permissions: string[]
29
+ }
30
+
31
+ export interface InheritableRole {
32
+ name: string
33
+ label?: string
34
+ description?: string
35
+ }
36
+
37
+ export type GrantOrigin =
38
+ | { kind: 'none' }
39
+ | { kind: 'own' }
40
+ /** Covered by a wildcard the role holds. */
41
+ | { kind: 'wildcard'; via: string }
42
+ /** Brought by an inherited role. */
43
+ | { kind: 'inherited'; via: string }
44
+
45
+ export interface GrantCell {
46
+ key: string
47
+ action: string
48
+ label: string
49
+ description: string | null
50
+ origin: GrantOrigin
51
+ }
52
+
53
+ /** One entity of the matrix: `entity:books`, or its ownership grants `entity-own:books`. */
54
+ export interface GrantRow {
55
+ namespace: string
56
+ entity: string
57
+ ownRecords: boolean
58
+ /** `entity:books:*` */
59
+ wildcard: string
60
+ wildcardOrigin: GrantOrigin
61
+ /** By action; null where the entity registers no such grant. */
62
+ cells: Record<string, GrantCell | null>
63
+ }
64
+
65
+ /** Named grants of one namespace, as registered: `offers:debug:read` belongs to `offers:debug`, not `offers`. */
66
+ export interface GrantGroup {
67
+ namespace: string
68
+ wildcard: string
69
+ wildcardOrigin: GrantOrigin
70
+ grants: GrantCell[]
71
+ }
72
+
73
+ /** A key of the role that is neither a registered grant nor a row or group wildcard. */
74
+ export interface OtherGrant {
75
+ key: string
76
+ /** Registered grants it covers: 0 for a key the registry does not know. */
77
+ covers: number
78
+ }
79
+
80
+ export interface GrantsModel {
81
+ actions: string[]
82
+ rows: GrantRow[]
83
+ groups: GrantGroup[]
84
+ others: OtherGrant[]
85
+ }
86
+
87
+ const STANDARD_ACTIONS = ['read', 'list', 'create', 'update', 'delete']
88
+
89
+ const isWildcard = (key: string) => key.includes('*')
90
+
91
+ /** Where a grant comes from for the role: its own key first, then its wildcards, then what it inherits. */
92
+ export function originOf(
93
+ key: string,
94
+ permissions: readonly string[],
95
+ inherited: readonly InheritedGrant[] = []
96
+ ): GrantOrigin {
97
+ if (permissions.includes(key)) return { kind: 'own' }
98
+ const wildcard = permissions.find((p) => isWildcard(p) && PermissionMatcher.matches(p, key))
99
+ if (wildcard) return { kind: 'wildcard', via: wildcard }
100
+ const brought = inherited.find((g) => PermissionMatcher.matches(g.permission, key))
101
+ if (brought) return { kind: 'inherited', via: brought.via }
102
+ return { kind: 'none' }
103
+ }
104
+
105
+ export function composeGrants(input: {
106
+ grants: readonly GrantDefinition[]
107
+ permissions: readonly string[]
108
+ inherited?: readonly InheritedGrant[]
109
+ }): GrantsModel {
110
+ const { grants, permissions } = input
111
+ const inherited = input.inherited ?? []
112
+ const cellOf = (g: GrantDefinition): GrantCell => ({
113
+ key: g.key,
114
+ action: g.action,
115
+ label: g.label || g.key,
116
+ description: g.description ?? null,
117
+ origin: originOf(g.key, permissions, inherited),
118
+ })
119
+
120
+ const entityNamespaces = new Map<string, GrantDefinition[]>()
121
+ const namedNamespaces = new Map<string, GrantDefinition[]>()
122
+ for (const g of grants) {
123
+ const target = /^entity(-own)?:/.test(g.namespace) ? entityNamespaces : namedNamespaces
124
+ target.set(g.namespace, [...(target.get(g.namespace) ?? []), g])
125
+ }
126
+
127
+ const registeredActions = new Set<string>()
128
+ for (const list of entityNamespaces.values()) for (const g of list) registeredActions.add(g.action)
129
+ const actions = [
130
+ ...STANDARD_ACTIONS.filter((a) => registeredActions.has(a)),
131
+ ...[...registeredActions].filter((a) => !STANDARD_ACTIONS.includes(a)).sort(),
132
+ ]
133
+
134
+ const rows: GrantRow[] = [...entityNamespaces.entries()]
135
+ .map(([namespace, list]) => {
136
+ const cells: Record<string, GrantCell | null> = {}
137
+ for (const action of actions) {
138
+ const g = list.find((d) => d.action === action)
139
+ cells[action] = g ? cellOf(g) : null
140
+ }
141
+ const wildcard = `${namespace}:*`
142
+ return {
143
+ namespace,
144
+ entity: namespace.slice(namespace.indexOf(':') + 1),
145
+ ownRecords: namespace.startsWith('entity-own:'),
146
+ wildcard,
147
+ wildcardOrigin: originOf(wildcard, permissions, inherited),
148
+ cells,
149
+ }
150
+ })
151
+ .sort((a, b) => a.entity.localeCompare(b.entity) || Number(a.ownRecords) - Number(b.ownRecords))
152
+
153
+ const groups: GrantGroup[] = [...namedNamespaces.entries()]
154
+ .sort(([a], [b]) => a.localeCompare(b))
155
+ .map(([namespace, list]) => ({
156
+ namespace,
157
+ wildcard: `${namespace}:*`,
158
+ wildcardOrigin: originOf(`${namespace}:*`, permissions, inherited),
159
+ grants: list.map(cellOf),
160
+ }))
161
+
162
+ const registered = new Set(grants.map((g) => g.key))
163
+ const shown = new Set([...rows.map((r) => r.wildcard), ...groups.map((g) => g.wildcard)])
164
+ const keys = grants.map((g) => g.key)
165
+ const others = [...new Set(permissions)]
166
+ .filter((p) => !registered.has(p) && !shown.has(p))
167
+ .map((key) => ({ key, covers: isWildcard(key) ? PermissionMatcher.expand(key, keys).length : 0 }))
168
+
169
+ return { actions, rows, groups, others }
170
+ }
171
+
172
+ /** The role's own keys with one added or removed; the others keep their order. */
173
+ export function setGrant(permissions: readonly string[], key: string, on: boolean): string[] {
174
+ if (on) return permissions.includes(key) ? [...permissions] : [...permissions, key]
175
+ return permissions.filter((p) => p !== key)
176
+ }
177
+
178
+ /** What inheritedGrants needs from a roles provider: qdadm's RoleProvider has it. */
179
+ export interface RoleHierarchySource {
180
+ getPermissions(role: string): string[]
181
+ getHierarchy(): Record<string, string[]>
182
+ }
183
+
184
+ /**
185
+ * What a role inherits, for apps whose front knows the roles (qdadm's own RoleProvider): each grant with the role
186
+ * that declares it, the nearest one first across a chain. An app whose server judges sends this list instead.
187
+ */
188
+ export function inheritedGrants(
189
+ provider: RoleHierarchySource | null | undefined,
190
+ inherits: readonly string[]
191
+ ): InheritedGrant[] {
192
+ if (!provider) return []
193
+ const hierarchy = provider.getHierarchy() ?? {}
194
+ const visited = new Set<string>()
195
+ const known = new Set<string>()
196
+ const out: InheritedGrant[] = []
197
+ const queue = [...inherits]
198
+ while (queue.length > 0) {
199
+ const role = queue.shift()!
200
+ if (visited.has(role)) continue
201
+ visited.add(role)
202
+ for (const permission of provider.getPermissions(role) ?? []) {
203
+ if (known.has(permission)) continue
204
+ known.add(permission)
205
+ out.push({ permission, via: role })
206
+ }
207
+ queue.push(...(hierarchy[role] ?? []))
208
+ }
209
+ return out
210
+ }
@@ -82,18 +82,26 @@
82
82
  font-size: 0.85rem;
83
83
  }
84
84
 
85
- // Force PrimeVue inputs to be full width
85
+ // Force PrimeVue inputs to be full width. Text-like native inputs only: a native checkbox, radio, range or
86
+ // colour keeps its own size (#2319). PrimeVue's Checkbox, RadioButton and ToggleSwitch size their hidden input
87
+ // themselves.
86
88
  .p-inputtext,
87
89
  .p-textarea,
88
90
  .p-select,
89
91
  .p-multiselect,
90
92
  .p-inputwrapper,
91
- input,
93
+ input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='color']),
92
94
  textarea {
93
95
  width: 100% !important;
94
96
  box-sizing: border-box;
95
97
  }
96
98
 
99
+ // The field is a flex column: a native checkbox or radio placed directly in it would be stretched across the row.
100
+ > input[type='checkbox'],
101
+ > input[type='radio'] {
102
+ align-self: flex-start;
103
+ }
104
+
97
105
  .p-textarea,
98
106
  textarea {
99
107
  min-height: 100px;
@@ -1,5 +1,4 @@
1
- // Type companion for the `@quazardous/qdadm/styles` export (#1386).
2
- // The export resolves to a .scss file, which TypeScript cannot see through
3
- // the exports map — this empty module makes the side-effect import
4
- // `import '@quazardous/qdadm/styles'` typecheck in strict consumers.
1
+ // Type companion for the `@quazardous/qdadm/styles` and `@quazardous/qdadm/styles/scss` exports (#1386, #2260).
2
+ // They resolve to a stylesheet (compiled .css, or the raw .scss), which TypeScript cannot see through the
3
+ // exports map — this empty module makes the side-effect imports typecheck in strict consumers.
5
4
  export {}
@@ -10,6 +10,7 @@
10
10
  import { onMounted, onUnmounted, inject } from 'vue'
11
11
  import { useToast } from 'primevue/usetoast'
12
12
  import type { SignalBus } from '../kernel/SignalBus'
13
+ import { explainMissingToast } from '../kernel/vitePluginCheck'
13
14
 
14
15
  interface ToastEventData {
15
16
  summary?: string
@@ -17,7 +18,7 @@ interface ToastEventData {
17
18
  life?: number
18
19
  }
19
20
 
20
- const toast = useToast()
21
+ const toast = explainMissingToast(useToast)
21
22
  const signals = inject<SignalBus | null>('qdadmSignals', null)
22
23
 
23
24
  let unsubscribe: (() => void) | null = null
@@ -86,6 +86,16 @@ export interface FieldConfig {
86
86
  required?: boolean
87
87
  /** number fields: false → no locale digit grouping in displays (#1388) */
88
88
  useGrouping?: boolean
89
+ /**
90
+ * number fields: digits the form input takes after the decimal point (#2316) — `2` for always two, or
91
+ * `{ min, max }`. Without it the input takes whole numbers only.
92
+ */
93
+ fractionDigits?: number | { min?: number; max?: number }
94
+ /** number fields: bounds of the form input (#2316) */
95
+ min?: number
96
+ max?: number
97
+ /** number fields: increment of the form input (#2316) */
98
+ step?: number
89
99
  default?: unknown | (() => unknown)
90
100
  options?: Array<{ label: string; value: unknown }>
91
101
  reference?: {
@@ -8,10 +8,12 @@
8
8
  * GET /__qdadm/snapshot.json — bridge.dump() (live state)
9
9
  * POST /__qdadm/call — body {collector, action, args?}
10
10
  *
11
- * Session selection: every browser tab that loads the app gets a fresh
12
- * session id (uuid in the injected client). All endpoints accept a
13
- * `?session=<id|latest>` query param. Default is `latest` (the most recently
14
- * active session).
11
+ * Session selection: every browser tab gets a session id (uuid in the
12
+ * injected client), kept in sessionStorage so a reload is the SAME session
13
+ * (#2231). A tab that says bye stays known through a short grace window: a
14
+ * request to it answers "reloading" instead of timing out. All endpoints
15
+ * accept a `?session=<id|latest>` query param. Default is `latest` (the most
16
+ * recently active connected session).
15
17
  *
16
18
  * Wire-up: the plugin injects a small ESM script via `transformIndexHtml`.
17
19
  * That inline script lives in Vite's HMR pipeline (so `import.meta.hot`
@@ -78,6 +80,8 @@ interface SessionState {
78
80
  describe: { data: unknown; at: number } | null
79
81
  snapshot: { data: unknown; at: number } | null
80
82
  meta: Record<string, unknown>
83
+ /** Set by bye; cleared by the next push from the same tab. */
84
+ disconnectedAt: number | null
81
85
  }
82
86
 
83
87
  /** Broker surface exposed to sibling plugins via `plugin.api` (#1398). */
@@ -95,6 +99,7 @@ export interface QdadmDebugPluginApi {
95
99
  ageMs: number
96
100
  hasDescribe: boolean
97
101
  hasSnapshot: boolean
102
+ connected: boolean
98
103
  meta: Record<string, unknown>
99
104
  }>
100
105
  prefix: string
@@ -107,12 +112,15 @@ export interface QdadmDebugPluginOptions {
107
112
  timeoutMs?: number
108
113
  /** Drop sessions inactive for this many ms (default 10 min) */
109
114
  sessionTtlMs?: number
115
+ /** Keep a tab that said bye this long, so its reload keeps it (default 30 s) */
116
+ reloadGraceMs?: number
110
117
  }
111
118
 
112
119
  export function qdadmDebugPlugin(options: QdadmDebugPluginOptions = {}): Plugin {
113
120
  const prefix = options.prefix ?? '/__qdadm'
114
121
  const timeoutMs = options.timeoutMs ?? 3000
115
122
  const sessionTtlMs = options.sessionTtlMs ?? 10 * 60 * 1000
123
+ const reloadGraceMs = options.reloadGraceMs ?? 30 * 1000
116
124
 
117
125
  const sessions = new Map<string, SessionState>()
118
126
  const pending = new Map<string, PendingRequest>()
@@ -120,18 +128,25 @@ export function qdadmDebugPlugin(options: QdadmDebugPluginOptions = {}): Plugin
120
128
  let nextId = 1
121
129
 
122
130
  function pruneStaleSessions(): void {
123
- const cutoff = Date.now() - sessionTtlMs
131
+ const now = Date.now()
124
132
  for (const [id, s] of sessions) {
125
- if (s.lastSeenAt < cutoff) sessions.delete(id)
133
+ if (s.lastSeenAt < now - sessionTtlMs) sessions.delete(id)
134
+ else if (s.disconnectedAt !== null && s.disconnectedAt < now - reloadGraceMs) sessions.delete(id)
126
135
  }
127
136
  }
128
137
 
129
138
  function pickSession(sessionParam: string | null): SessionState | null {
130
139
  pruneStaleSessions()
131
140
  if (!sessionParam || sessionParam === 'latest') {
141
+ // A connected tab wins; a reloading one only when nothing else is up,
142
+ // so the caller hears "reloading" rather than "no session".
132
143
  let best: SessionState | null = null
133
144
  for (const s of sessions.values()) {
134
- if (!best || s.lastSeenAt > best.lastSeenAt) best = s
145
+ const better =
146
+ !best ||
147
+ (best.disconnectedAt !== null && s.disconnectedAt === null) ||
148
+ ((best.disconnectedAt === null) === (s.disconnectedAt === null) && s.lastSeenAt > best.lastSeenAt)
149
+ if (better) best = s
135
150
  }
136
151
  return best
137
152
  }
@@ -144,6 +159,15 @@ export function qdadmDebugPlugin(options: QdadmDebugPluginOptions = {}): Plugin
144
159
  sessionId?: string
145
160
  ): Promise<unknown> {
146
161
  if (!server) return Promise.reject(new Error('[qdadm-debug] vite server not ready'))
162
+ const target = sessionId ? sessions.get(sessionId) : undefined
163
+ if (target?.disconnectedAt) {
164
+ const away = Math.round((Date.now() - target.disconnectedAt) / 1000)
165
+ return Promise.reject(
166
+ new Error(
167
+ `[qdadm-debug] session ${target.id.slice(0, 8)} left ${away}s ago — the tab is most likely reloading; retry in a few seconds`
168
+ )
169
+ )
170
+ }
147
171
  const id = String(nextId++)
148
172
  return new Promise((resolve, reject) => {
149
173
  const timeout = setTimeout(() => {
@@ -200,6 +224,7 @@ export function qdadmDebugPlugin(options: QdadmDebugPluginOptions = {}): Plugin
200
224
  ageMs: number
201
225
  hasDescribe: boolean
202
226
  hasSnapshot: boolean
227
+ connected: boolean
203
228
  meta: Record<string, unknown>
204
229
  }> {
205
230
  pruneStaleSessions()
@@ -213,6 +238,7 @@ export function qdadmDebugPlugin(options: QdadmDebugPluginOptions = {}): Plugin
213
238
  ageMs: now - s.lastSeenAt,
214
239
  hasDescribe: !!s.describe,
215
240
  hasSnapshot: !!s.snapshot,
241
+ connected: s.disconnectedAt === null,
216
242
  meta: s.meta,
217
243
  }))
218
244
  }
@@ -227,10 +253,12 @@ export function qdadmDebugPlugin(options: QdadmDebugPluginOptions = {}): Plugin
227
253
  describe: null,
228
254
  snapshot: null,
229
255
  meta: meta ?? {},
256
+ disconnectedAt: null,
230
257
  }
231
258
  sessions.set(id, s)
232
259
  } else {
233
260
  s.lastSeenAt = Date.now()
261
+ s.disconnectedAt = null
234
262
  if (meta) s.meta = { ...s.meta, ...meta }
235
263
  }
236
264
  return s
@@ -287,7 +315,7 @@ export function qdadmDebugPlugin(options: QdadmDebugPluginOptions = {}): Plugin
287
315
  } else if (msg.type === 'snapshot' && msg.data !== undefined) {
288
316
  session.snapshot = { data: msg.data, at }
289
317
  } else if (msg.type === 'bye') {
290
- sessions.delete(msg.sessionId)
318
+ session.disconnectedAt = at
291
319
  }
292
320
  })
293
321
 
@@ -376,7 +404,8 @@ function readBody(req: IncomingMessage): Promise<string> {
376
404
  * Client-side bridge — injected verbatim into index.html as an ES module.
377
405
  * Lives in Vite's HMR pipeline (so `import.meta.hot` works here).
378
406
  *
379
- * Each tab gets its own session id (random uuid). Pushes describe + snapshot
407
+ * Each tab gets its own session id (random uuid, kept across reloads in
408
+ * sessionStorage — #2231). Pushes describe + snapshot
380
409
  * on every reactive tick of the bridge so the plugin's per-session cache
381
410
  * stays fresh. Listens for ad-hoc requests targeted at this session.
382
411
  */
@@ -413,7 +442,14 @@ const hot = import.meta.hot
413
442
  if (!hot) {
414
443
  console.warn('[qdadm-debug] import.meta.hot unavailable in injected script')
415
444
  } else {
416
- const sessionId = crypto.randomUUID()
445
+ // Same tab, same session across reloads (#2231): an agent's target survives F5.
446
+ const sessionId = (() => {
447
+ try {
448
+ let v = sessionStorage.getItem('qdadm-debug:session')
449
+ if (!v) { v = crypto.randomUUID(); sessionStorage.setItem('qdadm-debug:session', v) }
450
+ return v
451
+ } catch (e) { return crypto.randomUUID() }
452
+ })()
417
453
  const meta = { userAgent: navigator.userAgent, location: location.pathname }
418
454
  let bridge = null
419
455
  let lastTick = -1
@@ -118,6 +118,8 @@ export function qdadmVitePlugin(options: QdadmVitePluginOptions = {}): Plugin {
118
118
  exclude: [...widgetExcludes, ...themesExcludes, '@quazardous/qdadm'],
119
119
  },
120
120
  ...(fsAllow.length > 0 ? { server: { fs: { allow: fsAllow } } } : {}),
121
+ // The kernel checks it: an app without this plugin is told what to add (#2259).
122
+ define: { __QDADM_VITE_PLUGIN__: 'true' },
121
123
  }
122
124
  },
123
125
  }