@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
@@ -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
 
@@ -19,7 +19,7 @@ import { useNavigation, type NavSection } from '../../composables/useNavigation'
19
19
  import { prettifyRole } from '../../utils/formatters'
20
20
  import { useApp } from '../../composables/useApp'
21
21
  import { useAuth } from '../../composables/useAuth'
22
- import { useGuardDialog } from '../../composables/useGuardStore'
22
+ import { useGuardDialog, GUARD_DIALOG_HOST } from '../../composables/useGuardStore'
23
23
  import { useNavContext, type BreadcrumbItem, type NavLinkItem } from '../../composables/useNavContext'
24
24
  import QdButton from '../base/QdButton.vue'
25
25
  import Breadcrumb from 'primevue/breadcrumb'
@@ -56,6 +56,8 @@ const homeRoute = inject<string>('qdadmHomeRoute', 'home')
56
56
 
57
57
  // Guard dialog from shared store (registered by useBareForm/useEntityItemFormPage when a form is active)
58
58
  const guardDialog = useGuardDialog()
59
+ // This layout renders it: pages below do not render their own (#2267)
60
+ provide(GUARD_DIALOG_HOST, true)
59
61
 
60
62
  const router = useRouter()
61
63
  const route = useRoute()
@@ -35,7 +35,7 @@ import { RouterView } from 'vue-router'
35
35
  import ConfirmDialog from 'primevue/confirmdialog'
36
36
  import Zone from './Zone.vue'
37
37
  import { LAYOUT_ZONES } from '../../zones/zones'
38
- import { useGuardDialog } from '../../composables/useGuardStore'
38
+ import { useGuardDialog, GUARD_DIALOG_HOST } from '../../composables/useGuardStore'
39
39
  import UnsavedChangesDialog from '../dialogs/UnsavedChangesDialog.vue'
40
40
  import type { BreadcrumbItem, NavLinkItem } from '../../composables/useNavContext'
41
41
 
@@ -52,6 +52,8 @@ const hasMainSlot: boolean = !!slots.main
52
52
 
53
53
  // Guard dialog from shared store (registered by useBareForm/useEntityItemFormPage when a form is active)
54
54
  const guardDialog = useGuardDialog()
55
+ // This layout renders it: pages below do not render their own (#2267)
56
+ provide(GUARD_DIALOG_HOST, true)
55
57
 
56
58
  // Provide breadcrumb/navlinks override mechanism for child pages
57
59
  const breadcrumbOverride = ref<BreadcrumbItem[] | null>(null)
@@ -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 {
@@ -5,7 +5,13 @@
5
5
  * and parent components (AppLayout) to render it.
6
6
  */
7
7
 
8
- import { shallowRef, type ShallowRef, type Ref } from 'vue'
8
+ import { shallowRef, type InjectionKey, type ShallowRef, type Ref } from 'vue'
9
+
10
+ /**
11
+ * Provided by a layout that renders the registered guard dialog (#2267). FormPage renders its own dialog only
12
+ * without one above it, so a dirty form never shows the dialog twice.
13
+ */
14
+ export const GUARD_DIALOG_HOST: InjectionKey<boolean> = Symbol('qdadm.guardDialogHost')
9
15
 
10
16
  /**
11
17
  * Guard dialog interface
@@ -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
  })