@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
@@ -1,59 +1,35 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
3
  * RoleForm - Role create/edit form (standard FormPage pattern)
4
+ *
5
+ * The composition is edited with RoleGrantsEditor (#2313), fed by the roles provider: here qdadm judges roles in
6
+ * the front, so it computes what the inherited roles bring itself.
4
7
  */
5
8
 
6
- import { ref, computed, inject } from 'vue'
7
- import { useEntityItemFormPage, FormPage, useOrchestrator, PermissionEditor } from '../../index.js'
9
+ import { computed } from 'vue'
10
+ import { useEntityItemFormPage, FormPage, useOrchestrator, RoleGrantsEditor } from '../../index.js'
8
11
  import InputText from 'primevue/inputtext'
9
- import AutoComplete from 'primevue/autocomplete'
10
- import Chip from 'primevue/chip'
11
- import type { PermissionRegistry } from '../PermissionRegistry'
12
+ import {
13
+ inheritedGrants,
14
+ type InheritableRole,
15
+ type RoleComposition,
16
+ type RoleHierarchySource,
17
+ } from '../roleGrants'
12
18
 
13
19
  /**
14
- * Role option for autocomplete
20
+ * What the form reads from the roles provider
15
21
  */
16
- interface RoleOption {
17
- name: string
18
- label: string
19
- display: string
20
- }
21
-
22
- /**
23
- * Role data from roles provider
24
- */
25
- interface RoleData {
26
- label?: string
27
- [key: string]: unknown
28
- }
29
-
30
- /**
31
- * RolesProvider interface
32
- */
33
- interface RolesProviderInterface {
22
+ interface RolesProviderInterface extends RoleHierarchySource {
34
23
  getRoles: () => string[]
35
- getRole?: (name: string) => RoleData | null
24
+ getRole?: (name: string) => { label?: string } | null
25
+ getRoleMeta?: (name: string) => { label?: string; description?: string } | null
36
26
  }
37
27
 
38
28
  /**
39
29
  * Roles manager interface
40
30
  */
41
31
  interface RolesManager {
42
- rolesProvider?: RolesProviderInterface
43
- }
44
-
45
- /**
46
- * Autocomplete search event
47
- */
48
- interface AutocompleteSearchEvent {
49
- query?: string
50
- }
51
-
52
- /**
53
- * Autocomplete select event
54
- */
55
- interface AutocompleteSelectEvent {
56
- value: RoleOption
32
+ rolesProvider?: RolesProviderInterface | null
57
33
  }
58
34
 
59
35
  /**
@@ -73,61 +49,27 @@ const form = useEntityItemFormPage({ entity: 'roles' })
73
49
  // ============ HELPERS ============
74
50
  const { getManager } = useOrchestrator()
75
51
  const manager = getManager('roles') as RolesManager | null
76
-
77
- // Get permissionRegistry directly from Kernel (via provide/inject)
78
- const permissionRegistry = inject<PermissionRegistry | null>('qdadmPermissionRegistry', null)
79
-
80
- // Role options for inheritance (exclude self)
81
- const allRoles = computed<RoleOption[]>(() => {
82
- const currentName = form.data.value?.name as string | undefined
83
- const roles = manager?.rolesProvider?.getRoles() || []
84
- return roles
85
- .filter((name: string) => name !== currentName)
86
- .map((name: string) => {
87
- const role = manager?.rolesProvider?.getRole?.(name)
88
- return {
89
- name,
90
- label: role?.label || name,
91
- display: `${name} (${role?.label || name})`
92
- }
93
- })
52
+ const provider = computed(() => manager?.rolesProvider ?? null)
53
+
54
+ const roles = computed<InheritableRole[]>(() =>
55
+ (provider.value?.getRoles() ?? []).map((name: string) => {
56
+ const meta = provider.value?.getRoleMeta?.(name)
57
+ return { name, label: provider.value?.getRole?.(name)?.label || meta?.label || name, description: meta?.description }
58
+ })
59
+ )
60
+
61
+ const composition = computed<RoleComposition>({
62
+ get: () => {
63
+ const data = form.data.value as RoleFormData
64
+ return { inherits: data.inherits || [], permissions: data.permissions || [] }
65
+ },
66
+ set: (value: RoleComposition) => {
67
+ form.data.value.inherits = value.inherits
68
+ form.data.value.permissions = value.permissions
69
+ },
94
70
  })
95
71
 
96
- // Autocomplete for roles
97
- const roleInput = ref<string>('')
98
- const roleSuggestions = ref<RoleOption[]>([])
99
-
100
- function searchRoles(event: AutocompleteSearchEvent): void {
101
- const query = (event.query || '').toLowerCase()
102
- const selected = (form.data.value.inherits as string[]) || []
103
-
104
- roleSuggestions.value = allRoles.value
105
- .filter((r: RoleOption) => !selected.includes(r.name))
106
- .filter((r: RoleOption) =>
107
- query === '' ||
108
- r.name.toLowerCase().includes(query) ||
109
- r.label.toLowerCase().includes(query)
110
- )
111
- }
112
-
113
- function onRoleSelect(event: AutocompleteSelectEvent): void {
114
- const role = event.value
115
- const inherits = (form.data.value.inherits as string[]) || []
116
- if (role && !inherits.includes(role.name)) {
117
- form.data.value.inherits = [...inherits, role.name]
118
- }
119
- roleInput.value = ''
120
- }
121
-
122
- function removeRole(roleName: string): void {
123
- const inherits = (form.data.value.inherits as string[]) || []
124
- form.data.value.inherits = inherits.filter((r: string) => r !== roleName)
125
- }
126
-
127
- function getRoleLabel(roleName: string): string {
128
- const role = allRoles.value.find((r: RoleOption) => r.name === roleName)
129
- return role?.label || roleName
130
- }
72
+ const inherited = computed(() => inheritedGrants(provider.value, composition.value.inherits))
131
73
  </script>
132
74
 
133
75
  <template>
@@ -156,56 +98,14 @@ function getRoleLabel(roleName: string): string {
156
98
  />
157
99
  </div>
158
100
 
159
- <!-- Inherits -->
160
- <div class="form-field">
161
- <label class="font-medium">Inherits From</label>
162
- <div class="inherits-editor">
163
- <div v-if="((form.data.value as RoleFormData).inherits || []).length > 0" class="inherits-chips">
164
- <Chip
165
- v-for="roleName in (form.data.value as RoleFormData).inherits"
166
- :key="roleName"
167
- removable
168
- @remove="removeRole(roleName)"
169
- class="inherits-chip"
170
- >
171
- <span class="chip-name">{{ roleName }}</span>
172
- <span class="chip-label">({{ getRoleLabel(roleName) }})</span>
173
- </Chip>
174
- </div>
175
- <AutoComplete
176
- v-model="roleInput"
177
- :suggestions="roleSuggestions"
178
- optionLabel="display"
179
- placeholder="Type role name..."
180
- :minLength="0"
181
- completeOnFocus
182
- @complete="searchRoles"
183
- @item-select="onRoleSelect"
184
- dropdown
185
- class="w-full"
186
- >
187
- <template #option="{ option }">
188
- <div class="role-option">
189
- <span class="role-name">{{ option.name }}</span>
190
- <span class="role-label">{{ option.label }}</span>
191
- </div>
192
- </template>
193
- </AutoComplete>
194
- </div>
195
- <small class="field-hint">
196
- This role inherits all permissions from selected roles
197
- </small>
198
- </div>
199
-
200
- <!-- Permissions -->
101
+ <!-- Inherits + permissions -->
201
102
  <div class="form-field">
202
- <label class="font-medium">
203
- Permissions ({{ ((form.data.value as RoleFormData).permissions || []).length }})
204
- </label>
205
- <PermissionEditor
206
- v-model="(form.data.value as RoleFormData).permissions"
207
- :permissionRegistry="permissionRegistry"
208
- placeholder="Type namespace:action..."
103
+ <label class="font-medium">Permissions</label>
104
+ <RoleGrantsEditor
105
+ v-model="composition"
106
+ :roles="roles"
107
+ :inherited="inherited"
108
+ :self="(form.data.value as RoleFormData).name"
209
109
  />
210
110
  </div>
211
111
  </div>
@@ -224,53 +124,4 @@ function getRoleLabel(roleName: string): string {
224
124
  flex-direction: column;
225
125
  gap: 1.5rem;
226
126
  }
227
-
228
- /* Inherits editor - uses .editor-box pattern */
229
- .inherits-editor {
230
- border: 1px solid var(--p-surface-200);
231
- border-radius: 0.5rem;
232
- padding: 0.75rem;
233
- background: var(--p-surface-50);
234
- }
235
-
236
- /* Inherits chips - uses .editor-chips pattern */
237
- .inherits-chips {
238
- display: flex;
239
- flex-wrap: wrap;
240
- gap: 0.5rem;
241
- margin-bottom: 0.75rem;
242
- padding-bottom: 0.75rem;
243
- border-bottom: 1px solid var(--p-surface-200);
244
- }
245
-
246
- .inherits-chip {
247
- font-family: monospace;
248
- font-size: 0.875rem;
249
- }
250
-
251
- .chip-name {
252
- font-weight: 500;
253
- }
254
-
255
- .chip-label {
256
- color: var(--p-surface-500);
257
- margin-left: 0.25rem;
258
- }
259
-
260
- .role-option {
261
- display: flex;
262
- justify-content: space-between;
263
- align-items: center;
264
- gap: 1rem;
265
- }
266
-
267
- .role-name {
268
- font-family: monospace;
269
- font-weight: 500;
270
- }
271
-
272
- .role-label {
273
- color: var(--p-surface-500);
274
- font-size: 0.875rem;
275
- }
276
127
  </style>
@@ -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?: {