@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.
- package/dist/node/vite/qdadmDebugPlugin.d.ts +9 -4
- package/dist/node/vite/qdadmDebugPlugin.js +36 -10
- package/dist/node/vite/qdadmPlugin.js +2 -0
- package/dist/styles/qdadm.css +1 -0
- package/package.json +10 -4
- package/src/components/edit/FormField.vue +51 -4
- package/src/components/edit/FormInput.vue +44 -2
- package/src/components/edit/FormPage.vue +7 -2
- package/src/components/edit/formFieldIds.ts +11 -0
- package/src/components/editors/RoleGrantsEditor.vue +355 -0
- package/src/components/index.ts +1 -0
- package/src/components/layout/AppLayout.vue +3 -1
- package/src/components/layout/BaseLayout.vue +3 -1
- package/src/components/show/ShowPage.vue +4 -2
- package/src/composables/confirmDelete.ts +35 -0
- package/src/composables/index.ts +1 -0
- package/src/composables/useEntityItemFormPage.ts +2 -3
- package/src/composables/useEntityItemFormPage.types.ts +1 -0
- package/src/composables/useEntityItemShowPage.ts +3 -3
- package/src/composables/useGuardStore.ts +7 -1
- package/src/composables/useListPage.ts +3 -6
- package/src/composables/useListPage.types.ts +1 -0
- package/src/composables/useSecurity.ts +41 -0
- package/src/i18n/defaults/DefaultCoreProvider.ts +4 -2
- package/src/i18n/defaults/__tests__/generated-sync.test.ts +9 -0
- package/src/i18n/defaults/core.en.generated.ts +20 -0
- package/src/i18n/defaults/core.en.yml +20 -0
- package/src/i18n/defaults/core.fr.generated.ts +20 -0
- package/src/i18n/defaults/core.fr.yml +20 -0
- package/src/index.ts +1 -0
- package/src/kernel/Kernel.routing.ts +20 -4
- package/src/kernel/Kernel.ts +3 -0
- package/src/kernel/Kernel.vue.ts +3 -0
- package/src/kernel/chunkReload.ts +110 -0
- package/src/kernel/vitePluginCheck.ts +61 -0
- package/src/modules/debug/DebugModule.ts +9 -0
- package/src/notifications/NotificationListener.vue +2 -1
- package/src/security/index.ts +17 -0
- package/src/security/pages/RoleForm.vue +42 -191
- package/src/security/roleGrants.ts +210 -0
- package/src/styles/_forms.scss +10 -2
- package/src/styles/index.d.ts +3 -4
- package/src/toast/ToastListener.vue +2 -1
- package/src/types/index.ts +10 -0
- package/src/vite/qdadmDebugPlugin.ts +46 -10
- 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>
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/composables/index.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}
|
|
@@ -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
|
|
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
|
})
|