@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.
- 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/formFieldIds.ts +11 -0
- package/src/components/editors/RoleGrantsEditor.vue +355 -0
- package/src/components/index.ts +1 -0
- 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/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
|
@@ -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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
package/src/kernel/Kernel.ts
CHANGED
|
@@ -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
|
|
package/src/kernel/Kernel.vue.ts
CHANGED
|
@@ -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
|
|
package/src/security/index.ts
CHANGED
|
@@ -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 {
|
|
7
|
-
import { useEntityItemFormPage, FormPage, useOrchestrator,
|
|
9
|
+
import { computed } from 'vue'
|
|
10
|
+
import { useEntityItemFormPage, FormPage, useOrchestrator, RoleGrantsEditor } from '../../index.js'
|
|
8
11
|
import InputText from 'primevue/inputtext'
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
import {
|
|
13
|
+
inheritedGrants,
|
|
14
|
+
type InheritableRole,
|
|
15
|
+
type RoleComposition,
|
|
16
|
+
type RoleHierarchySource,
|
|
17
|
+
} from '../roleGrants'
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
|
-
*
|
|
20
|
+
* What the form reads from the roles provider
|
|
15
21
|
*/
|
|
16
|
-
interface
|
|
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) =>
|
|
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
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
:
|
|
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>
|