@quazardous/qdadm 2.10.0 → 2.12.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 CHANGED
@@ -2,143 +2,33 @@
2
2
 
3
3
  **Vue 3 admin framework. PrimeVue. Zero boilerplate.**
4
4
 
5
- Quick start: [README](https://github.com/quazardous/qdadm/blob/main/README.md) (5 min tutorial)
6
-
7
- Concepts & patterns: [QDADM_CREDO](https://github.com/quazardous/qdadm/blob/main/packages/qdadm/QDADM_CREDO.md)
8
-
9
- Changelog: [CHANGELOG](https://github.com/quazardous/qdadm/blob/main/CHANGELOG.md)
10
-
11
5
  ## Installation
12
6
 
13
7
  ```bash
14
- npm install @quazardous/qdadm primevue @primeuix/themes
15
- ```
16
-
17
- ## Exports
18
-
19
- ```js
20
- // Core
21
- import { Kernel, EntityManager } from '@quazardous/qdadm'
22
-
23
- // Storage backends
24
- import { MockApiStorage, ApiStorage, SdkStorage } from '@quazardous/qdadm'
25
-
26
- // Auth
27
- import { SessionAuthAdapter, LocalStorageSessionAuthAdapter } from '@quazardous/qdadm'
28
-
29
- // Security (permissions, roles)
30
- import { SecurityChecker, PermissionMatcher, PermissionRegistry } from '@quazardous/qdadm/security'
31
- import { PersistableRoleProvider, createLocalStorageRolesProvider } from '@quazardous/qdadm/security'
32
-
33
- // Composables
34
- import { useBareForm, useListPage } from '@quazardous/qdadm/composables'
35
-
36
- // Components
37
- import { ListPage, PageLayout, AppLayout, FormField, FormActions } from '@quazardous/qdadm/components'
38
-
39
- // Module system
40
- import { KernelContext } from '@quazardous/qdadm/module'
41
-
42
- // Styles
43
- import '@quazardous/qdadm/styles'
44
- ```
45
-
46
- ## ctx.crud() - Route Helper
47
-
48
- Register CRUD routes + navigation in one call:
49
-
50
- ```js
51
- class BooksModule extends Module {
52
- async connect(ctx) {
53
- ctx.entity('books', { ... })
54
-
55
- // Full CRUD with single form
56
- ctx.crud('books', {
57
- list: () => import('./pages/BookList.vue'),
58
- form: () => import('./pages/BookForm.vue') // create + edit
59
- }, {
60
- nav: { section: 'Library', icon: 'pi pi-book' }
61
- })
62
-
63
- // List only (read-only entity)
64
- ctx.crud('settings', {
65
- list: () => import('./pages/SettingsPage.vue')
66
- }, { nav: { section: 'Config', icon: 'pi pi-cog' } })
67
- }
68
- }
8
+ npm install @quazardous/qdadm primevue @primeuix/themes primeicons vue-router pinia
69
9
  ```
70
10
 
71
- Auto-generates: routes, route family, nav item. Use `ctx.routes()` for custom pages.
72
-
73
- ## SdkStorage
74
-
75
- Adapter for generated SDK clients (hey-api, openapi-generator, etc.):
76
-
77
- ```js
78
- import { SdkStorage } from '@quazardous/qdadm'
11
+ ```ts
12
+ // vite.config.ts — the plugin wires the resolver/optimizer settings
13
+ // qdadm's raw-source packaging needs (npm and file:/workspace installs)
14
+ import { qdadmVitePlugin } from '@quazardous/qdadm/vite'
79
15
 
80
- const storage = new SdkStorage({
81
- sdk,
82
- methods: {
83
- list: 'getApiBooks',
84
- get: 'getApiBooksById',
85
- create: 'postApiBooks',
86
- update: 'patchApiBooksById',
87
- delete: 'deleteApiBooksById'
88
- }
16
+ export default defineConfig({
17
+ plugins: [vue(), qdadmVitePlugin()],
89
18
  })
90
19
  ```
91
20
 
92
- Options: `transformRequest`, `transformResponse`, `responseFormat`, `clientSidePagination`.
93
-
94
21
  ## Documentation
95
22
 
96
- | Doc | Purpose |
97
- |-----|---------|
98
- | [QDADM_CREDO](https://github.com/quazardous/qdadm/blob/main/packages/qdadm/QDADM_CREDO.md) | Philosophy, patterns, concepts |
99
- | [Page compositions](https://github.com/quazardous/qdadm/blob/main/docs/page-compositions.md) | **Start here** — which composition for which need |
100
- | [CRUD Pages](https://github.com/quazardous/qdadm/blob/main/docs/crud.md) | List / form / show / child pages reference |
101
- | [Navigation](https://github.com/quazardous/qdadm/blob/main/docs/navigation.md) | Breadcrumb, sibling navlinks, View↔Edit toggle |
102
- | [Architecture](https://github.com/quazardous/qdadm/blob/main/docs/architecture.md) | PAC pattern, layers |
103
- | [Extension](https://github.com/quazardous/qdadm/blob/main/docs/extension.md) | Decorators, bundles, multi-storage |
104
- | [Hooks](https://github.com/quazardous/qdadm/blob/main/docs/hooks.md) | Alter / invoke registry |
105
- | [Signals](https://github.com/quazardous/qdadm/blob/main/docs/signals.md) | Event bus, SSE bridge |
106
- | [Zones](https://github.com/quazardous/qdadm/blob/main/docs/zones.md) | Extensible UI blocks |
107
- | [Security](https://github.com/quazardous/qdadm/blob/main/docs/security.md) | Permissions, roles |
108
- | [i18n](https://github.com/quazardous/qdadm/blob/main/docs/i18n.md) | Messages, providers, strategies |
109
- | [Codegen](https://github.com/quazardous/qdadm/blob/main/docs/gen.md) | Generate managers (OpenAPI / manual) |
110
- | [Deferred](https://github.com/quazardous/qdadm/blob/main/docs/deferred.md) | Deferred values & warmup |
111
- | [AGENT_GUIDE](https://github.com/quazardous/qdadm/blob/main/docs/AGENT_GUIDE.md) | Quick index for AI agents |
112
-
113
- ## Peer Dependencies
114
-
115
- - vue ^3.3.0
116
- - vue-router ^4.0.0
117
- - primevue ^4.0.0
118
- - pinia ^2.0.0
119
-
120
- ## Consuming via `file:` link from a sibling monorepo
23
+ Start with the **[step-by-step tutorial](https://github.com/quazardous/qdadm/blob/main/docs/tutorial-mini-admin.md)** — bootstrap, CRUD, auth, navigation, parent-child in ~310 lines.
121
24
 
122
- If you install qdadm with a `file:../path/to/qdadm` link instead of from npm — typically during local development on qdadm itself, or before a release lands — pnpm/npm resolves the symlink into the qdadm-monorepo's hoisted `node_modules`. That can pull a *second* copy of `primevue` (and `vue`, `vue-router`, `@primeuix/themes`) into your host bundle even though qdadm itself only declares them as peers.
25
+ - [Full documentation](https://github.com/quazardous/qdadm/tree/main/docs)
26
+ - [Concepts & patterns (CREDO)](https://github.com/quazardous/qdadm/blob/main/docs/QDADM_CREDO.md)
27
+ - [Changelog](https://github.com/quazardous/qdadm/blob/main/CHANGELOG.md)
123
28
 
124
- Symptom: PrimeVue components render without their Aura preset tokens — component-level CSS variables (`--p-paginator-nav-button-width`, etc.) end up undefined, so paginator buttons collapse to 16×16, datatables lose padding, rounded corners disappear. Plugin singletons rely on a shared runtime registry; two instances split the registry in half.
125
-
126
- Fix in the host's bundler config — `dedupe` collapses duplicates back to a single resolved copy:
127
-
128
- ```ts
129
- // vite.config.ts
130
- export default defineConfig({
131
- resolve: {
132
- dedupe: ['vue', 'vue-router', 'primevue', '@primeuix/themes'],
133
- },
134
- })
135
- ```
136
-
137
- 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
-
139
- Installing qdadm from npm (`@quazardous/qdadm@^1.19.3`) doesn't have this issue: the published tarball ships no `node_modules/`, so peers always resolve from the host. The trap is specific to `file:` / `workspace:` links onto a package that lives inside another monorepo.
29
+ ## Peer Dependencies
140
30
 
141
- 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.
31
+ vue ^3.3 · vue-router ^4 || ^5 · primevue ^4 · pinia ^2 || ^3 || ^4
142
32
 
143
33
  ## License
144
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.10.0",
3
+ "version": "2.12.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": "./src/styles/index.scss",
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,6 +52,7 @@
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,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
- activeStack: ActiveStack,
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
 
@@ -269,7 +269,7 @@ const slotActions = computed<SlotActions>(() => ({
269
269
  <!-- Step panels -->
270
270
  <StepPanels>
271
271
  <StepPanel
272
- v-for="(stepDef, idx) in steps"
272
+ v-for="(_, idx) in steps"
273
273
  :key="idx"
274
274
  :value="idx + 1"
275
275
  >
@@ -28,18 +28,23 @@ interface SelectOption {
28
28
 
29
29
  interface FieldConfig {
30
30
  name: string
31
- type?: InputType
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
- modelValue: { type: [String, Number, Boolean, Date, Object] as PropType<ModelValue>, default: null },
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'
@@ -210,7 +211,9 @@ const userDisplayName = computed<string>(() => {
210
211
 
211
212
  const userSubtitle = computed<string>(() => {
212
213
  const userData = user.value as UserData | null
213
- return userData?.email || userData?.role || ''
214
+ if (userData?.email) return userData.email
215
+ // raw ROLE_* constants are ugly by default — prettify (#1388)
216
+ return userData?.role ? prettifyRole(userData.role) : ''
214
217
  })
215
218
 
216
219
  async function handleLogout(): Promise<void> {
@@ -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
- return userData.value?.email || userData.value?.role || ''
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
- return formatNumber(num, {}, props.field.locale)
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
- value: { type: [String, Number, Boolean, Date, Object, Array], default: null },
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
- const auth = inject<AuthAdapter | null>('authAdapter')
65
- const features = inject<FeaturesConfig | null>('qdadmFeatures')
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>>(new Map())
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: route.params as RouteParamsRawGeneric },
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
  */
@@ -13,6 +13,10 @@ export interface MockApiStorageOptions<T extends EntityRecord = EntityRecord> {
13
13
  generateId?: () => string
14
14
  initialData?: T[] | null
15
15
  authCheck?: (() => boolean) | null
16
+ /** Override the localStorage key (default `mockapi_${entityName}_data`).
17
+ * Needed when several apps sharing an origin use the same entity names
18
+ * (e.g. multiple demos on one GitHub Pages site) (#1382). */
19
+ storageKey?: string
16
20
  }
17
21
 
18
22
  /**
@@ -63,6 +67,7 @@ export class MockApiStorage<T extends EntityRecord = EntityRecord> extends IStor
63
67
  generateId = defaultGenerateId,
64
68
  initialData = null,
65
69
  authCheck = null,
70
+ storageKey = null,
66
71
  } = options
67
72
 
68
73
  if (!entityName) {
@@ -72,7 +77,7 @@ export class MockApiStorage<T extends EntityRecord = EntityRecord> extends IStor
72
77
  this.entityName = entityName
73
78
  this.idField = idField
74
79
  this.generateId = generateId
75
- this._storageKey = `mockapi_${entityName}_data`
80
+ this._storageKey = storageKey || `mockapi_${entityName}_data`
76
81
  this._data = new Map()
77
82
  this._authCheck = authCheck
78
83
 
@@ -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({
@@ -21,7 +21,9 @@ export interface AuthAdapter {
21
21
  getToken?(): string | null
22
22
  logout?(): void
23
23
  connectSignals?(signals: SignalBus): () => void
24
- [key: string]: unknown
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?: EntityAuthAdapter | string | Record<string, unknown> | null
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
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
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
  ]
@@ -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 {}
@@ -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
  /**
@@ -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,97 @@
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
+ let symlinked = false
68
+ try {
69
+ const pkgDir = path.join(root, 'node_modules', '@quazardous', 'qdadm')
70
+ const real = fs.realpathSync(pkgDir)
71
+ if (real !== pkgDir) {
72
+ symlinked = true
73
+ fsAllow.push(searchForWorkspaceRoot(root), real)
74
+ }
75
+ } catch {
76
+ // not installed under root node_modules (aliased/hoisted) — nothing to allow
77
+ }
78
+
79
+ return {
80
+ resolve: {
81
+ dedupe: ['vue', 'vue-router', 'primevue', 'pinia', ...(options.dedupe ?? [])],
82
+ },
83
+ optimizeDeps: {
84
+ exclude: ['primevue', '@primeuix/themes', '@quazardous/qdadm'],
85
+ // One form per install mode: the nested path resolves through a
86
+ // real node_modules chain (npm installs), the plain form through a
87
+ // symlink realpath — declaring the unused one logs a "Failed to
88
+ // resolve dependency" warning (skybot testbed, #1389).
89
+ include: symlinked
90
+ ? [...CJS_TRANSITIVES]
91
+ : CJS_TRANSITIVES.map((dep) => `@quazardous/qdadm > ${dep}`),
92
+ },
93
+ ...(fsAllow.length > 0 ? { server: { fs: { allow: fsAllow } } } : {}),
94
+ }
95
+ },
96
+ }
97
+ }