@quazardous/qdadm 2.9.0 → 2.11.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/README.md +18 -2
- package/package.json +9 -4
- package/src/chain/StackHydrator.ts +1 -3
- package/src/components/dialogs/MultiStepDialog.vue +1 -1
- package/src/components/edit/FormInput.vue +12 -7
- package/src/components/layout/AppLayout.vue +8 -2
- package/src/components/layout/defaults/DefaultUserInfo.vue +4 -1
- package/src/components/show/ShowDisplay.vue +6 -1
- package/src/components/show/ShowField.vue +3 -1
- package/src/composables/useAuth.ts +4 -2
- package/src/composables/useEntityItemFormPage.types.ts +1 -1
- package/src/composables/useEntityItemPage.ts +5 -5
- package/src/composables/useEntityItemShowPage.ts +1 -1
- package/src/composables/useNavContext.ts +10 -1
- package/src/entity/auth/SecurityChecker.ts +23 -0
- package/src/kernel/Kernel.registries.ts +10 -0
- package/src/kernel/Kernel.routing.ts +6 -12
- package/src/kernel/Kernel.types.ts +9 -2
- package/src/kernel/ModuleLoader.ts +0 -6
- package/src/orchestrator/Orchestrator.ts +2 -2
- package/src/query/FilterQuery.ts +0 -4
- package/src/security/EntityRolesProvider.ts +0 -3
- package/src/styles/index.d.ts +5 -0
- package/src/types/index.ts +6 -0
- package/src/utils/formatters.ts +14 -0
- package/src/vite/qdadmPlugin.ts +92 -0
package/README.md
CHANGED
|
@@ -11,9 +11,25 @@ Changelog: [CHANGELOG](https://github.com/quazardous/qdadm/blob/main/CHANGELOG.m
|
|
|
11
11
|
## Installation
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
npm install @quazardous/qdadm primevue @primeuix/themes
|
|
14
|
+
npm install @quazardous/qdadm primevue @primeuix/themes primeicons vue-router pinia
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
qdadm ships raw TS/Vue sources, so Vite needs a small config assist to keep
|
|
18
|
+
PrimeVue as a single module instance in dev. One line:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// vite.config.ts
|
|
22
|
+
import { qdadmVitePlugin } from '@quazardous/qdadm/vite'
|
|
23
|
+
|
|
24
|
+
export default defineConfig({
|
|
25
|
+
plugins: [vue(), qdadmVitePlugin()],
|
|
26
|
+
})
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
(It applies `resolve.dedupe` + the `optimizeDeps` exclude/include set — see
|
|
30
|
+
the plugin's JSDoc for details. Without it, dev boots to a blank page with
|
|
31
|
+
`Error: No PrimeVue Toast provided!`.)
|
|
32
|
+
|
|
17
33
|
## Exports
|
|
18
34
|
|
|
19
35
|
```js
|
|
@@ -136,7 +152,7 @@ export default defineConfig({
|
|
|
136
152
|
|
|
137
153
|
This is the canonical Vite/Webpack pattern for Vue plugin singletons. The `file:` link can stay in place — useful for hot-reload during qdadm development — and the dedupe ensures only one instance of each peer ends up in the bundle.
|
|
138
154
|
|
|
139
|
-
Installing qdadm from npm
|
|
155
|
+
Installing qdadm from npm avoids the *duplicate-copy* trap (the published tarball ships no `node_modules/`, so peers resolve from the host) — but a related *split-pipeline* trap still applies in Vite dev: the host's `primevue` import gets pre-bundled into `.vite/deps` while qdadm's raw sources import `node_modules/primevue/` directly, which also yields two PrimeVue instances. `qdadmVitePlugin()` (see Installation above) handles both cases — dedupe for links, optimizeDeps exclude/include for the pre-bundle split.
|
|
140
156
|
|
|
141
157
|
One more caveat: qdadm ships TypeScript sources, so with a `file:` link your `vue-tsc` / lint / test counters measure qdadm's **working tree**, not a released version. Any local qdadm edit moves your numbers with zero change on your side — don't calibrate quality ratchets or CI thresholds against a linked checkout; pin them to the published package your CI actually installs.
|
|
142
158
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quazardous/qdadm",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"description": "Vue 3 framework for admin dashboards with PrimeVue",
|
|
5
5
|
"author": "quazardous",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,10 +35,14 @@
|
|
|
35
35
|
"./module": "./src/module/index.ts",
|
|
36
36
|
"./utils": "./src/utils/index.ts",
|
|
37
37
|
"./modules/debug": "./src/modules/debug/index.ts",
|
|
38
|
-
"./styles":
|
|
38
|
+
"./styles": {
|
|
39
|
+
"types": "./src/styles/index.d.ts",
|
|
40
|
+
"default": "./src/styles/index.scss"
|
|
41
|
+
},
|
|
39
42
|
"./styles/variables": "./src/styles/_variables.scss",
|
|
40
43
|
"./gen": "./src/gen/index.ts",
|
|
41
44
|
"./gen/vite-plugin": "./src/gen/vite-plugin.ts",
|
|
45
|
+
"./vite": "./src/vite/qdadmPlugin.ts",
|
|
42
46
|
"./vite-plugin-debug": "./src/vite/qdadmDebugPlugin.ts"
|
|
43
47
|
},
|
|
44
48
|
"files": [
|
|
@@ -48,17 +52,18 @@
|
|
|
48
52
|
],
|
|
49
53
|
"dependencies": {
|
|
50
54
|
"@quazardous/qdcore": "^1.0.0",
|
|
55
|
+
"@types/pluralize": "^0.0.33",
|
|
51
56
|
"@quazardous/qddebug": "^1.0.0",
|
|
52
57
|
"@quazardous/quarkernel": "^2.1.0",
|
|
53
58
|
"pluralize": "^8.0.0",
|
|
54
59
|
"yaml": "^2.8.4"
|
|
55
60
|
},
|
|
56
61
|
"peerDependencies": {
|
|
57
|
-
"pinia": "^2.0.0 || ^3.0.0",
|
|
62
|
+
"pinia": "^2.0.0 || ^3.0.0 || ^4.0.0",
|
|
58
63
|
"primevue": "^4.0.0",
|
|
59
64
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
|
60
65
|
"vue": "^3.3.0",
|
|
61
|
-
"vue-router": "^4.0.0"
|
|
66
|
+
"vue-router": "^4.0.0 || ^5.0.0"
|
|
62
67
|
},
|
|
63
68
|
"peerDependenciesMeta": {
|
|
64
69
|
"vanilla-jsoneditor": {
|
|
@@ -54,7 +54,6 @@ export interface HydratedPayload {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export class StackHydrator implements Hydrator<EntityStackLevel, HydratedLevel> {
|
|
57
|
-
private _activeStack: ActiveStack
|
|
58
57
|
private _orchestrator: Orchestrator
|
|
59
58
|
private _signalBus: SignalBus | null
|
|
60
59
|
private _levels: HydratedLevel[] = []
|
|
@@ -62,11 +61,10 @@ export class StackHydrator implements Hydrator<EntityStackLevel, HydratedLevel>
|
|
|
62
61
|
private _unsubscribe: (() => void) | null = null
|
|
63
62
|
|
|
64
63
|
constructor(
|
|
65
|
-
|
|
64
|
+
_activeStack: ActiveStack,
|
|
66
65
|
orchestrator: Orchestrator,
|
|
67
66
|
signalBus: SignalBus | null = null
|
|
68
67
|
) {
|
|
69
|
-
this._activeStack = activeStack
|
|
70
68
|
this._orchestrator = orchestrator
|
|
71
69
|
this._signalBus = signalBus
|
|
72
70
|
|
|
@@ -28,18 +28,23 @@ interface SelectOption {
|
|
|
28
28
|
|
|
29
29
|
interface FieldConfig {
|
|
30
30
|
name: string
|
|
31
|
-
|
|
31
|
+
/** Known widget types get dedicated rendering; other strings (e.g. from
|
|
32
|
+
* ResolvedFieldConfig, #1387) fall back to the text input. */
|
|
33
|
+
type?: InputType | (string & {})
|
|
32
34
|
placeholder?: string
|
|
33
35
|
disabled?: boolean
|
|
34
36
|
readonly?: boolean
|
|
35
|
-
options?: SelectOption[]
|
|
37
|
+
options?: SelectOption[] | unknown[]
|
|
36
38
|
optionLabel?: string
|
|
37
39
|
optionValue?: string
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
const props = defineProps({
|
|
41
43
|
field: { type: Object as PropType<FieldConfig>, required: true },
|
|
42
|
-
|
|
44
|
+
// unknown: v-model over Record<string, unknown> indexes (the documented
|
|
45
|
+
// generateFields loop) must bind without casts (#1387)
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
47
|
+
modelValue: { type: null as unknown as PropType<any>, default: null },
|
|
43
48
|
hint: { type: String as () => InputType | null, default: null } // Optional type hint override
|
|
44
49
|
})
|
|
45
50
|
|
|
@@ -48,7 +53,7 @@ const emit = defineEmits<{
|
|
|
48
53
|
}>()
|
|
49
54
|
|
|
50
55
|
const value = computed<ModelValue>({
|
|
51
|
-
get: (): ModelValue => props.modelValue,
|
|
56
|
+
get: (): ModelValue => (props.modelValue ?? null) as ModelValue,
|
|
52
57
|
set: (v: ModelValue): void => emit('update:modelValue', v)
|
|
53
58
|
})
|
|
54
59
|
|
|
@@ -64,17 +69,17 @@ const numberValue = computed<number | null>({
|
|
|
64
69
|
})
|
|
65
70
|
|
|
66
71
|
const booleanValue = computed<boolean>({
|
|
67
|
-
get: (): boolean => (props.modelValue as boolean) ?? false,
|
|
72
|
+
get: (): boolean => (props.modelValue as boolean | null | undefined) ?? false,
|
|
68
73
|
set: (v: boolean): void => emit('update:modelValue', v)
|
|
69
74
|
})
|
|
70
75
|
|
|
71
76
|
// Resolve component type: field.type > hint > 'text'
|
|
72
|
-
const inputType = computed<InputType>(() => props.field?.type || props.hint || 'text')
|
|
77
|
+
const inputType = computed<InputType>(() => (props.field?.type as InputType) || props.hint || 'text')
|
|
73
78
|
|
|
74
79
|
// Date value with string ↔ Date conversion for DatePicker
|
|
75
80
|
const dateValue = computed<Date | null>({
|
|
76
81
|
get: (): Date | null => {
|
|
77
|
-
const v = props.modelValue
|
|
82
|
+
const v = props.modelValue as string | Date | null
|
|
78
83
|
if (!v) return null
|
|
79
84
|
if (v instanceof Date) return v
|
|
80
85
|
// Convert ISO string to Date
|
|
@@ -16,6 +16,7 @@ import { ref, watch, onMounted, onUnmounted, computed, inject, provide, useSlots
|
|
|
16
16
|
import { SIDEBAR_COLLAPSED_KEY } from '../../composables/useSidebarState'
|
|
17
17
|
import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
|
|
18
18
|
import { useNavigation, type NavSection } from '../../composables/useNavigation'
|
|
19
|
+
import { prettifyRole } from '../../utils/formatters'
|
|
19
20
|
import { useApp } from '../../composables/useApp'
|
|
20
21
|
import { useAuth } from '../../composables/useAuth'
|
|
21
22
|
import { useGuardDialog } from '../../composables/useGuardStore'
|
|
@@ -59,6 +60,9 @@ const guardDialog = useGuardDialog()
|
|
|
59
60
|
const router = useRouter()
|
|
60
61
|
const route = useRoute()
|
|
61
62
|
const app = useApp()
|
|
63
|
+
// Top-level binding so the template unwraps the ComputedRef — `app.version`
|
|
64
|
+
// nested in a plain object stays a ref object (always truthy) in v-if (#1382)
|
|
65
|
+
const appVersion = app.version
|
|
62
66
|
const { navSections, isNavActive, sectionHasActiveItem, handleNavClick } = useNavigation()
|
|
63
67
|
const { user, logout, authEnabled } = useAuth()
|
|
64
68
|
const signals = inject<SignalBus | null>('qdadmSignals', null)
|
|
@@ -207,7 +211,9 @@ const userDisplayName = computed<string>(() => {
|
|
|
207
211
|
|
|
208
212
|
const userSubtitle = computed<string>(() => {
|
|
209
213
|
const userData = user.value as UserData | null
|
|
210
|
-
|
|
214
|
+
if (userData?.email) return userData.email
|
|
215
|
+
// raw ROLE_* constants are ugly by default — prettify (#1388)
|
|
216
|
+
return userData?.role ? prettifyRole(userData.role) : ''
|
|
211
217
|
})
|
|
212
218
|
|
|
213
219
|
async function handleLogout(): Promise<void> {
|
|
@@ -288,7 +294,7 @@ const showBreadcrumb = computed<boolean>(() => {
|
|
|
288
294
|
<img v-if="app.logo" :src="app.logo" :alt="app.name" class="sidebar-logo" />
|
|
289
295
|
<h1 v-else>{{ app.name }}</h1>
|
|
290
296
|
</div>
|
|
291
|
-
<span v-if="
|
|
297
|
+
<span v-if="appVersion" class="version">v{{ appVersion }}</span>
|
|
292
298
|
</div>
|
|
293
299
|
|
|
294
300
|
<nav class="sidebar-nav">
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { computed, type ComputedRef } from 'vue'
|
|
11
11
|
import { useRouter } from 'vue-router'
|
|
12
|
+
import { prettifyRole } from '../../../utils/formatters'
|
|
12
13
|
import { useAuth } from '../../../composables/useAuth'
|
|
13
14
|
import Button from 'primevue/button'
|
|
14
15
|
|
|
@@ -37,7 +38,9 @@ const userDisplayName: ComputedRef<string> = computed(() => {
|
|
|
37
38
|
})
|
|
38
39
|
|
|
39
40
|
const userSubtitle: ComputedRef<string> = computed(() => {
|
|
40
|
-
|
|
41
|
+
if (userData.value?.email) return userData.value.email
|
|
42
|
+
// raw ROLE_* constants are ugly by default — prettify (#1388)
|
|
43
|
+
return userData.value?.role ? prettifyRole(userData.value.role) : ''
|
|
41
44
|
})
|
|
42
45
|
|
|
43
46
|
function handleLogout(): void {
|
|
@@ -62,6 +62,8 @@ interface FieldConfig {
|
|
|
62
62
|
dateFormat?: string
|
|
63
63
|
currencyCode?: string
|
|
64
64
|
locale?: string
|
|
65
|
+
/** false → no locale digit grouping for number fields (years, ids) (#1388) */
|
|
66
|
+
useGrouping?: boolean
|
|
65
67
|
booleanLabels?: { true: string; false: string }
|
|
66
68
|
// Reference options
|
|
67
69
|
reference?: string
|
|
@@ -107,7 +109,10 @@ const numberValue = computed(() => {
|
|
|
107
109
|
if (isEmpty.value) return '-'
|
|
108
110
|
const num = Number(props.value)
|
|
109
111
|
if (isNaN(num)) return String(props.value)
|
|
110
|
-
|
|
112
|
+
// useGrouping: false → no locale digit grouping (years, ids… "1965",
|
|
113
|
+
// not "1 965") (#1388)
|
|
114
|
+
const opts = props.field.useGrouping === false ? { useGrouping: false } : {}
|
|
115
|
+
return formatNumber(num, opts, props.field.locale)
|
|
111
116
|
})
|
|
112
117
|
|
|
113
118
|
// Format currency value
|
|
@@ -27,7 +27,9 @@ const props = defineProps({
|
|
|
27
27
|
// Field config (for auto-display mode)
|
|
28
28
|
field: { type: Object as PropType<FieldConfig | null>, default: null },
|
|
29
29
|
// Value (for auto-display mode)
|
|
30
|
-
|
|
30
|
+
// unknown: show.data.value?.[f.name] over the default Record generic (#1387)
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
value: { type: null as unknown as PropType<any>, default: null },
|
|
31
33
|
// Manual props (override field config)
|
|
32
34
|
name: { type: String, default: '' },
|
|
33
35
|
label: { type: String, default: '' },
|
|
@@ -61,8 +61,10 @@ export function useAuth(): UseAuthReturn {
|
|
|
61
61
|
throw new Error('[qdadm] useAuth() must be called within component setup()')
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
// null defaults: a no-auth app must not log "injection not found" warnings
|
|
65
|
+
// on every page (#1388)
|
|
66
|
+
const auth = inject<AuthAdapter | null>('authAdapter', null)
|
|
67
|
+
const features = inject<FeaturesConfig | null>('qdadmFeatures', null)
|
|
66
68
|
|
|
67
69
|
// If auth disabled or not provided, return neutral values
|
|
68
70
|
if (!features?.auth || !auth) {
|
|
@@ -271,7 +271,7 @@ export interface UseEntityItemFormPageReturn<T extends Record<string, unknown> =
|
|
|
271
271
|
// Parent chain (from route.meta.parent, supports N-level nesting)
|
|
272
272
|
parentConfig: ComputedRef<ParentConfig | null>
|
|
273
273
|
parentId: ComputedRef<string | number | null>
|
|
274
|
-
parentData: ComputedRef<unknown | null>
|
|
274
|
+
parentData: ComputedRef<Record<string, unknown> | null>
|
|
275
275
|
parentChain: Ref<Map<number, unknown>>
|
|
276
276
|
getChainDepth: (config?: ParentConfig | null) => number
|
|
277
277
|
|
|
@@ -103,8 +103,8 @@ export interface UseEntityItemPageReturn<T = unknown> {
|
|
|
103
103
|
// Parent chain
|
|
104
104
|
parentConfig: ComputedRef<ParentConfig | null>
|
|
105
105
|
parentId: ComputedRef<string | number | null>
|
|
106
|
-
parentData: ComputedRef<unknown | null>
|
|
107
|
-
parentChain: Ref<Map<number, unknown
|
|
106
|
+
parentData: ComputedRef<Record<string, unknown> | null>
|
|
107
|
+
parentChain: Ref<Map<number, Record<string, unknown>>>
|
|
108
108
|
parentLoading: Ref<boolean>
|
|
109
109
|
loadParent: () => Promise<void>
|
|
110
110
|
loadParentChain: () => Promise<void>
|
|
@@ -189,7 +189,7 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
|
|
|
189
189
|
* Loaded parent entities data (Map: level -> data)
|
|
190
190
|
* Level 1 = immediate parent, Level 2 = grandparent, etc.
|
|
191
191
|
*/
|
|
192
|
-
const parentChain = ref<Map<number, unknown
|
|
192
|
+
const parentChain = ref<Map<number, Record<string, unknown>>>(new Map())
|
|
193
193
|
const parentLoading = ref(false)
|
|
194
194
|
|
|
195
195
|
/**
|
|
@@ -228,7 +228,7 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
|
|
|
228
228
|
if (!parentConfig.value || !parentId.value) return
|
|
229
229
|
|
|
230
230
|
parentLoading.value = true
|
|
231
|
-
const newChain = new Map<number, unknown
|
|
231
|
+
const newChain = new Map<number, Record<string, unknown>>()
|
|
232
232
|
|
|
233
233
|
try {
|
|
234
234
|
// Calculate total depth to set correct breadcrumb levels
|
|
@@ -251,7 +251,7 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
|
|
|
251
251
|
|
|
252
252
|
const loadedData = await parentManager.get(parentEntityId)
|
|
253
253
|
if (loadedData) {
|
|
254
|
-
newChain.set(level, loadedData)
|
|
254
|
+
newChain.set(level, loadedData as Record<string, unknown>)
|
|
255
255
|
|
|
256
256
|
// Update active stack
|
|
257
257
|
hydrator.setEntityData(currentConfig.entity, loadedData)
|
|
@@ -257,7 +257,7 @@ interface ConfirmService {
|
|
|
257
257
|
}) => void
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
export function useEntityItemShowPage<T = unknown
|
|
260
|
+
export function useEntityItemShowPage<T = Record<string, unknown>>(
|
|
261
261
|
options: UseEntityItemShowPageOptions<T>
|
|
262
262
|
): UseEntityItemShowPageReturn<T> {
|
|
263
263
|
const { entity, loadOnMount = true, transformLoad, onLoadSuccess, onLoadError } = options
|
|
@@ -409,10 +409,19 @@ export function useNavContext(_options: UseNavContextOptions = {}): UseNavContex
|
|
|
409
409
|
)
|
|
410
410
|
for (const sibling of siblings) {
|
|
411
411
|
const sibManager = sibling.meta?.entity ? getManager(sibling.meta.entity as string) : null
|
|
412
|
+
// Only pass the params the sibling path declares — forwarding the full
|
|
413
|
+
// current params (e.g. the child item id on an edit page) triggers
|
|
414
|
+
// vue-router's "Discarded invalid param(s)" warning (#1388)
|
|
415
|
+
const declared = new Set(
|
|
416
|
+
(String(sibling.path || '').match(/:([A-Za-z0-9_]+)/g) || []).map((m) => m.slice(1))
|
|
417
|
+
)
|
|
418
|
+
const params = declared.size
|
|
419
|
+
? Object.fromEntries(Object.entries(route.params).filter(([k]) => declared.has(k)))
|
|
420
|
+
: route.params
|
|
412
421
|
links.push({
|
|
413
422
|
label:
|
|
414
423
|
(sibling.meta?.navLabel as string) || sibManager?.labelPlural || (sibling.name as string),
|
|
415
|
-
to: { name: sibling.name as string, params:
|
|
424
|
+
to: { name: sibling.name as string, params: params as RouteParamsRawGeneric },
|
|
416
425
|
active: route.name === sibling.name,
|
|
417
426
|
})
|
|
418
427
|
}
|
|
@@ -53,6 +53,8 @@ export interface SecurityCheckerConfig {
|
|
|
53
53
|
export class SecurityChecker {
|
|
54
54
|
protected _rolesProvider: RoleProvider
|
|
55
55
|
readonly getCurrentUser: () => AuthUser | null
|
|
56
|
+
/** Roles already warned about (warn-once, #1388) */
|
|
57
|
+
protected _warnedRoles = new Set<string>()
|
|
56
58
|
|
|
57
59
|
constructor({ rolesProvider, roleHierarchy, rolePermissions, getCurrentUser }: SecurityCheckerOptions) {
|
|
58
60
|
if (rolesProvider) {
|
|
@@ -134,6 +136,7 @@ export class SecurityChecker {
|
|
|
134
136
|
const rolePerms = this._rolesProvider.getPermissions(r)
|
|
135
137
|
rolePerms.forEach((p) => perms.add(p))
|
|
136
138
|
}
|
|
139
|
+
this._warnUnknownRole(role)
|
|
137
140
|
}
|
|
138
141
|
|
|
139
142
|
// User-specific permission overrides
|
|
@@ -144,6 +147,26 @@ export class SecurityChecker {
|
|
|
144
147
|
return [...perms]
|
|
145
148
|
}
|
|
146
149
|
|
|
150
|
+
/**
|
|
151
|
+
* A user role that matches NO configured role silently yields zero
|
|
152
|
+
* permissions (e.g. `admin` instead of `ROLE_ADMIN`) — surface it once
|
|
153
|
+
* per role so the misconfiguration is debuggable (#1388).
|
|
154
|
+
*/
|
|
155
|
+
protected _warnUnknownRole(role: string): void {
|
|
156
|
+
if (this._warnedRoles.has(role)) return
|
|
157
|
+
const known = this._rolesProvider.getRoles()
|
|
158
|
+
if (known.includes(role)) return
|
|
159
|
+
// Hierarchy-only roles (declared in role_hierarchy without own
|
|
160
|
+
// permissions) still count as known.
|
|
161
|
+
if (Object.keys(this._rolesProvider.getHierarchy()).includes(role)) return
|
|
162
|
+
this._warnedRoles.add(role)
|
|
163
|
+
console.warn(
|
|
164
|
+
`[qdadm] SecurityChecker: user role "${role}" matches no configured role ` +
|
|
165
|
+
`(known: ${known.join(', ') || 'none'}) — the user gets NO permissions. ` +
|
|
166
|
+
`Roles must match role_permissions/role_hierarchy keys exactly.`
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
147
170
|
/**
|
|
148
171
|
* Check if user can assign a role to another user
|
|
149
172
|
*/
|
|
@@ -117,6 +117,16 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
117
117
|
|
|
118
118
|
if (!security) return
|
|
119
119
|
|
|
120
|
+
// security configured but no user source: every isGranted() falls back
|
|
121
|
+
// to permissive — a newcomer believes gating is on when it isn't (#1388)
|
|
122
|
+
if (!entityAuthAdapter) {
|
|
123
|
+
console.warn(
|
|
124
|
+
'[Kernel] security is configured but entityAuthAdapter is not — entity ' +
|
|
125
|
+
'permission checks default to PERMISSIVE (no current user to check). ' +
|
|
126
|
+
'Pass entityAuthAdapter (e.g. `() => authAdapter.getUser()`) to enable gating.'
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
120
130
|
let rolesProvider = security.rolesProvider
|
|
121
131
|
if (!rolesProvider && (security.role_permissions || security.role_hierarchy)) {
|
|
122
132
|
rolesProvider = new StaticRoleProvider({
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
createWebHashHistory,
|
|
5
5
|
type RouteRecordRaw,
|
|
6
6
|
type RouteLocationNormalized,
|
|
7
|
-
type NavigationGuardNext,
|
|
8
7
|
type RouteRecordNormalized,
|
|
9
8
|
} from 'vue-router'
|
|
10
9
|
import { getRoutes } from '../module/moduleRegistry'
|
|
@@ -170,7 +169,9 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
170
169
|
)
|
|
171
170
|
})
|
|
172
171
|
|
|
173
|
-
|
|
172
|
+
// Return-style guard (no next() callback): supported since vue-router 4,
|
|
173
|
+
// required to avoid the R0025 deprecation warning on vue-router 5 (#1384)
|
|
174
|
+
this.router!.beforeEach((to: RouteLocationNormalized) => {
|
|
174
175
|
if (authAdapter.isAuthenticated()) {
|
|
175
176
|
wasEverAuthenticated = true
|
|
176
177
|
}
|
|
@@ -181,7 +182,6 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
181
182
|
)
|
|
182
183
|
|
|
183
184
|
if (isPublic) {
|
|
184
|
-
next()
|
|
185
185
|
return
|
|
186
186
|
}
|
|
187
187
|
|
|
@@ -204,11 +204,9 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
204
204
|
wasEverAuthenticated = false
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
|
|
207
|
+
return this.router!.hasRoute('login')
|
|
208
208
|
? { name: 'login', query: { session_lost: '1' } }
|
|
209
209
|
: '/'
|
|
210
|
-
next(loginRoute)
|
|
211
|
-
return
|
|
212
210
|
}
|
|
213
211
|
|
|
214
212
|
const entity = to.meta?.entity as string | undefined
|
|
@@ -233,20 +231,16 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
233
231
|
entity,
|
|
234
232
|
manager,
|
|
235
233
|
})
|
|
236
|
-
|
|
237
|
-
return
|
|
234
|
+
return { path: '/' }
|
|
238
235
|
}
|
|
239
236
|
} catch (err) {
|
|
240
237
|
console.error(
|
|
241
238
|
`[qdadm] Access check failed for ${to.path} (entity: ${entity}) — denying navigation:`,
|
|
242
239
|
err
|
|
243
240
|
)
|
|
244
|
-
|
|
245
|
-
return
|
|
241
|
+
return { path: '/' }
|
|
246
242
|
}
|
|
247
243
|
}
|
|
248
|
-
|
|
249
|
-
next()
|
|
250
244
|
})
|
|
251
245
|
}
|
|
252
246
|
|
|
@@ -21,7 +21,9 @@ export interface AuthAdapter {
|
|
|
21
21
|
getToken?(): string | null
|
|
22
22
|
logout?(): void
|
|
23
23
|
connectSignals?(signals: SignalBus): () => void
|
|
24
|
-
|
|
24
|
+
// NOTE: no string index signature here — class instances (e.g. a
|
|
25
|
+
// LocalStorageSessionAuthAdapter subclass, the documented pattern) do not
|
|
26
|
+
// satisfy implicit index signatures and would stop being assignable (#1387).
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -161,7 +163,12 @@ export interface KernelOptions {
|
|
|
161
163
|
storageResolver?: (config: unknown, entityName: string) => unknown
|
|
162
164
|
managerResolver?: (config: unknown, entityName: string, context: unknown) => EntityManager
|
|
163
165
|
authAdapter?: AuthAdapter | null
|
|
164
|
-
entityAuthAdapter?:
|
|
166
|
+
entityAuthAdapter?:
|
|
167
|
+
| EntityAuthAdapter
|
|
168
|
+
| string
|
|
169
|
+
| Record<string, unknown>
|
|
170
|
+
| (() => Record<string, unknown> | null)
|
|
171
|
+
| null
|
|
165
172
|
authTypes?: Record<string, unknown>
|
|
166
173
|
pages?: Pages
|
|
167
174
|
homeRoute?: HomeRoute
|
|
@@ -151,7 +151,6 @@ export class ModuleLoadError extends Error {
|
|
|
151
151
|
*/
|
|
152
152
|
class ObjectModuleAdapter implements ModuleLike {
|
|
153
153
|
private _def: ObjectModuleDefinition
|
|
154
|
-
private _ctx: ModuleContext | null = null
|
|
155
154
|
|
|
156
155
|
constructor(def: ObjectModuleDefinition) {
|
|
157
156
|
this._def = def
|
|
@@ -177,7 +176,6 @@ class ObjectModuleAdapter implements ModuleLike {
|
|
|
177
176
|
}
|
|
178
177
|
|
|
179
178
|
async connect(ctx: ModuleContext): Promise<void> {
|
|
180
|
-
this._ctx = ctx
|
|
181
179
|
if (typeof this._def.connect === 'function') {
|
|
182
180
|
await this._def.connect(ctx)
|
|
183
181
|
}
|
|
@@ -187,7 +185,6 @@ class ObjectModuleAdapter implements ModuleLike {
|
|
|
187
185
|
if (typeof this._def.disconnect === 'function') {
|
|
188
186
|
await this._def.disconnect()
|
|
189
187
|
}
|
|
190
|
-
this._ctx = null
|
|
191
188
|
}
|
|
192
189
|
}
|
|
193
190
|
|
|
@@ -201,7 +198,6 @@ class ObjectModuleAdapter implements ModuleLike {
|
|
|
201
198
|
class ClassModuleAdapter implements ModuleLike {
|
|
202
199
|
private _ClassDef: ModuleClassConstructor
|
|
203
200
|
private _instance: ModuleLike
|
|
204
|
-
private _ctx: ModuleContext | null = null
|
|
205
201
|
|
|
206
202
|
constructor(ClassDef: ModuleClassConstructor) {
|
|
207
203
|
this._ClassDef = ClassDef
|
|
@@ -237,7 +233,6 @@ class ClassModuleAdapter implements ModuleLike {
|
|
|
237
233
|
}
|
|
238
234
|
|
|
239
235
|
async connect(ctx: ModuleContext): Promise<void> {
|
|
240
|
-
this._ctx = ctx
|
|
241
236
|
if (typeof this._instance.connect === 'function') {
|
|
242
237
|
await this._instance.connect(ctx)
|
|
243
238
|
}
|
|
@@ -247,7 +242,6 @@ class ClassModuleAdapter implements ModuleLike {
|
|
|
247
242
|
if (typeof this._instance.disconnect === 'function') {
|
|
248
243
|
await this._instance.disconnect()
|
|
249
244
|
}
|
|
250
|
-
this._ctx = null
|
|
251
245
|
}
|
|
252
246
|
}
|
|
253
247
|
|
|
@@ -434,8 +434,8 @@ export function createOrchestrator(options?: OrchestratorOptions): Orchestrator
|
|
|
434
434
|
// ============================================================================
|
|
435
435
|
type _AssertManagerLike<T extends EntityManagerLike> = T
|
|
436
436
|
type _AssertOrchestratorLike<T extends OrchestratorLike> = T
|
|
437
|
-
//
|
|
438
|
-
type _LikeChecks = [
|
|
437
|
+
// (exported so strict consumer typechecks don't flag it unused, #1386)
|
|
438
|
+
export type _LikeChecks = [
|
|
439
439
|
_AssertManagerLike<EntityManager>,
|
|
440
440
|
_AssertOrchestratorLike<Orchestrator>,
|
|
441
441
|
]
|
package/src/query/FilterQuery.ts
CHANGED
|
@@ -107,7 +107,6 @@ export class FilterQuery<T = unknown> {
|
|
|
107
107
|
toQuery: ((value: unknown) => Record<string, unknown>) | null
|
|
108
108
|
|
|
109
109
|
private _options: FilterOption[] | null = null
|
|
110
|
-
private _signals: SignalBusLike | null = null
|
|
111
110
|
private _subscriptions: Array<() => void> = []
|
|
112
111
|
|
|
113
112
|
constructor(options: FilterQueryOptions<T>) {
|
|
@@ -164,8 +163,6 @@ export class FilterQuery<T = unknown> {
|
|
|
164
163
|
// Clean up existing subscriptions if any
|
|
165
164
|
this._cleanupSubscriptions()
|
|
166
165
|
|
|
167
|
-
this._signals = signals
|
|
168
|
-
|
|
169
166
|
if (!signals) return this
|
|
170
167
|
|
|
171
168
|
// Subscribe to entity CRUD signals for cache invalidation
|
|
@@ -200,7 +197,6 @@ export class FilterQuery<T = unknown> {
|
|
|
200
197
|
*/
|
|
201
198
|
dispose(): void {
|
|
202
199
|
this._cleanupSubscriptions()
|
|
203
|
-
this._signals = null
|
|
204
200
|
this._options = null
|
|
205
201
|
}
|
|
206
202
|
|
|
@@ -88,7 +88,6 @@ export class EntityRoleProvider extends RoleProvider {
|
|
|
88
88
|
private _inheritsField: string
|
|
89
89
|
|
|
90
90
|
private _cache: RoleCache | null = null
|
|
91
|
-
private _ctx: RoleProviderContext | null = null
|
|
92
91
|
private _orchestrator: Orchestrator | null = null
|
|
93
92
|
private _signalCleanup: (() => void) | null = null
|
|
94
93
|
private _loading: Promise<void> | null = null
|
|
@@ -106,7 +105,6 @@ export class EntityRoleProvider extends RoleProvider {
|
|
|
106
105
|
* Install adapter (called by Kernel after orchestrator ready)
|
|
107
106
|
*/
|
|
108
107
|
install(ctx: RoleProviderContext): void {
|
|
109
|
-
this._ctx = ctx
|
|
110
108
|
this._orchestrator = ctx.orchestrator as Orchestrator
|
|
111
109
|
|
|
112
110
|
// Auto-invalidate on role changes
|
|
@@ -125,7 +123,6 @@ export class EntityRoleProvider extends RoleProvider {
|
|
|
125
123
|
this._signalCleanup()
|
|
126
124
|
this._signalCleanup = null
|
|
127
125
|
}
|
|
128
|
-
this._ctx = null
|
|
129
126
|
this._orchestrator = null
|
|
130
127
|
this._cache = null
|
|
131
128
|
this._loading = null
|
|
@@ -0,0 +1,5 @@
|
|
|
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.
|
|
5
|
+
export {}
|
package/src/types/index.ts
CHANGED
|
@@ -84,6 +84,8 @@ export interface FieldConfig {
|
|
|
84
84
|
type: FieldType
|
|
85
85
|
label?: string
|
|
86
86
|
required?: boolean
|
|
87
|
+
/** number fields: false → no locale digit grouping in displays (#1388) */
|
|
88
|
+
useGrouping?: boolean
|
|
87
89
|
default?: unknown | (() => unknown)
|
|
88
90
|
options?: Array<{ label: string; value: unknown }>
|
|
89
91
|
reference?: {
|
|
@@ -108,6 +110,10 @@ export interface FieldConfig {
|
|
|
108
110
|
export interface ChildConfig {
|
|
109
111
|
entity: string
|
|
110
112
|
endpoint?: string
|
|
113
|
+
/** FK field on the child pointing at this entity (documented README shape, #1387) */
|
|
114
|
+
foreignKey?: string
|
|
115
|
+
/** Label for child navlinks/tabs */
|
|
116
|
+
label?: string
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
/**
|
package/src/utils/formatters.ts
CHANGED
|
@@ -203,3 +203,17 @@ export function formatPercent(value: number | null | undefined, decimals = 1): s
|
|
|
203
203
|
if (value === null || value === undefined) return '-'
|
|
204
204
|
return `${(value * 100).toFixed(decimals)}%`
|
|
205
205
|
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Prettify a role constant for display when no role_labels entry exists:
|
|
209
|
+
* ROLE_SUPER_USER → "Super User" (#1388).
|
|
210
|
+
*/
|
|
211
|
+
export function prettifyRole(role: string): string {
|
|
212
|
+
return role
|
|
213
|
+
.replace(/^ROLE_/, '')
|
|
214
|
+
.toLowerCase()
|
|
215
|
+
.split('_')
|
|
216
|
+
.filter(Boolean)
|
|
217
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
218
|
+
.join(' ')
|
|
219
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qdadm Vite consumer plugin (#1385)
|
|
3
|
+
*
|
|
4
|
+
* qdadm publishes raw TS/.vue sources. Without help, Vite pre-bundles the
|
|
5
|
+
* host app's `primevue` import into `.vite/deps` while qdadm's raw sources
|
|
6
|
+
* import `node_modules/primevue/...` directly — two module instances whose
|
|
7
|
+
* injection symbols don't match, and the app dies at boot with
|
|
8
|
+
* `Error: No PrimeVue Toast provided!`.
|
|
9
|
+
*
|
|
10
|
+
* This plugin applies the required config in one line:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* // vite.config.ts
|
|
14
|
+
* import { qdadmVitePlugin } from '@quazardous/qdadm/vite'
|
|
15
|
+
*
|
|
16
|
+
* export default defineConfig({
|
|
17
|
+
* plugins: [vue(), qdadmVitePlugin()],
|
|
18
|
+
* })
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* - `resolve.dedupe` collapses duplicate peer copies (also covers the
|
|
22
|
+
* `file:`/workspace-link scenario described in the package README).
|
|
23
|
+
* - `optimizeDeps.exclude` keeps primevue/@primeuix/themes/qdadm out of the
|
|
24
|
+
* pre-bundle so BOTH pipelines resolve the same raw modules. Excluding
|
|
25
|
+
* qdadm also keeps HMR sane when the package is consumed via a symlink.
|
|
26
|
+
* - `optimizeDeps.include` pre-bundles qdadm's CJS transitives (currently
|
|
27
|
+
* `pluralize`), which cannot be served raw as ESM. The list lives HERE —
|
|
28
|
+
* consumers should not have to track qdadm's internal CJS deps (skybot
|
|
29
|
+
* feedback on #1389). Both the nested (`@quazardous/qdadm > pluralize`,
|
|
30
|
+
* npm installs) and plain (`pluralize`, symlinked installs where the
|
|
31
|
+
* nested path can't resolve) forms are declared; the unused one is a
|
|
32
|
+
* no-op warning at worst.
|
|
33
|
+
* - When `node_modules/@quazardous/qdadm` is a symlink (file:/workspace
|
|
34
|
+
* link — qdadm's own dev mode against a testbed app), its realpath is
|
|
35
|
+
* appended to `server.fs.allow` so qdadm's `?raw` dynamic imports (i18n
|
|
36
|
+
* YAML defaults) are servable from outside the consumer's workspace
|
|
37
|
+
* root. The workspace root is re-added explicitly because a populated
|
|
38
|
+
* `fs.allow` disables Vite's implicit default.
|
|
39
|
+
*
|
|
40
|
+
* Returned partial config is deep-merged by Vite (arrays concatenate), so
|
|
41
|
+
* consumer overrides in their own `defineConfig` still apply.
|
|
42
|
+
*/
|
|
43
|
+
import fs from 'node:fs'
|
|
44
|
+
import path from 'node:path'
|
|
45
|
+
import { searchForWorkspaceRoot, type Plugin, type UserConfig } from 'vite'
|
|
46
|
+
|
|
47
|
+
/** qdadm's CJS transitive deps that must be pre-bundled (internal knowledge). */
|
|
48
|
+
const CJS_TRANSITIVES = ['pluralize']
|
|
49
|
+
|
|
50
|
+
export interface QdadmVitePluginOptions {
|
|
51
|
+
/**
|
|
52
|
+
* Extra packages to dedupe on top of the defaults
|
|
53
|
+
* (vue, vue-router, primevue, pinia).
|
|
54
|
+
*/
|
|
55
|
+
dedupe?: string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function qdadmVitePlugin(options: QdadmVitePluginOptions = {}): Plugin {
|
|
59
|
+
return {
|
|
60
|
+
name: 'qdadm',
|
|
61
|
+
config: (userConfig: UserConfig = {}) => {
|
|
62
|
+
const root = path.resolve(userConfig.root ?? process.cwd())
|
|
63
|
+
|
|
64
|
+
// Symlinked install (file:/workspace link)? Allow serving from the
|
|
65
|
+
// link target, which typically lives outside the consumer's root.
|
|
66
|
+
const fsAllow: string[] = []
|
|
67
|
+
try {
|
|
68
|
+
const pkgDir = path.join(root, 'node_modules', '@quazardous', 'qdadm')
|
|
69
|
+
const real = fs.realpathSync(pkgDir)
|
|
70
|
+
if (real !== pkgDir) {
|
|
71
|
+
fsAllow.push(searchForWorkspaceRoot(root), real)
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// not installed under root node_modules (aliased/hoisted) — nothing to allow
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
resolve: {
|
|
79
|
+
dedupe: ['vue', 'vue-router', 'primevue', 'pinia', ...(options.dedupe ?? [])],
|
|
80
|
+
},
|
|
81
|
+
optimizeDeps: {
|
|
82
|
+
exclude: ['primevue', '@primeuix/themes', '@quazardous/qdadm'],
|
|
83
|
+
include: [
|
|
84
|
+
...CJS_TRANSITIVES.map((dep) => `@quazardous/qdadm > ${dep}`),
|
|
85
|
+
...CJS_TRANSITIVES,
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
...(fsAllow.length > 0 ? { server: { fs: { allow: fsAllow } } } : {}),
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|