@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,355 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * RoleGrantsEditor — a role's composition, readable (#2313): the roles it inherits, the entity × action matrix and
4
+ * the named grants of the permission registry, and where every checked grant comes from (its own key, one of its
5
+ * wildcards, or an inherited role).
6
+ *
7
+ * It judges nothing. The roles to inherit from and what they bring (`inherited`) come from the app: computed by
8
+ * qdadm's roles provider (see `inheritedGrants`), or sent by the app's server. It emits the role's own composition.
9
+ */
10
+ import { computed, inject } from 'vue'
11
+ import {
12
+ composeGrants,
13
+ setGrant,
14
+ type GrantCell,
15
+ type GrantDefinition,
16
+ type GrantOrigin,
17
+ type GrantRow,
18
+ type InheritableRole,
19
+ type InheritedGrant,
20
+ type RoleComposition,
21
+ } from '../../security/roleGrants'
22
+ import { useI18n } from '../../i18n/useI18n'
23
+ import PermissionEditor from './PermissionEditor.vue'
24
+
25
+ const props = withDefaults(
26
+ defineProps<{
27
+ modelValue: RoleComposition
28
+ /** The grants to compose from. Default: the kernel's permission registry. */
29
+ grants?: GrantDefinition[] | null
30
+ /** The roles this one can inherit from. */
31
+ roles?: InheritableRole[]
32
+ /** What the inherited roles bring, with the role that declares each grant. */
33
+ inherited?: InheritedGrant[]
34
+ /** This role's name: left out of the roles to inherit from. */
35
+ self?: string | null
36
+ readonly?: boolean
37
+ }>(),
38
+ { grants: null, roles: () => [], inherited: () => [], self: null, readonly: false }
39
+ )
40
+
41
+ const emit = defineEmits<{ 'update:modelValue': [value: RoleComposition] }>()
42
+
43
+ const { t } = useI18n()
44
+ const registry = inject<{ getAll(): GrantDefinition[] } | null>('qdadmPermissionRegistry', null)
45
+
46
+ const grantList = computed<GrantDefinition[]>(() => props.grants ?? registry?.getAll() ?? [])
47
+ const inherits = computed<string[]>(() => props.modelValue?.inherits ?? [])
48
+ const permissions = computed<string[]>(() => props.modelValue?.permissions ?? [])
49
+ const model = computed(() =>
50
+ composeGrants({ grants: grantList.value, permissions: permissions.value, inherited: props.inherited })
51
+ )
52
+
53
+ const update = (next: Partial<RoleComposition>) =>
54
+ emit('update:modelValue', { inherits: [...inherits.value], permissions: [...permissions.value], ...next })
55
+ const isOn = (event: Event) => (event.target as HTMLInputElement).checked
56
+
57
+ function setPermission(key: string, on: boolean): void {
58
+ if (!props.readonly) update({ permissions: setGrant(permissions.value, key, on) })
59
+ }
60
+
61
+ function setInherit(role: string, on: boolean): void {
62
+ if (props.readonly) return
63
+ const current = inherits.value
64
+ update({ inherits: on ? (current.includes(role) ? current : [...current, role]) : current.filter((r) => r !== role) })
65
+ }
66
+
67
+ function addKeys(keys: string[]): void {
68
+ const added = keys.filter((k) => !permissions.value.includes(k))
69
+ if (!props.readonly && added.length > 0) update({ permissions: [...permissions.value, ...added] })
70
+ }
71
+
72
+ /** The roles to offer, plus any the role already inherits that the list does not know. */
73
+ const roleList = computed<InheritableRole[]>(() => {
74
+ const listed = props.roles.filter((r) => r.name !== props.self)
75
+ const unknown = inherits.value.filter((name) => !listed.some((r) => r.name === name)).map((name) => ({ name }))
76
+ return [...listed, ...unknown]
77
+ })
78
+ const brings = (role: string) => props.inherited.filter((g) => g.via === role).length
79
+
80
+ const checked = (origin: GrantOrigin) => origin.kind !== 'none'
81
+ const editable = (origin: GrantOrigin) => !props.readonly && (origin.kind === 'own' || origin.kind === 'none')
82
+ const viaOf = (origin: GrantOrigin) =>
83
+ origin.kind === 'wildcard' || origin.kind === 'inherited' ? t('core.roles.via', { origin: origin.via }) : ''
84
+ const titleOf = (cell: GrantCell) => [cell.label, cell.description, viaOf(cell.origin)].filter(Boolean).join(' — ')
85
+ const cellsOf = (row: GrantRow) => model.value.actions.map((action) => ({ action, cell: row.cells[action] ?? null }))
86
+ const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
87
+
88
+ /** The same grants, in the shape PermissionEditor reads to suggest keys. */
89
+ const editorRegistry = computed(() => {
90
+ const list = grantList.value.map((g) => ({ key: g.key, namespace: g.namespace, action: g.action, label: g.label || g.key }))
91
+ return { getAll: () => list, get: (key: string) => list.find((g) => g.key === key) }
92
+ })
93
+ </script>
94
+
95
+ <template>
96
+ <div class="role-grants" :class="{ 'role-grants-readonly': readonly }">
97
+ <section class="role-grants-section">
98
+ <h4 class="role-grants-title">{{ t('core.roles.inherits') }}</h4>
99
+ <p v-if="roleList.length === 0" class="role-grants-hint">{{ t('core.roles.noRoles') }}</p>
100
+ <ul v-else class="role-grants-roles">
101
+ <li v-for="role in roleList" :key="role.name">
102
+ <label class="role-grants-role" :title="role.description">
103
+ <input
104
+ type="checkbox"
105
+ :data-inherit="role.name"
106
+ :checked="inherits.includes(role.name)"
107
+ :disabled="readonly"
108
+ @change="setInherit(role.name, isOn($event))"
109
+ />
110
+ <span class="role-grants-role-label">{{ role.label || role.name }}</span>
111
+ <code class="role-grants-key">{{ role.name }}</code>
112
+ <span v-if="inherits.includes(role.name)" class="role-grants-via">
113
+ {{ t('core.roles.brings', { count: brings(role.name) }) }}
114
+ </span>
115
+ </label>
116
+ </li>
117
+ </ul>
118
+ <p class="role-grants-hint">{{ t('core.roles.inheritsHint') }}</p>
119
+ </section>
120
+
121
+ <section v-if="model.rows.length > 0" class="role-grants-section">
122
+ <h4 class="role-grants-title">{{ t('core.roles.entities') }}</h4>
123
+ <div class="role-grants-scroll">
124
+ <table class="role-grants-matrix">
125
+ <thead>
126
+ <tr>
127
+ <th scope="col">{{ t('core.roles.entity') }}</th>
128
+ <th scope="col">{{ t('core.roles.all') }}</th>
129
+ <th v-for="action in model.actions" :key="action" scope="col">{{ capitalize(action) }}</th>
130
+ </tr>
131
+ </thead>
132
+ <tbody>
133
+ <tr v-for="row in model.rows" :key="row.namespace" :data-grant-row="row.namespace">
134
+ <th scope="row">
135
+ {{ row.entity }}<span v-if="row.ownRecords" class="role-grants-own"> ({{ t('core.roles.ownRecords') }})</span>
136
+ </th>
137
+ <td>
138
+ <label class="role-grants-cell" :title="viaOf(row.wildcardOrigin) || row.wildcard">
139
+ <input
140
+ type="checkbox"
141
+ :data-grant="row.wildcard"
142
+ :aria-label="`${row.entity}: ${t('core.roles.all')}`"
143
+ :checked="checked(row.wildcardOrigin)"
144
+ :disabled="!editable(row.wildcardOrigin)"
145
+ @change="setPermission(row.wildcard, isOn($event))"
146
+ />
147
+ <span v-if="viaOf(row.wildcardOrigin)" class="role-grants-via">{{ viaOf(row.wildcardOrigin) }}</span>
148
+ </label>
149
+ </td>
150
+ <td v-for="{ action, cell } in cellsOf(row)" :key="action">
151
+ <label v-if="cell" class="role-grants-cell" :title="titleOf(cell)">
152
+ <input
153
+ type="checkbox"
154
+ :data-grant="cell.key"
155
+ :aria-label="cell.label"
156
+ :checked="checked(cell.origin)"
157
+ :disabled="!editable(cell.origin)"
158
+ @change="setPermission(cell.key, isOn($event))"
159
+ />
160
+ <span v-if="viaOf(cell.origin)" class="role-grants-via">{{ viaOf(cell.origin) }}</span>
161
+ </label>
162
+ </td>
163
+ </tr>
164
+ </tbody>
165
+ </table>
166
+ </div>
167
+ </section>
168
+
169
+ <section
170
+ v-for="group in model.groups"
171
+ :key="group.namespace"
172
+ class="role-grants-section"
173
+ :data-grant-group="group.namespace"
174
+ >
175
+ <div class="role-grants-group-head">
176
+ <h4 class="role-grants-title"><code>{{ group.namespace }}</code></h4>
177
+ <label class="role-grants-cell role-grants-group-all" :title="group.wildcard">
178
+ <input
179
+ type="checkbox"
180
+ :data-grant="group.wildcard"
181
+ :aria-label="`${group.namespace}: ${t('core.roles.all')}`"
182
+ :checked="checked(group.wildcardOrigin)"
183
+ :disabled="!editable(group.wildcardOrigin)"
184
+ @change="setPermission(group.wildcard, isOn($event))"
185
+ />
186
+ <span>{{ t('core.roles.all') }}</span>
187
+ <span v-if="viaOf(group.wildcardOrigin)" class="role-grants-via">{{ viaOf(group.wildcardOrigin) }}</span>
188
+ </label>
189
+ </div>
190
+ <ul class="role-grants-list">
191
+ <li v-for="grant in group.grants" :key="grant.key">
192
+ <label class="role-grants-grant" :title="grant.key">
193
+ <input
194
+ type="checkbox"
195
+ :data-grant="grant.key"
196
+ :checked="checked(grant.origin)"
197
+ :disabled="!editable(grant.origin)"
198
+ @change="setPermission(grant.key, isOn($event))"
199
+ />
200
+ <span class="role-grants-grant-label">{{ grant.label }}</span>
201
+ <span v-if="grant.description" class="role-grants-hint">{{ grant.description }}</span>
202
+ <span v-if="viaOf(grant.origin)" class="role-grants-via">{{ viaOf(grant.origin) }}</span>
203
+ </label>
204
+ </li>
205
+ </ul>
206
+ </section>
207
+
208
+ <section class="role-grants-section">
209
+ <h4 class="role-grants-title">{{ t('core.roles.others') }}</h4>
210
+ <p class="role-grants-hint">{{ t('core.roles.othersHint') }}</p>
211
+ <ul v-if="model.others.length > 0" class="role-grants-list">
212
+ <li v-for="other in model.others" :key="other.key" class="role-grants-other" :data-grant-other="other.key">
213
+ <code class="role-grants-key">{{ other.key }}</code>
214
+ <span class="role-grants-via">
215
+ {{ other.covers > 0 ? t('core.roles.covers', { count: other.covers }) : t('core.roles.notRegistered') }}
216
+ </span>
217
+ <button
218
+ v-if="!readonly"
219
+ type="button"
220
+ class="role-grants-remove"
221
+ :aria-label="`${t('core.roles.remove')} ${other.key}`"
222
+ @click="setPermission(other.key, false)"
223
+ >
224
+ <i class="pi pi-times" />
225
+ </button>
226
+ </li>
227
+ </ul>
228
+ <PermissionEditor v-if="!readonly" :model-value="[]" :permission-registry="editorRegistry" @update:model-value="addKeys" />
229
+ </section>
230
+ </div>
231
+ </template>
232
+
233
+ <style scoped>
234
+ .role-grants {
235
+ display: flex;
236
+ flex-direction: column;
237
+ gap: 1rem;
238
+ }
239
+ .role-grants-section {
240
+ display: flex;
241
+ flex-direction: column;
242
+ gap: 0.5rem;
243
+ padding: 0.75rem;
244
+ border: 1px solid var(--p-surface-200);
245
+ border-radius: 0.5rem;
246
+ background: var(--p-surface-50);
247
+ }
248
+ .role-grants-title {
249
+ margin: 0;
250
+ font-size: 0.9rem;
251
+ font-weight: 600;
252
+ }
253
+ .role-grants-hint {
254
+ margin: 0;
255
+ color: var(--p-surface-500);
256
+ font-size: 0.8rem;
257
+ }
258
+ .role-grants-key {
259
+ font-family: monospace;
260
+ font-size: 0.8rem;
261
+ color: var(--p-surface-600);
262
+ }
263
+ .role-grants-via {
264
+ color: var(--p-primary-600, var(--p-surface-600));
265
+ font-size: 0.72rem;
266
+ font-style: italic;
267
+ white-space: nowrap;
268
+ }
269
+ .role-grants-roles,
270
+ .role-grants-list {
271
+ display: flex;
272
+ flex-direction: column;
273
+ gap: 0.35rem;
274
+ margin: 0;
275
+ padding: 0;
276
+ list-style: none;
277
+ }
278
+ .role-grants-role,
279
+ .role-grants-grant {
280
+ display: flex;
281
+ flex-wrap: wrap;
282
+ align-items: baseline;
283
+ gap: 0.5rem;
284
+ }
285
+ .role-grants-role-label,
286
+ .role-grants-grant-label {
287
+ font-weight: 500;
288
+ }
289
+ .role-grants-scroll {
290
+ overflow-x: auto;
291
+ }
292
+ .role-grants-matrix {
293
+ border-collapse: collapse;
294
+ width: 100%;
295
+ }
296
+ .role-grants-matrix th,
297
+ .role-grants-matrix td {
298
+ padding: 0.35rem 0.6rem;
299
+ border-bottom: 1px solid var(--p-surface-200);
300
+ text-align: center;
301
+ vertical-align: top;
302
+ }
303
+ .role-grants-matrix th[scope='row'],
304
+ .role-grants-matrix thead th:first-child {
305
+ text-align: left;
306
+ white-space: nowrap;
307
+ }
308
+ .role-grants-cell {
309
+ display: inline-flex;
310
+ flex-direction: column;
311
+ align-items: center;
312
+ gap: 0.1rem;
313
+ }
314
+ .role-grants-own {
315
+ color: var(--p-surface-500);
316
+ font-weight: 400;
317
+ }
318
+ .role-grants-group-head {
319
+ display: flex;
320
+ flex-wrap: wrap;
321
+ align-items: center;
322
+ justify-content: space-between;
323
+ gap: 0.5rem;
324
+ }
325
+ .role-grants-group-all {
326
+ flex-direction: row;
327
+ gap: 0.35rem;
328
+ }
329
+ .role-grants-other {
330
+ display: flex;
331
+ align-items: center;
332
+ gap: 0.5rem;
333
+ }
334
+ .role-grants-remove {
335
+ padding: 0.1rem 0.3rem;
336
+ border: 0;
337
+ background: transparent;
338
+ color: var(--p-surface-500);
339
+ cursor: pointer;
340
+ }
341
+ .role-grants-remove:hover {
342
+ color: var(--p-red-500);
343
+ }
344
+ /* A box is a box, whatever layout surrounds it. */
345
+ .role-grants input[type='checkbox'] {
346
+ display: inline-block;
347
+ flex: none;
348
+ width: auto;
349
+ height: auto;
350
+ margin: 0;
351
+ }
352
+ input[type='checkbox']:disabled {
353
+ cursor: not-allowed;
354
+ }
355
+ </style>
@@ -51,6 +51,7 @@ export { default as KeyValueEditor } from './editors/KeyValueEditor.vue'
51
51
  export { default as LanguageEditor } from './editors/LanguageEditor.vue'
