@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
@@ -53,3 +53,23 @@ core:
53
53
  invalid: Valeur invalide
54
54
  notFound: Introuvable
55
55
  unknown: Une erreur inconnue est survenue
56
+ # RoleGrantsEditor (#2313)
57
+ roles:
58
+ inherits: Hérite de
59
+ inheritsHint: Un rôle reçoit toutes les permissions des rôles dont il hérite.
60
+ noRoles: Aucun autre rôle dont hériter
61
+ brings: "apporte {count}"
62
+ entities: Entités
63
+ entity: Entité
64
+ all: Tout
65
+ ownRecords: ses propres enregistrements
66
+ others: Autres permissions
67
+ othersHint: Jokers et clés que le registre ne connaît pas
68
+ covers: "couvre {count}"
69
+ notRegistered: non enregistrée
70
+ remove: Retirer
71
+ via: "via {origin}"
72
+ # The breadcrumb's View↔Edit toggle (useNavContext).
73
+ breadcrumb:
74
+ view: Voir
75
+ edit: Modifier
package/src/index.ts CHANGED
@@ -262,6 +262,7 @@ export {
262
262
  } from './composables/usePageTitle'
263
263
  export { useApp, type UseAppReturn } from './composables/useApp'
264
264
  export { useAuth, type UseAuthReturn } from './composables/useAuth'
265
+ export { useSecurity, type UseSecurityReturn } from './composables/useSecurity'
265
266
  export {
266
267
  useEntityItemPage,
267
268
  type UseEntityItemPageOptions,
@@ -7,6 +7,7 @@ import {
7
7
  type RouteRecordNormalized,
8
8
  } from 'vue-router'
9
9
  import { getRoutes } from '../module/moduleRegistry'
10
+ import { installChunkReload } from './chunkReload'
10
11
  import type { Kernel } from './Kernel'
11
12
  // #1196 Phase B — this-typing against the real Kernel shape (was Self = any)
12
13
  type Self = Kernel
@@ -148,6 +149,20 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
148
149
  })
149
150
  }
150
151
 
152
+ /**
153
+ * A lazy page whose chunk is gone, typically after a deploy (#2295): reload once, then say so.
154
+ */
155
+ proto._setupChunkReload = function (this: Self): void {
156
+ installChunkReload(this.router!, {
157
+ notify: () =>
158
+ this.orchestrator!.toast.error(
159
+ 'A new version is available',
160
+ 'This page could not be loaded. Reload the page to get the new version.',
161
+ { life: 0, emitter: 'Kernel' }
162
+ ),
163
+ })
164
+ }
165
+
151
166
  /**
152
167
  * Setup auth guard on router
153
168
  */
@@ -190,7 +205,9 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
190
205
  )
191
206
 
