@quazardous/qdadm 2.19.0 → 2.21.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/package.json +2 -2
- package/src/components/lists/ListPage.vue +7 -0
- package/src/composables/index.ts +3 -0
- package/src/composables/useListPage.filters.ts +137 -67
- package/src/composables/useListPage.ts +106 -18
- package/src/composables/useListPage.types.ts +74 -5
- package/src/composables/useListPage.utils.ts +58 -0
- package/src/entity/EntityManager.interface.ts +11 -0
- package/src/entity/EntityManager.ts +5 -0
- package/src/entity/EntityManager.types.ts +11 -0
- package/src/entity/auth/SecurityChecker.ts +88 -2
- package/src/entity/auth/index.ts +2 -0
- package/src/entity/storage/ApiStorage.ts +16 -2
- package/src/entity/storage/LocalStorage.ts +1 -1
- package/src/entity/storage/MemoryStorage.ts +7 -2
- package/src/entity/storage/MockApiStorage.ts +1 -1
- package/src/entity/storage/SdkStorage.ts +12 -3
- package/src/index.ts +19 -0
- package/src/kernel/Kernel.auth.ts +4 -0
- package/src/kernel/Kernel.registries.ts +26 -7
- package/src/kernel/Kernel.types.ts +23 -1
- package/src/kernel/Kernel.vue.ts +3 -0
- package/src/kernel/SignalBus.ts +6 -0
- package/src/modules/debug/AuthCollector.ts +15 -1
- package/src/modules/debug/components/panels/AuthPanel.vue +1 -0
- package/src/plugin.ts +1 -1
- package/src/query/clientFilter.ts +14 -3
- package/src/routeState/CompositePersister.ts +144 -0
- package/src/routeState/CookiePersister.ts +109 -0
- package/src/routeState/MemoryPersister.ts +60 -0
- package/src/routeState/NullPersister.ts +33 -0
- package/src/routeState/RouteStatePersister.ts +86 -0
- package/src/routeState/UrlPersister.ts +207 -0
- package/src/routeState/WebStoragePersister.ts +149 -0
- package/src/routeState/defaultPersister.ts +66 -0
- package/src/routeState/factory.ts +86 -0
- package/src/routeState/index.ts +32 -0
- package/src/routeState/keyLocator.ts +84 -0
- package/src/security/RolesProvider.ts +8 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Extracted from useListPage.ts for maintainability.
|
|
5
5
|
*/
|
|
6
|
+
import type { RouteStatePersister } from '../routeState'
|
|
6
7
|
import type { Ref, ComputedRef } from 'vue'
|
|
7
8
|
import type { Router } from 'vue-router'
|
|
8
9
|
import type { FilterQuery } from '../query/FilterQuery'
|
|
@@ -76,9 +77,45 @@ export interface ColumnConfig {
|
|
|
76
77
|
/**
|
|
77
78
|
* Filter configuration
|
|
78
79
|
*/
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The filter controls qdadm knows how to render.
|
|
82
|
+
*
|
|
83
|
+
* Exported as a VALUE, not only a type, so a consumer's own guard can read
|
|
84
|
+
* this list instead of copying it — a copy goes stale the day a type is
|
|
85
|
+
* added and starts rejecting correct code.
|
|
86
|
+
*/
|
|
87
|
+
export const FILTER_TYPES = [
|
|
88
|
+
'select',
|
|
89
|
+
'multiselect',
|
|
90
|
+
'date',
|
|
91
|
+
'checkbox',
|
|
92
|
+
'dropdown',
|
|
93
|
+
'autocomplete',
|
|
94
|
+
] as const
|
|
95
|
+
|
|
96
|
+
export type FilterType = (typeof FILTER_TYPES)[number]
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Everything a filter can be given EXCEPT its name (#2147).
|
|
100
|
+
*
|
|
101
|
+
* Split out rather than written as `Omit<FilterConfig, 'name'>`, and the
|
|
102
|
+
* reason is not style. `FilterConfig` carries an index signature, so
|
|
103
|
+
* `keyof` includes `string`; `Exclude<string, 'name'>` is still `string`, and
|
|
104
|
+
* the `Omit` therefore collapsed to a bare `{ [x: string]: unknown }` —
|
|
105
|
+
* ERASING every declared property.
|
|
106
|
+
*
|
|
107
|
+
* `addFilter` took that type, so **no filter option was checked at all**:
|
|
108
|
+
* not `type`, not `optionLabel`, not `local_filter`. A consumer declared
|
|
109
|
+
* `type: 'text'`, TypeScript accepted it, and the filter rendered as an empty
|
|
110
|
+
* dropdown saying "No available options". Nothing was wrong at any single
|
|
111
|
+
* step, which is why it survived.
|
|
112
|
+
*
|
|
113
|
+
* Declaring the properties here and having `FilterConfig` extend it keeps the
|
|
114
|
+
* index signature — apps do stash their own keys — while the declared ones
|
|
115
|
+
* are checked again.
|
|
116
|
+
*/
|
|
117
|
+
export interface FilterOptions {
|
|
118
|
+
type?: FilterType
|
|
82
119
|
placeholder?: string
|
|
83
120
|
options?: Array<{ label: string; value: unknown }>
|
|
84
121
|
default?: unknown
|
|
@@ -101,6 +138,10 @@ export interface FilterConfig {
|
|
|
101
138
|
[key: string]: unknown
|
|
102
139
|
}
|
|
103
140
|
|
|
141
|
+
export interface FilterConfig extends FilterOptions {
|
|
142
|
+
name: string
|
|
143
|
+
}
|
|
144
|
+
|
|
104
145
|
/**
|
|
105
146
|
* Row action configuration
|
|
106
147
|
*/
|
|
@@ -229,8 +270,27 @@ export interface UseListPageOptions<T = unknown> {
|
|
|
229
270
|
persistFilters?: boolean
|
|
230
271
|
/** Persist the active sort per entity (session storage), restored on init (#1218). Default true. */
|
|
231
272
|
persistSort?: boolean
|
|
232
|
-
/**
|
|
273
|
+
/**
|
|
274
|
+
* WRITE filters, search and the page to the URL query (default: true).
|
|
275
|
+
*
|
|
276
|
+
* Writing only. `false` has never stopped the query string being read, so
|
|
277
|
+
* a deep link someone typed by hand still restores the list — say
|
|
278
|
+
* `routeState` if you want the state kept somewhere else entirely.
|
|
279
|
+
*/
|
|
233
280
|
syncUrlParams?: boolean
|
|
281
|
+
/**
|
|
282
|
+
* Where this list's state is remembered (#2146): a slug the framework
|
|
283
|
+
* knows (`'url'`, …), or your own persister. Defaults to the URL.
|
|
284
|
+
*
|
|
285
|
+
* Setting it is a deliberate choice of medium, so it answers
|
|
286
|
+
* `syncUrlParams` rather than obeying it: a list with `routeState` set
|
|
287
|
+
* reads and writes that medium whatever the URL flag says.
|
|
288
|
+
*
|
|
289
|
+
* An unknown slug throws rather than falling back to the URL — a list
|
|
290
|
+
* quietly persisting somewhere other than where you asked is worse than
|
|
291
|
+
* one that will not start (ADR 0011).
|
|
292
|
+
*/
|
|
293
|
+
routeState?: string | RouteStatePersister
|
|
234
294
|
/** Filter mode: 'auto' | 'manager' | 'local' */
|
|
235
295
|
filterMode?: 'auto' | 'manager' | 'local'
|
|
236
296
|
/** Auto-filter threshold for 'auto' mode */
|
|
@@ -270,6 +330,15 @@ export interface ListPageProps {
|
|
|
270
330
|
lazy: boolean
|
|
271
331
|
totalRecords: number
|
|
272
332
|
rows: number
|
|
333
|
+
/**
|
|
334
|
+
* Row offset of the current page — what POSITIONS the paginator (#2145).
|
|
335
|
+
*
|
|
336
|
+
* In lazy mode the table renders exactly the rows it is handed and has no
|
|
337
|
+
* way to know which page they are; `first` is the only thing that tells it.
|
|
338
|
+
* Without it, a restored page 2 showed page 2's rows under a paginator
|
|
339
|
+
* highlighting 1.
|
|
340
|
+
*/
|
|
341
|
+
first: number
|
|
273
342
|
rowsPerPageOptions: number[]
|
|
274
343
|
sortField: string | null
|
|
275
344
|
sortOrder: number
|
|
@@ -382,7 +451,7 @@ export interface UseListPageReturn<T = unknown> {
|
|
|
382
451
|
filterValues: Ref<Record<string, unknown>>
|
|
383
452
|
filteredItems: ComputedRef<T[]>
|
|
384
453
|
fromCache: Ref<boolean>
|
|
385
|
-
addFilter: (name: string, config:
|
|
454
|
+
addFilter: (name: string, config: FilterOptions) => void
|
|
386
455
|
removeFilter: (name: string) => void
|
|
387
456
|
setFilterValue: (name: string, value: unknown) => void
|
|
388
457
|
updateFilters: (values: Record<string, unknown>) => void
|
|
@@ -40,6 +40,35 @@ export function persistPageSize(size: number): void {
|
|
|
40
40
|
setCookie(COOKIE_NAME, size, COOKIE_EXPIRY_DAYS)
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Settle the rows-per-page a list opens on (#2146).
|
|
45
|
+
*
|
|
46
|
+
* The seam owns this now, so the stored value arrives from whichever medium
|
|
47
|
+
* the app chose. The LEGACY bare-number cookie is read only when the seam has
|
|
48
|
+
* nothing — an existing user's choice must survive the move, or a comfort
|
|
49
|
+
* setting they picked once quietly resets on upgrade.
|
|
50
|
+
*
|
|
51
|
+
* Anything outside {@link PAGE_SIZE_OPTIONS} falls back to the default: the
|
|
52
|
+
* value reaches us from a cookie or a query string that anyone can edit, and
|
|
53
|
+
* a row count the paginator cannot offer would leave its dropdown showing a
|
|
54
|
+
* blank.
|
|
55
|
+
*/
|
|
56
|
+
export function restorePageSize(stored: unknown, defaultSize: number): number {
|
|
57
|
+
const fromSeam = Number(stored)
|
|
58
|
+
if (PAGE_SIZE_OPTIONS.includes(fromSeam)) return fromSeam
|
|
59
|
+
return getSavedPageSize(defaultSize)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Drop the pre-seam cookie once its value has been carried over.
|
|
64
|
+
*
|
|
65
|
+
* Left in place it would outlive the setting it stands for by a year, and
|
|
66
|
+
* shadow the seam on any machine where the new store was cleared.
|
|
67
|
+
*/
|
|
68
|
+
export function retireLegacyPageSizeCookie(): void {
|
|
69
|
+
setCookie(COOKIE_NAME, '', -1)
|
|
70
|
+
}
|
|
71
|
+
|
|
43
72
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
73
|
// Default label formatter (snake_case → Title Case)
|
|
45
74
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -129,6 +158,35 @@ export function clearSessionSort(key: string): void {
|
|
|
129
158
|
}
|
|
130
159
|
}
|
|
131
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Settle the sort a list opens on (#2146).
|
|
163
|
+
*
|
|
164
|
+
* The seam owns this now. The medium has not changed — the default
|
|
165
|
+
* composition routes `sort` back to `sessionStorage`, deliberately, so that
|
|
166
|
+
* moving it behind an interface changes nothing anyone can observe. Only the
|
|
167
|
+
* KEY changed, from `qdadm_sort_offers` to `qdadm:offers:sort`.
|
|
168
|
+
*
|
|
169
|
+
* Which is why the legacy entry is still read when the seam has nothing: a
|
|
170
|
+
* sort somebody set five minutes ago must not vanish because the framework
|
|
171
|
+
* was upgraded under them.
|
|
172
|
+
*/
|
|
173
|
+
export function restoreSort(
|
|
174
|
+
stored: { sort?: unknown; sortOrder?: unknown } | null,
|
|
175
|
+
legacyKey: string,
|
|
176
|
+
defaults: SessionSort
|
|
177
|
+
): SessionSort {
|
|
178
|
+
const field = stored?.sort
|
|
179
|
+
const order = Number(stored?.sortOrder)
|
|
180
|
+
if (typeof field === 'string' && field && (order === 1 || order === -1)) {
|
|
181
|
+
return { field, order }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const legacy = getSessionSort(legacyKey)
|
|
185
|
+
if (legacy) return legacy
|
|
186
|
+
|
|
187
|
+
return defaults
|
|
188
|
+
}
|
|
189
|
+
|
|
132
190
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
133
191
|
// Smart filter discovery
|
|
134
192
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import type { EntityBadge } from './EntityManager.types'
|
|
28
28
|
import type { SignalBus } from '../kernel/SignalBus'
|
|
29
29
|
import type { EntityAuthAdapter } from './auth/EntityAuthAdapter'
|
|
30
|
+
import type { RouteStatePersister } from '../routeState'
|
|
30
31
|
|
|
31
32
|
// ─── Base ────────────────────────────────────────────────────────────────────
|
|
32
33
|
|
|
@@ -84,6 +85,16 @@ export interface EntityManagerRead<T = unknown> extends EntityManagerBase<T>, En
|
|
|
84
85
|
getFieldConfig: (name: string) => unknown | null
|
|
85
86
|
/** Config-carrying property: always present on the class, VALUE nullable */
|
|
86
87
|
localFilterThreshold?: number | null
|
|
88
|
+
/**
|
|
89
|
+
* Where lists of this entity remember their state (#2146): a slug the
|
|
90
|
+
* framework knows, or a persister instance.
|
|
91
|
+
*
|
|
92
|
+
* A property of the ENTITY, so every screen showing it agrees — a list of
|
|
93
|
+
* audit rows nobody links to can say `local_storage` once here rather than
|
|
94
|
+
* on each of the four pages that render it. A list's own `routeState`
|
|
95
|
+
* still wins; the kernel default applies when neither says anything.
|
|
96
|
+
*/
|
|
97
|
+
routeState?: string | RouteStatePersister
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
// ─── CRUD ────────────────────────────────────────────────────────────────────
|
|
@@ -51,6 +51,7 @@ export type {
|
|
|
51
51
|
} from './EntityManager.types'
|
|
52
52
|
|
|
53
53
|
// Import concrete types needed in method signatures
|
|
54
|
+
import type { RouteStatePersister } from '../routeState'
|
|
54
55
|
import type {
|
|
55
56
|
RoutingContext,
|
|
56
57
|
PresaveContext,
|
|
@@ -88,6 +89,8 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
88
89
|
protected _fields: Record<string, FieldConfig>
|
|
89
90
|
|
|
90
91
|
localFilterThreshold: number | null
|
|
92
|
+
/** Where lists of this entity remember their state (#2146); null = inherit. */
|
|
93
|
+
routeState: string | RouteStatePersister | null
|
|
91
94
|
/** Default null placement for local sorts (#1222); field-level nullSort wins. */
|
|
92
95
|
nullSort?: NullSortMode
|
|
93
96
|
protected _cacheTtlMs: number | null
|
|
@@ -163,6 +166,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
163
166
|
badges = null,
|
|
164
167
|
fields = {},
|
|
165
168
|
localFilterThreshold = null,
|
|
169
|
+
routeState = null,
|
|
166
170
|
nullSort,
|
|
167
171
|
cacheTtlMs = null,
|
|
168
172
|
asymmetric = false,
|
|
@@ -196,6 +200,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
196
200
|
this._fields = fields
|
|
197
201
|
|
|
198
202
|
this.localFilterThreshold = localFilterThreshold
|
|
203
|
+
this.routeState = routeState
|
|
199
204
|
this.nullSort = nullSort
|
|
200
205
|
this._cacheTtlMs = cacheTtlMs
|
|
201
206
|
this._asymmetric = asymmetric
|
|
@@ -16,6 +16,7 @@ import type { HookRegistry } from '../hooks/HookRegistry'
|
|
|
16
16
|
|
|
17
17
|
// Circular type import — safe because `import type` is erased at runtime
|
|
18
18
|
import type { EntityManager } from './EntityManager'
|
|
19
|
+
import type { RouteStatePersister } from '../routeState'
|
|
19
20
|
|
|
20
21
|
// ============ INTERNAL TYPES ============
|
|
21
22
|
|
|
@@ -235,6 +236,16 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
|
|
|
235
236
|
badges?: (entity: T) => EntityBadge[]
|
|
236
237
|
fields?: Record<string, FieldConfig>
|
|
237
238
|
localFilterThreshold?: number | null
|
|
239
|
+
/**
|
|
240
|
+
* Where lists of this entity remember their state (#2146): a slug the
|
|
241
|
+
* framework knows, or a persister instance.
|
|
242
|
+
*
|
|
243
|
+
* A property of the ENTITY, so every screen showing it agrees — a list of
|
|
244
|
+
* audit rows nobody links to can say `local_storage` once here rather than
|
|
245
|
+
* on each of the four pages that render it. A list's own `routeState`
|
|
246
|
+
* still wins; the kernel default applies when neither says anything.
|
|
247
|
+
*/
|
|
248
|
+
routeState?: string | RouteStatePersister
|
|
238
249
|
/** Default null placement for local sorts: 'first' | 'last' | 'low' | 'high' (default 'last', #1222). Field-level nullSort wins. */
|
|
239
250
|
nullSort?: NullSortMode
|
|
240
251
|
/** Cache TTL in milliseconds (0=disabled, -1=infinite, >0=TTL). Overrides global, overridden by storage. */
|
|
@@ -15,6 +15,43 @@ export interface RoleProvider {
|
|
|
15
15
|
readonly canPersist: boolean
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* What an application's judge receives at install time (#2225).
|
|
20
|
+
*/
|
|
21
|
+
export interface GrantJudgeContext {
|
|
22
|
+
orchestrator?: unknown
|
|
23
|
+
signals?: unknown
|
|
24
|
+
/**
|
|
25
|
+
* The live permission registry — a REFERENCE, not a snapshot.
|
|
26
|
+
*
|
|
27
|
+
* `install` runs while the Kernel is still being built, before modules
|
|
28
|
+
* register their entities, so `getKeys()` called here returns only the
|
|
29
|
+
* framework's own namespaces. Read the keys when you pre-warm (after login,
|
|
30
|
+
* before mount), not in `install`.
|
|
31
|
+
*/
|
|
32
|
+
permissionRegistry?: { getKeys(): string[] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* An application-provided judgement of permissions (#2225).
|
|
37
|
+
*
|
|
38
|
+
* For apps whose backend decides who may do what: the judge answers from its
|
|
39
|
+
* own cache instead of qdadm's role matrix. It is consulted FIRST, for roles
|
|
40
|
+
* and permissions alike.
|
|
41
|
+
*
|
|
42
|
+
* - `true` / `false` is the verdict.
|
|
43
|
+
* - Anything else (`undefined`, `null`, a cache miss) falls through to the
|
|
44
|
+
* built-in judgement, which stays the default.
|
|
45
|
+
* - A judge that THROWS denies — see `SecurityChecker._judge`.
|
|
46
|
+
*
|
|
47
|
+
* `isGranted` is called synchronously on every check, so answer from memory.
|
|
48
|
+
* Fetch in bulk beforehand and emit `security:changed` when answers change.
|
|
49
|
+
*/
|
|
50
|
+
export interface GrantJudge {
|
|
51
|
+
isGranted(attribute: string, subject: unknown, user: AuthUser): boolean | undefined
|
|
52
|
+
install?(ctx: GrantJudgeContext): void
|
|
53
|
+
}
|
|
54
|
+
|
|
18
55
|
/**
|
|
19
56
|
* SecurityChecker constructor options
|
|
20
57
|
*/
|
|
@@ -22,6 +59,8 @@ export interface SecurityCheckerOptions {
|
|
|
22
59
|
rolesProvider?: RoleProvider
|
|
23
60
|
roleHierarchy?: RoleHierarchy | RoleHierarchyConfig
|
|
24
61
|
rolePermissions?: Record<string, string[]>
|
|
62
|
+
/** Application-provided judgement, consulted first (#2225). */
|
|
63
|
+
grant?: GrantJudge
|
|
25
64
|
getCurrentUser: () => AuthUser | null
|
|
26
65
|
}
|
|
27
66
|
|
|
@@ -32,6 +71,8 @@ export interface SecurityCheckerConfig {
|
|
|
32
71
|
rolesProvider?: RoleProvider
|
|
33
72
|
role_hierarchy?: RoleHierarchyConfig
|
|
34
73
|
role_permissions?: Record<string, string[]>
|
|
74
|
+
/** Application-provided judgement, consulted first (#2225). */
|
|
75
|
+
grant?: GrantJudge
|
|
35
76
|
getCurrentUser: () => AuthUser | null
|
|
36
77
|
}
|
|
37
78
|
|
|
@@ -55,8 +96,12 @@ export class SecurityChecker {
|
|
|
55
96
|
readonly getCurrentUser: () => AuthUser | null
|
|
56
97
|
/** Roles already warned about (warn-once, #1388) */
|
|
57
98
|
protected _warnedRoles = new Set<string>()
|
|
99
|
+
/** Application-provided judgement, consulted before the role matrix (#2225) */
|
|
100
|
+
protected _grant: GrantJudge | null
|
|
101
|
+
/** Attributes whose judge already threw (report-once) */
|
|
102
|
+
protected _judgeFailures = new Set<string>()
|
|
58
103
|
|
|
59
|
-
constructor({ rolesProvider, roleHierarchy, rolePermissions, getCurrentUser }: SecurityCheckerOptions) {
|
|
104
|
+
constructor({ rolesProvider, roleHierarchy, rolePermissions, grant, getCurrentUser }: SecurityCheckerOptions) {
|
|
60
105
|
if (rolesProvider) {
|
|
61
106
|
this._rolesProvider = rolesProvider
|
|
62
107
|
} else {
|
|
@@ -75,6 +120,12 @@ export class SecurityChecker {
|
|
|
75
120
|
}
|
|
76
121
|
|
|
77
122
|
this.getCurrentUser = getCurrentUser
|
|
123
|
+
this._grant = grant ?? null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The application's judge, when one is configured (#2225). */
|
|
127
|
+
get grant(): GrantJudge | null {
|
|
128
|
+
return this._grant
|
|
78
129
|
}
|
|
79
130
|
|
|
80
131
|
/**
|
|
@@ -108,10 +159,15 @@ export class SecurityChecker {
|
|
|
108
159
|
*
|
|
109
160
|
* This is the main contract method, similar to Symfony's isGranted().
|
|
110
161
|
*/
|
|
111
|
-
isGranted(attribute: string,
|
|
162
|
+
isGranted(attribute: string, subject: unknown = null): boolean {
|
|
112
163
|
const user = this.getCurrentUser()
|
|
113
164
|
if (!user) return false
|
|
114
165
|
|
|
166
|
+
// 0. The application's own judgement comes first (#2225). A boolean is a
|
|
167
|
+
// verdict; anything else falls through to the built-in matrix below.
|
|
168
|
+
const verdict = this._judge(attribute, subject, user)
|
|
169
|
+
if (verdict !== undefined) return verdict
|
|
170
|
+
|
|
115
171
|
// 1. Check if it's a role (ROLE_*)
|
|
116
172
|
if (attribute.startsWith('ROLE_')) {
|
|
117
173
|
return this.roleHierarchy.isGrantedRole(user.roles || [user.role || ''], attribute)
|
|
@@ -122,6 +178,34 @@ export class SecurityChecker {
|
|
|
122
178
|
return PermissionMatcher.any(userPerms, attribute)
|
|
123
179
|
}
|
|
124
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Ask the application's judge, if any.
|
|
183
|
+
*
|
|
184
|
+
* A judge that THROWS denies. Falling through to the built-in matrix would
|
|
185
|
+
* quietly substitute a different authority for the one the app configured,
|
|
186
|
+
* and failing open would grant whatever the backend never got to answer —
|
|
187
|
+
* the worst direction a security check can fail in (the rule the route
|
|
188
|
+
* guard already follows, #1190). Reported once per attribute.
|
|
189
|
+
*/
|
|
190
|
+
protected _judge(attribute: string, subject: unknown, user: AuthUser): boolean | undefined {
|
|
191
|
+
if (!this._grant) return undefined
|
|
192
|
+
let answer: unknown
|
|
193
|
+
try {
|
|
194
|
+
answer = this._grant.isGranted(attribute, subject, user)
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (!this._judgeFailures.has(attribute)) {
|
|
197
|
+
this._judgeFailures.add(attribute)
|
|
198
|
+
console.error(
|
|
199
|
+
`[qdadm] security.grant threw while judging "${attribute}" — DENIED. ` +
|
|
200
|
+
`A failing judge denies rather than falling back to the role matrix.`,
|
|
201
|
+
error
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
return typeof answer === 'boolean' ? answer : undefined
|
|
207
|
+
}
|
|
208
|
+
|
|
125
209
|
/**
|
|
126
210
|
* Get all permissions for a user (resolved from roles + user overrides)
|
|
127
211
|
*/
|
|
@@ -203,6 +287,7 @@ export function createSecurityChecker(config: SecurityCheckerConfig): SecurityCh
|
|
|
203
287
|
if (config.rolesProvider) {
|
|
204
288
|
return new SecurityChecker({
|
|
205
289
|
rolesProvider: config.rolesProvider,
|
|
290
|
+
grant: config.grant,
|
|
206
291
|
getCurrentUser: config.getCurrentUser,
|
|
207
292
|
})
|
|
208
293
|
}
|
|
@@ -213,6 +298,7 @@ export function createSecurityChecker(config: SecurityCheckerConfig): SecurityCh
|
|
|
213
298
|
role_hierarchy: config.role_hierarchy || {},
|
|
214
299
|
role_permissions: config.role_permissions || {},
|
|
215
300
|
}),
|
|
301
|
+
grant: config.grant,
|
|
216
302
|
getCurrentUser: config.getCurrentUser,
|
|
217
303
|
})
|
|
218
304
|
}
|
package/src/entity/auth/index.ts
CHANGED
|
@@ -151,7 +151,7 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
async list(params: ListParams = {}, context: RoutingContext | null = null): Promise<ListResult<T>> {
|
|
154
|
-
const { page = 1, page_size = 20, sort_by, sort_order, filters = {} } = params
|
|
154
|
+
const { page = 1, page_size = 20, sort_by, sort_order, search, filters = {} } = params
|
|
155
155
|
|
|
156
156
|
// `paramMapping` covers the WHOLE outgoing query, pagination included
|
|
157
157
|
// (#2113). It used to be applied to the filters only, so `status` could
|
|
@@ -160,9 +160,23 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
160
160
|
// storage for that alone. The names qdadm uses internally are its own
|
|
161
161
|
// business; what goes on the wire is the backend's.
|
|
162
162
|
//
|
|
163
|
+
// `search` goes out too (#2147). It is part of `ListParams` and always
|
|
164
|
+
// has been, but this method never read it — a user typed in the search
|
|
165
|
+
// box, the list rebuilt its query, and the term was dropped here without
|
|
166
|
+
// a word. The rows came back unfiltered and nothing anywhere said why,
|
|
167
|
+
// which is the silent no-op ADR 0011 exists to forbid.
|
|
168
|
+
//
|
|
169
|
+
// It travels under whatever name your backend uses, like every other key:
|
|
170
|
+
// `paramMapping: { search: 'q' }`.
|
|
171
|
+
//
|
|
172
|
+
// `searchFields` deliberately stays behind. It says which fields the
|
|
173
|
+
// FRONT should look at when filtering a cached page locally; a backend
|
|
174
|
+
// that searches knows its own columns, and shipping an array parameter
|
|
175
|
+
// nobody asked for would be a new surprise in place of the old one.
|
|
176
|
+
//
|
|
163
177
|
// Filters keep their precedence over the pagination keys, exactly as
|
|
164
178
|
// before: a filter named `page` still wins, for better or worse.
|
|
165
|
-
const outgoing = { page, page_size, sort_by, sort_order, ...filters }
|
|
179
|
+
const outgoing = { page, page_size, sort_by, sort_order, search, ...filters }
|
|
166
180
|
|
|
167
181
|
const response = await this.client.get<Record<string, unknown>>(this.endpoint, {
|
|
168
182
|
params: this._applyParamMapping(outgoing),
|
|
@@ -67,7 +67,7 @@ export class LocalStorage<T extends EntityRecord = EntityRecord> extends IStorag
|
|
|
67
67
|
items = filterItems(items, filters, { stringMatch: 'exact' })
|
|
68
68
|
|
|
69
69
|
// Apply search (shared pipeline, #1192)
|
|
70
|
-
items = searchItems(items, search
|
|
70
|
+
items = searchItems(items, search)
|
|
71
71
|
|
|
72
72
|
const total = items.length
|
|
73
73
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { IStorage } from './IStorage'
|
|
2
2
|
import type { EntityRecord, ListParams, ListResult, StorageCapabilities } from '../../types'
|
|
3
3
|
import { StorageError } from './errors'
|
|
4
|
-
import { sortItems, filterItems, paginate, defaultGenerateId } from '../../query/clientFilter'
|
|
4
|
+
import { sortItems, filterItems, paginate, defaultGenerateId, searchItems } from '../../query/clientFilter'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* MemoryStorage options
|
|
@@ -61,10 +61,15 @@ export class MemoryStorage<T extends EntityRecord = EntityRecord> extends IStora
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
async list(params: ListParams = {}): Promise<ListResult<T>> {
|
|
64
|
-
const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {} } = params
|
|
64
|
+
const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {}, search } = params
|
|
65
65
|
|
|
66
66
|
let items = this._getAll()
|
|
67
67
|
|
|
68
|
+
// Apply the search term (#2147). `searchItems` has existed since #1192
|
|
69
|
+
// and LocalStorage and MockApiStorage both called it; this one never did,
|
|
70
|
+
// so a search box over a MemoryStorage entity typed into the void.
|
|
71
|
+
items = searchItems(items, search)
|
|
72
|
+
|
|
68
73
|
// Apply filters (shared pipeline, #1192 — legacy substring semantics)
|
|
69
74
|
items = filterItems(items, filters, { stringMatch: 'includes' })
|
|
70
75
|
|
|
@@ -151,7 +151,7 @@ export class MockApiStorage<T extends EntityRecord = EntityRecord> extends IStor
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
// Apply search (shared pipeline, #1192)
|
|
154
|
-
items = searchItems(items, search
|
|
154
|
+
items = searchItems(items, search)
|
|
155
155
|
|
|
156
156
|
const total = items.length
|
|
157
157
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { IStorage } from './IStorage'
|
|
2
2
|
import type { EntityRecord, ListParams, ListResult, StorageCapabilities } from '../../types'
|
|
3
|
-
import { sortItems, filterItems, paginate } from '../../query/clientFilter'
|
|
3
|
+
import { sortItems, filterItems, paginate, searchItems } from '../../query/clientFilter'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* SDK method result with data property
|
|
@@ -275,9 +275,13 @@ export class SdkStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
async list(params: ListParams = {}): Promise<ListResult<T>> {
|
|
278
|
-
const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {} } = params
|
|
278
|
+
const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {}, search } = params
|
|
279
279
|
|
|
280
|
-
|
|
280
|
+
// `search` goes to the SDK with everything else (#2147). It was dropped
|
|
281
|
+
// here, so a search box over an SDK-backed entity typed into the void:
|
|
282
|
+
// the call went out without the term and the rows came back unfiltered,
|
|
283
|
+
// with nothing anywhere saying why.
|
|
284
|
+
const queryParams = { query: { page, page_size, sort_by, sort_order, search, ...filters } }
|
|
281
285
|
const data = (await this._execute('list', queryParams)) as ListResult<T>
|
|
282
286
|
|
|
283
287
|
let { items, total } = data
|
|
@@ -292,8 +296,13 @@ export class SdkStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
292
296
|
|
|
293
297
|
// Shared pipeline (#1192) — original order preserved: sort, then
|
|
294
298
|
// filter (substring semantics), then paginate.
|
|
299
|
+
//
|
|
300
|
+
// The search is applied here TOO, not only sent (#2147). An SDK that
|
|
301
|
+
// ignores the term would otherwise hand back the whole collection and
|
|
302
|
+
// this branch would paginate it as if it had been searched.
|
|
295
303
|
items = sortItems([...items], sort_by, sort_order)
|
|
296
304
|
items = filterItems(items, filters, { stringMatch: 'includes' })
|
|
305
|
+
items = searchItems(items, search)
|
|
297
306
|
total = items.length
|
|
298
307
|
items = paginate(items, page, page_size)
|
|
299
308
|
}
|
package/src/index.ts
CHANGED
|
@@ -237,10 +237,13 @@ export * from './composables/useJsonSyntax'
|
|
|
237
237
|
export {
|
|
238
238
|
useListPage,
|
|
239
239
|
PAGE_SIZE_OPTIONS,
|
|
240
|
+
FILTER_TYPES,
|
|
240
241
|
type UseListPageOptions,
|
|
241
242
|
type UseListPageReturn,
|
|
242
243
|
type ColumnConfig,
|
|
243
244
|
type FilterConfig,
|
|
245
|
+
type FilterOptions,
|
|
246
|
+
type FilterType,
|
|
244
247
|
type ActionConfig as ListActionConfig,
|
|
245
248
|
type ResolvedAction,
|
|
246
249
|
type HeaderActionConfig,
|
|
@@ -322,6 +325,22 @@ export {
|
|
|
322
325
|
type LayoutMeta,
|
|
323
326
|
} from './composables/useLayoutResolver'
|
|
324
327
|
export { useSSEBridge, type UseSSEBridgeReturn } from './composables/useSSEBridge'
|
|
328
|
+
|
|
329
|
+
// Route-state persistence (#2146) — where a route remembers what it shows.
|
|
330
|
+
export {
|
|
331
|
+
UrlPersister,
|
|
332
|
+
isRouteStatePersister,
|
|
333
|
+
resolveRouteStatePersister,
|
|
334
|
+
createRouteStatePersisterFactory,
|
|
335
|
+
type RouteStatePersister,
|
|
336
|
+
type RouteStateScope,
|
|
337
|
+
type RouteStatePersisterSpec,
|
|
338
|
+
type RouteStatePersisterFactory,
|
|
339
|
+
type UrlPersisterOptions,
|
|
340
|
+
dottedKeyLocator,
|
|
341
|
+
namespacedKeyLocator,
|
|
342
|
+
type RouteStateKeyLocator,
|
|
343
|
+
} from './routeState'
|
|
325
344
|
export {
|
|
326
345
|
useDeferred,
|
|
327
346
|
useDeferredValue,
|
|
@@ -120,11 +120,15 @@ export function applyAuthMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
120
120
|
*/
|
|
121
121
|
proto._setupAuthInvalidation = function (this: Self): void {
|
|
122
122
|
const debug = this.options.debug ?? false
|
|
123
|
+
// `security:changed` (#2225) rides the same path: nothing on screen reads
|
|
124
|
+
// permissions reactively (canCreate, menu filtering and the route guard
|
|
125
|
+
// all ask synchronously), so a remount is what makes new answers show.
|
|
123
126
|
const authSignals = [
|
|
124
127
|
'auth:login',
|
|
125
128
|
'auth:logout',
|
|
126
129
|
'auth:impersonate',
|
|
127
130
|
'auth:impersonate:stop',
|
|
131
|
+
'security:changed',
|
|
128
132
|
]
|
|
129
133
|
|
|
130
134
|
for (const signal of authSignals) {
|