52
52
  export { default as ScopeEditor } from './editors/ScopeEditor.vue'
53
53
  export { default as PermissionEditor } from './editors/PermissionEditor.vue'
54
+ export { default as RoleGrantsEditor } from './editors/RoleGrantsEditor.vue'
54
55
  export { default as JsonEditorFoldable } from './editors/JsonEditorFoldable.vue'
55
56
  export { default as JsonViewer } from './editors/JsonViewer.vue'
56
57
 
@@ -313,15 +313,17 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
313
313
  display: block;
314
314
  }
315
315
 
316
+ /* minmax(0, 1fr), not 1fr: a bare 1fr track grows to its longest unbreakable
317
+ content (a URL, a <pre>) and pushes the card past its container (#2245). */
316
318
  .show-content--with-media {
317
319
  display: grid;
318
- grid-template-columns: var(--media-width, 200px) 1fr;
320
+ grid-template-columns: var(--media-width, 200px) minmax(0, 1fr);
319
321
  gap: 2rem;
320
322
  }
321
323
 
322
324
  @media (max-width: 768px) {
323
325
  .show-content--with-media {
324
- grid-template-columns: 1fr;
326
+ grid-template-columns: minmax(0, 1fr);
325
327
  }
326
328
  }
327
329
 
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The delete confirmations of the list, form and show pages (#2269).
3
+ *
4
+ * PrimeVue's ConfirmDialog closes on Escape only when the confirmation itself says so. Escape hides the
5
+ * dialog: `accept` never runs, so nothing is deleted.
6
+ */
7
+
8
+ export interface DeleteConfirmation {
9
+ message: string
10
+ header: string
11
+ accept: () => void
12
+ }
13
+
14
+ /** The part of PrimeVue's confirm service a delete confirmation uses. */
15
+ export interface ConfirmRequire {
16
+ require: (options: {
17
+ message: string
18
+ header: string
19
+ icon: string
20
+ acceptClass?: string
21
+ closeOnEscape?: boolean
22
+ accept: () => void
23
+ }) => void
24
+ }
25
+
26
+ export function requireDeleteConfirmation(confirm: ConfirmRequire, { message, header, accept }: DeleteConfirmation): void {
27
+ confirm.require({
28
+ message,
29
+ header,
30
+ icon: 'pi pi-exclamation-triangle',
31
+ acceptClass: 'p-button-danger',
32
+ closeOnEscape: true,
33
+ accept,
34
+ })
35
+ }
@@ -50,6 +50,7 @@ export {
50
50
  export { usePageTitle, type UsePageTitleReturn, type TitleParts, type TitleInput } from './usePageTitle'
51
51
  export { useApp, type UseAppReturn } from './useApp'
52
52
  export { useAuth, type UseAuthReturn } from './useAuth'
53
+ export { useSecurity, type UseSecurityReturn } from './useSecurity'
53
54
  export {
54
55
  useEntityItemPage,
55
56
  type UseEntityItemPageOptions,
@@ -27,6 +27,7 @@
27
27
  import { ref, computed, watch, onMounted, onUnmounted, provide, type Ref } from 'vue'
28
28
  import { useRouter, useRoute } from 'vue-router'
29
29
  import { useConfirm } from 'primevue/useconfirm'
30
+ import { requireDeleteConfirmation } from './confirmDelete'
30
31
  import { useDirtyState } from './useDirtyState'
31
32
  import { useUnsavedChangesGuard, type GuardDialogState } from './useUnsavedChangesGuard'
32
33
  import { useBreadcrumb } from './useBreadcrumb'
@@ -384,11 +385,9 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
384
385
 
385
386
  function confirmDelete(): void {
386
387
  const label = manager.getEntityLabel(data.value) || entityId.value
387
- confirm.require({
388
+ requireDeleteConfirmation(confirm, {
388
389
  message: `Delete ${entityName} "${label}"?`,
389
390
  header: 'Confirm Delete',
390
- icon: 'pi pi-exclamation-triangle',
391
- acceptClass: 'p-button-danger',
392
391
  accept: remove,
393
392
  })
394
393
  }
@@ -159,6 +159,7 @@ export interface ConfirmService {
159
159
  header: string
160
160
  icon: string
161
161
  acceptClass?: string
162
+ closeOnEscape?: boolean
162
163
  accept: () => void
163
164
  }) => void
164
165
  }
@@ -36,6 +36,7 @@
36
36
  import { ref, computed, type Ref, type ComputedRef } from 'vue'
37
37
  import { useRouter, type Router } from 'vue-router'
38
38
  import { useConfirm } from 'primevue/useconfirm'
39
+ import { requireDeleteConfirmation } from './confirmDelete'
39
40
  import {
40
41
  useEntityItemPage,
41
42
  type ParentConfig,
@@ -254,6 +255,7 @@ interface ConfirmService {
254
255
  header: string
255
256
  icon: string
256
257
  acceptClass?: string
258
+ closeOnEscape?: boolean
257
259
  accept: () => void
258
260
  }) => void
259
261
  }
@@ -411,11 +413,9 @@ export function useEntityItemShowPage<T = Record<string, unknown>>(
411
413
  severity: 'danger',
412
414
  onClick: () => {
413
415
  if (confirmDelete) {
414
- confirm.require({
416
+ requireDeleteConfirmation(confirm, {
415
417
  message: `Are you sure you want to delete this ${manager.label || entity}?`,
416
418
  header: 'Confirm Delete',
417
- icon: 'pi pi-exclamation-triangle',
418
- acceptClass: 'p-button-danger',
419
419
  accept: () => deleteEntity(),
420
420
  })
421
421
  } else {
@@ -36,6 +36,7 @@ import {
36
36
  type RouteStatePersister,
37
37
  } from '../routeState'
38
38
  import { useConfirm } from 'primevue/useconfirm'
39
+ import { requireDeleteConfirmation } from './confirmDelete'
39
40
  import { useHooks } from './useHooks.js'
40
41
  import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
41
42
  import { useActiveStack } from '../chain/useActiveStack.js'
@@ -1050,11 +1051,9 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
1050
1051
 
1051
1052
  function confirmDelete(item: unknown, labelField = 'name'): void {
1052
1053
  const itemRecord = item as Record<string, unknown>
1053
- confirm.require({
1054
+ requireDeleteConfirmation(confirm, {
1054
1055
  message: `Delete ${entityName} "${itemRecord[labelField] || itemRecord[resolvedDataKey]}"?`,
1055
1056
  header: 'Confirm Delete',
1056
- icon: 'pi pi-exclamation-triangle',
1057
- acceptClass: 'p-button-danger',
1058
1057
  accept: () => deleteItem(item, labelField),
1059
1058
  })
1060
1059
  }
@@ -1103,11 +1102,9 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
1103
1102
 
1104
1103
  function confirmBulkDelete(): void {
1105
1104
  const count = selected.value.length
1106
- confirm.require({
1105
+ requireDeleteConfirmation(confirm, {
1107
1106
  message: `Delete ${count} ${count > 1 ? entityNamePlural : entityName}?`,
1108
1107
  header: 'Confirm Bulk Delete',
1109
- icon: 'pi pi-exclamation-triangle',
1110
- acceptClass: 'p-button-danger',
1111
1108
  accept: bulkDelete,
1112
1109
  })
1113
1110
  }
@@ -53,6 +53,7 @@ export interface ConfirmService {
53
53
  header: string
54
54
  icon: string
55
55
  acceptClass?: string
56
+ closeOnEscape?: boolean
56
57
  accept: () => void
57
58
  }) => void
58
59
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * useSecurity - ask for a permission from a component (#2242).
3
+ *
4
+ * `isGranted(attribute, subject?)` gives the entity managers' own verdict:
5
+ * the application's `security.grant` judge first (#2225), then the role
6
+ * matrix. With no security configured it grants, as managers do — a page
7
+ * must never disagree with the list actions next to it.
8
+ *
9
+ * Nothing is cached: every call asks. The app remounts on `security:changed`
10
+ * (and on login and logout), so a `v-if="isGranted('…')"` shows new answers
11
+ * without any listener.
12
+ *
13
+ * Hiding is a convenience, not the protection: the server still decides.
14
+ *
15
+ * Usage:
16
+ * const { isGranted } = useSecurity()
17
+ * <ReplyBox v-if="isGranted('offers:debug:write')" />
18
+ */
19
+
20
+ import { getCurrentInstance, inject } from 'vue'
21
+ import type { SecurityChecker } from '../entity/auth/SecurityChecker'
22
+
23
+ export interface UseSecurityReturn {
24
+ /** The verdict for a permission or a `ROLE_*`, optionally about `subject` (a record). */
25
+ isGranted: (attribute: string, subject?: unknown) => boolean
26
+ }
27
+
28
+ export function useSecurity(): UseSecurityReturn {
29
+ if (!getCurrentInstance()) {
30
+ throw new Error(
31
+ '[qdadm] useSecurity() must be called within component setup() — outside a component, a module asks ctx.security.isGranted()'
32
+ )
33
+ }
34
+
35
+ // null default: an app without security logs no "injection not found" warning.
36
+ const checker = inject<SecurityChecker | null>('qdadmSecurityChecker', null)
37
+
38
+ return {
39
+ isGranted: (attribute, subject = null) => (checker ? checker.isGranted(attribute, subject) : true),
40
+ }
41
+ }
@@ -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
  `