192
207
  if (requiresAuth && !authAdapter.isAuthenticated()) {
193
- if (wasEverAuthenticated) {
208
+ // Only a session this tab had can be lost: a first visit goes to login without the flag (#2292).
209
+ const sessionLost = wasEverAuthenticated
210
+ if (sessionLost) {
194
211
  const debug = this.options.debug ?? false
195
212
  if (debug) {
196
213
  console.warn(
@@ -204,9 +221,8 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
204
221
  wasEverAuthenticated = false
205
222
  }
206
223
 
207
- return this.router!.hasRoute('login')
208
- ? { name: 'login', query: { session_lost: '1' } }
209
- : '/'
224
+ if (!this.router!.hasRoute('login')) return '/'
225
+ return sessionLost ? { name: 'login', query: { session_lost: '1' } } : { name: 'login' }
210
226
  }
211
227
 
212
228
  const entity = to.meta?.entity as string | undefined
@@ -228,6 +228,7 @@ export class Kernel {
228
228
  this._createRouter()
229
229
  this._setupStackSync()
230
230
  this._setupAuthGuard()
231
+ this._setupChunkReload()
231
232
  this._setupAuthExpiredHandler()
232
233
  this._setupAuthImpersonation()
233
234
  this._setupAuthInvalidation()
@@ -262,6 +263,7 @@ export class Kernel {
262
263
  this._createRouter()
263
264
  this._setupStackSync()
264
265
  this._setupAuthGuard()
266
+ this._setupChunkReload()
265
267
  this._setupAuthExpiredHandler()
266
268
  this._setupAuthImpersonation()
267
269
  this._setupAuthInvalidation()
@@ -467,6 +469,7 @@ export interface Kernel {
467
469
  // Routing (Kernel.routing.ts)
468
470
  _createRouter(): void
469
471
  _setupAuthGuard(): void
472
+ _setupChunkReload(): void
470
473
  _setupStackSync(): void
471
474
  _rebuildActiveStack(route: RouteLocationNormalized): void
472
475
 
@@ -17,6 +17,7 @@ import ToastListener from '../toast/ToastListener.vue'
17
17
  import { createQdadm } from '../plugin.js'
18
18
  import { createNotificationStore, NOTIFICATION_KEY } from '../notifications/NotificationStore'
19
19
  import { I18N_INJECTION_KEY } from '../i18n/useI18n'
20
+ import { warnWithoutQdadmVitePlugin } from './vitePluginCheck'
20
21
  import type { Kernel } from './Kernel'
21
22
  // #1196 Phase B — this-typing against the real Kernel shape (was Self = any)
22
23
  type Self = Kernel
@@ -197,6 +198,8 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
197
198
  * ConfirmationService.
198
199
  */
199
200
  proto._installPlugins = function (this: Self): void {
201
+ // Before PrimeVue: without the vite plugin, PrimeVue is what fails, without naming qdadm (#2259).
202
+ warnWithoutQdadmVitePlugin()
200
203
  const app = this.vueApp!
201
204
  const { authAdapter, features, primevue, existingApp } = this.options
202
205
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -0,0 +1,110 @@
1
+ /**
2
+ * A lazy page whose chunk no longer exists (#2295), typically in a tab left open across a deploy: the build renamed
3
+ * its hashed chunks, the tab still runs the old bundle, and the import 404s. The router aborts the navigation and
4
+ * nothing tells the user; on a first load the screen stays blank.
5
+ *
6
+ * The page reloads once, at the URL the user was going to, which fetches the new index.html. A flag in
7
+ * sessionStorage keeps a chunk that is really missing from reloading in a loop: a second failure is reported
8
+ * instead. The flag is cleared by the next navigation that succeeds.
9
+ */
10
+ import type { Router } from 'vue-router'
11
+
12
+ export const CHUNK_RELOAD_KEY = 'qdadm:chunk-reload'
13
+
14
+ // Chrome, Firefox, Safari, Vite's preload helper, webpack.
15
+ const CHUNK_ERROR =
16
+ /Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed|Unable to preload CSS|ChunkLoadError|Loading chunk \S+ failed/i
17
+
18
+ export function isChunkLoadError(error: unknown): boolean {
19
+ if (!error) return false
20
+ const { name, message } = error as { name?: unknown; message?: unknown }
21
+ return CHUNK_ERROR.test(`${String(name ?? '')} ${typeof message === 'string' ? message : String(error)}`)
22
+ }
23
+
24
+ type FlagStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
25
+
26
+ export interface ChunkReloadOptions {
27
+ /** A second failure, or no way to guard a reload: tell the user. */
28
+ notify(error: unknown): void
29
+ /** Default: sessionStorage. Without one, nothing reloads: a loop could not be stopped. */
30
+ storage?: FlagStorage | null
31
+ /** Default: load `href` for real. */
32
+ reload?(href: string): void
33
+ /** Where Vite dispatches `vite:preloadError`. Default: window. */
34
+ target?: EventTarget | null
35
+ }
36
+
37
+ function sessionFlags(): FlagStorage | null {
38
+ try {
39
+ return typeof sessionStorage === 'undefined' ? null : sessionStorage
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ function reloadAt(href: string): void {
46
+ const url = new URL(href, window.location.href).href
47
+ // The failed navigation never changed the URL; the reload must land where the user was going.
48
+ if (url !== window.location.href) window.history.pushState(null, '', url)
49
+ window.location.reload()
50
+ }
51
+
52
+ /** Returns a function that removes the handlers. */
53
+ export function installChunkReload(router: Router, options: ChunkReloadOptions): () => void {
54
+ const storage = options.storage === undefined ? sessionFlags() : options.storage
55
+ const reload = options.reload ?? reloadAt
56
+ const target = options.target === undefined ? (typeof window === 'undefined' ? null : window) : options.target
57
+ let reloading = false
58
+
59
+ const failed = (error: unknown, href: string | null) => {
60
+ if (reloading) return
61
+ let tried = true
62
+ try {
63
+ tried = !storage || storage.getItem(CHUNK_RELOAD_KEY) !== null
64
+ if (!tried) storage!.setItem(CHUNK_RELOAD_KEY, String(Date.now()))
65
+ } catch {
66
+ tried = true
67
+ }
68
+ if (tried) {
69
+ console.error('[qdadm] a page could not be loaded, and reloading did not help', error)
70
+ options.notify(error)
71
+ return
72
+ }
73
+ reloading = true
74
+ console.warn('[qdadm] a page could not be loaded, probably a new deploy: reloading once', error)
75
+ reload(href ?? (typeof window === 'undefined' ? '/' : window.location.href))
76
+ }
77
+
78
+ let routerSaw = false
79
+ const removeOnError = router.onError((error, to) => {
80
+ if (!isChunkLoadError(error)) return
81
+ routerSaw = true
82
+ failed(error, to ? router.resolve(to).href : null)
83
+ })
84
+ const removeAfterEach = router.afterEach((_to, _from, failure) => {
85
+ if (failure || reloading) return
86
+ try {
87
+ storage?.removeItem(CHUNK_RELOAD_KEY)
88
+ } catch {
89
+ /* nothing to clear */
90
+ }
91
+ })
92
+
93
+ // Vite dispatches this before the import rejects. When the import belongs to a navigation, the router hears the
94
+ // rejection a few microtasks later and handles it with the page the user wanted; waiting one task lets it go
95
+ // first, and the failure is then not handled twice.
96
+ const onPreloadError = (event: Event) => {
97
+ const error = (event as Event & { payload?: unknown }).payload ?? event
98
+ routerSaw = false
99
+ setTimeout(() => {
100
+ if (!routerSaw) failed(error, null)
101
+ }, 0)
102
+ }
103
+ target?.addEventListener('vite:preloadError', onPreloadError)
104
+
105
+ return () => {
106
+ removeOnError()
107
+ removeAfterEach()
108
+ target?.removeEventListener('vite:preloadError', onPreloadError)
109
+ }
110
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Is `qdadmVitePlugin()` in the app's vite config? (#2259)
3
+ *
4
+ * qdadm ships raw sources (ADR 0003), and `qdadmVitePlugin()` is what makes the app and qdadm share one copy
5
+ * of PrimeVue. Without it the app dies at boot on PrimeVue's own `No PrimeVue Toast provided!`, which never
6
+ * names qdadm. The plugin defines `__QDADM_VITE_PLUGIN__`; the kernel checks it once, in dev, and says what to
7
+ * add — a log, not a throw, so an app that wires the same config by hand still boots.
8
+ */
9
+ declare const __QDADM_VITE_PLUGIN__: boolean | undefined
10
+
11
+ export const MISSING_VITE_PLUGIN_HINT =
12
+ 'qdadmVitePlugin() is missing from vite.config: without it the app and qdadm can load two copies of PrimeVue, ' +
13
+ "and the app then fails at boot. Add it — import { qdadmVitePlugin } from '@quazardous/qdadm/vite', then " +
14
+ 'plugins: [vue(), qdadmVitePlugin()] — see https://github.com/quazardous/qdadm#quick-start'
15
+
16
+ /** Whether the app was built with the plugin: the define it sets. */
17
+ export function hasQdadmVitePlugin(): boolean {
18
+ try {
19
+ return typeof __QDADM_VITE_PLUGIN__ !== 'undefined' && __QDADM_VITE_PLUGIN__ === true
20
+ } catch {
21
+ return false
22
+ }
23
+ }
24
+
25
+ const isDev = (): boolean => (import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV === true
26
+
27
+ let warned = false
28
+
29
+ export interface VitePluginCheckOptions {
30
+ /** Test seams: what the build says, and where the hint goes. */
31
+ present?: boolean
32
+ dev?: boolean
33
+ log?: (message: string) => void
34
+ }
35
+
36
+ /** Log the hint once, in dev, when the plugin is missing. True when it did. */
37
+ export function warnWithoutQdadmVitePlugin(options: VitePluginCheckOptions = {}): boolean {
38
+ const present = options.present ?? hasQdadmVitePlugin()
39
+ const dev = options.dev ?? isDev()
40
+ if (present || !dev || warned) return false
41
+ warned = true
42
+ ;(options.log ?? console.error)(`[qdadm] ${MISSING_VITE_PLUGIN_HINT}`)
43
+ return true
44
+ }
45
+
46
+ /** Test seam: forget that the hint was logged. */
47
+ export function resetVitePluginWarning(): void {
48
+ warned = false
49
+ }
50
+
51
+ /** PrimeVue's "No PrimeVue Toast provided!", with its usual cause named. Any other error passes through. */
52
+ export function explainMissingToast<T>(use: () => T): T {
53
+ try {
54
+ return use()
55
+ } catch (e) {
56
+ if (e instanceof Error && /No PrimeVue Toast provided/i.test(e.message)) {
57
+ throw new Error(`${e.message} The usual cause: ${MISSING_VITE_PLUGIN_HINT}`)
58
+ }
59
+ throw e
60
+ }
61
+ }
@@ -22,6 +22,7 @@ import { AuthCollector } from './AuthCollector'
22
22
  import { EntitiesCollector } from './EntitiesCollector'
23
23
  import { RouterCollector } from './RouterCollector'
24
24
  import { I18nCollector } from './I18nCollector'
25
+ import { RelayCollector, findRelayController } from '@quazardous/qddebug'
25
26
  import DebugBar from './components/DebugBar.vue'
26
27
  import type { KernelContext } from '../../kernel/KernelContext'
27
28
 
@@ -67,6 +68,8 @@ export interface DebugModuleOptions extends ModuleOptions {
67
68
  entitiesCollector?: boolean
68
69
  routerCollector?: boolean
69
70
  i18nCollector?: boolean
71
+ /** MCP tab (#2231) — only shown where the relay connector is installed */
72
+ relayCollector?: boolean
70
73
  _kernelManaged?: boolean
71
74
  /**
72
75
  * Existing DebugBridge to register collectors onto. When provided,
@@ -152,6 +155,12 @@ export class DebugModule extends Module {
152
155
  this._bridge.addCollector(new I18nCollector(collectorOptions))
153
156
  }
154
157
 
158
+ // The MCP tab (#2231), after i18n — and only where the app installed the
159
+ // relay connector: no connector, nothing to pair, no tab.
160
+ if (this.options.relayCollector !== false && findRelayController()) {
161
+ this._bridge.addCollector(new RelayCollector(collectorOptions))
162
+ }
163
+
155
164
  // Install collectors with context — only when WE own the bridge.
156
165
  // External bridges are installed by the host later with a merged
157
166
  // context that may include data from other frameworks (e.g. cms).
@@ -12,6 +12,7 @@ import { useToast } from 'primevue/usetoast'
12
12
  import type { SignalBus } from '../kernel/SignalBus'
13
13
  import { useNotifications } from './NotificationStore'
14
14
  import type { NotificationSeverity } from './NotificationStore'
15
+ import { explainMissingToast } from '../kernel/vitePluginCheck'
15
16
 
16
17
  interface ToastEventData {
17
18
  summary?: string
@@ -21,7 +22,7 @@ interface ToastEventData {
21
22
  forceToast?: boolean
22
23
  }
23
24
 
24
- const toast = useToast()
25
+ const toast = explainMissingToast(useToast)
25
26
  const signals = inject<SignalBus | null>('qdadmSignals', null)
26
27
  const store = useNotifications()
27
28
 
@@ -69,6 +69,23 @@ export {
69
69
  type RoleListResult,
70
70
  type RoleInput,
71
71
  } from './RolesProviderStorage'
72
+ export {
73
+ composeGrants,
74
+ inheritedGrants,
75
+ originOf,
76
+ setGrant,
77
+ type GrantDefinition,
78
+ type InheritedGrant,
79
+ type RoleComposition,
80
+ type InheritableRole,
81
+ type GrantOrigin,
82
+ type GrantCell,
83
+ type GrantRow,
84
+ type GrantGroup,
85
+ type OtherGrant,
86
+ type GrantsModel,
87
+ type RoleHierarchySource,
88
+ } from './roleGrants'
72
89
 
73
90
  /**
74
91
  * Standard entity actions for CRUD operations
@@ -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>