@asteby/metacore-runtime-react 49.9.0 → 49.10.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/CHANGELOG.md +20 -0
- package/dist/business/line-items-editor.js +2 -2
- package/dist/business/line-product-cell.js +1 -1
- package/dist/dynamic-columns-shim.d.ts +17 -2
- package/dist/dynamic-columns-shim.d.ts.map +1 -1
- package/dist/dynamic-columns.d.ts +17 -2
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +37 -4
- package/dist/dynamic-kanban.d.ts +10 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +9 -8
- package/dist/dynamic-table.d.ts +26 -2
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +17 -7
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/permissions-context.d.ts +64 -3
- package/dist/permissions-context.d.ts.map +1 -1
- package/dist/permissions-context.js +52 -6
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/document-editor-inline-selects.test.tsx +7 -7
- package/src/__tests__/dynamic-table-default-sort-bulk-ctx.test.tsx +195 -0
- package/src/__tests__/image-cell-basepath.test.tsx +72 -0
- package/src/__tests__/permissions-allowed-roles.test.tsx +104 -0
- package/src/__tests__/row-action-predicate.test.tsx +172 -0
- package/src/business/line-items-editor.tsx +8 -5
- package/src/business/line-product-cell.tsx +1 -1
- package/src/dynamic-columns-shim.ts +17 -1
- package/src/dynamic-columns.tsx +52 -4
- package/src/dynamic-kanban.tsx +27 -5
- package/src/dynamic-table.tsx +38 -8
- package/src/index.ts +5 -0
- package/src/permissions-context.tsx +99 -4
- package/src/types.ts +8 -0
package/src/dynamic-table.tsx
CHANGED
|
@@ -69,7 +69,7 @@ import { useMetadataCache } from './metadata-cache'
|
|
|
69
69
|
import { useRecordMutationTick } from './use-record-mutation-tick'
|
|
70
70
|
import { useApi, useCurrentBranch } from './api-context'
|
|
71
71
|
import { useReasonPrompt } from './reason-prompt'
|
|
72
|
-
import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
|
|
72
|
+
import type { ColumnFilterConfig, GetDynamicColumns, RowActionPredicate } from './dynamic-columns-shim'
|
|
73
73
|
import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
|
|
74
74
|
import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
|
|
75
75
|
import { translateOptionLabels } from './filter-chips'
|
|
@@ -81,11 +81,11 @@ import {
|
|
|
81
81
|
} from './table-virtualization'
|
|
82
82
|
import { OptionsContext } from './options-context'
|
|
83
83
|
import { RowActionsModelContext } from './row-actions-menu'
|
|
84
|
-
import type { TableMetadata, ApiResponse, ColumnDefinition } from './types'
|
|
84
|
+
import type { TableMetadata, ApiResponse, ColumnDefinition, ActionDefinition } from './types'
|
|
85
85
|
import { getSearchableColumnKeys } from './column-visibility'
|
|
86
86
|
import { visibleRelationInclude } from './list-include'
|
|
87
87
|
import { useDebouncedValue } from './use-debounced-value'
|
|
88
|
-
import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context'
|
|
88
|
+
import { useCan, usePermissionsActive, useRoleGate, gateTableMetadata, modelCapability } from './permissions-context'
|
|
89
89
|
import { useDynamicRowActions } from './dynamic-row-actions'
|
|
90
90
|
import { ExportDialog } from './dialogs/export'
|
|
91
91
|
import { ImportDialog } from './dialogs/import'
|
|
@@ -184,6 +184,13 @@ export interface DynamicTableProps {
|
|
|
184
184
|
*/
|
|
185
185
|
mutationEndpoint?: string
|
|
186
186
|
enableUrlSync?: boolean
|
|
187
|
+
/**
|
|
188
|
+
* Initial sort applied when there is no user/URL sorting yet. It seeds the
|
|
189
|
+
* `sorting` state, so a `?sortBy=` deep-link wins over it (when
|
|
190
|
+
* `enableUrlSync`), and the user can still change or clear the sort via
|
|
191
|
+
* the column headers. Only read on mount.
|
|
192
|
+
*/
|
|
193
|
+
defaultSort?: { id: string; desc?: boolean }
|
|
187
194
|
/**
|
|
188
195
|
* Hide the import action on THIS view even when the model supports it.
|
|
189
196
|
* A role-scoped view (e.g. a rep seeing only their own records) usually
|
|
@@ -213,6 +220,15 @@ export interface DynamicTableProps {
|
|
|
213
220
|
* clickable and the behaviour is unchanged.
|
|
214
221
|
*/
|
|
215
222
|
onRowClick?: (row: any) => void
|
|
223
|
+
/**
|
|
224
|
+
* Consumer-side per-row gate for row actions, AND-ed with the metadata
|
|
225
|
+
* gates (`requiresState` + `condition`): it can only hide more. Receives the
|
|
226
|
+
* full action definition (adapt an `(actionKey, row)` predicate with
|
|
227
|
+
* `(a, row) => legacy(a.key, row)`). A throw hides the action (fail-closed,
|
|
228
|
+
* `console.error`). Memoize it (module-level fn or `useCallback`).
|
|
229
|
+
* Omitted → behaviour unchanged. Not a substitute for backend authorization.
|
|
230
|
+
*/
|
|
231
|
+
isRowActionVisible?: RowActionPredicate
|
|
216
232
|
refreshTrigger?: any
|
|
217
233
|
defaultFilters?: Record<string, any>
|
|
218
234
|
extraColumns?: ColumnDef<any>[]
|
|
@@ -294,6 +310,10 @@ export interface DynamicTableBulkContext {
|
|
|
294
310
|
selectedIds: Array<string | number>
|
|
295
311
|
clearSelection: () => void
|
|
296
312
|
refresh: () => void
|
|
313
|
+
/** Metadata actions visible to the current user (capability-gated when a PermissionsProvider is mounted). */
|
|
314
|
+
actions: ActionDefinition[]
|
|
315
|
+
/** `true` when the user may run `actionKey` on this model; always `true` without a PermissionsProvider. */
|
|
316
|
+
can: (actionKey: string) => boolean
|
|
297
317
|
}
|
|
298
318
|
|
|
299
319
|
/** True when an api-client error is an HTTP 403 (axios-style or fetch-style). */
|
|
@@ -307,12 +327,14 @@ export function DynamicTable({
|
|
|
307
327
|
endpoint,
|
|
308
328
|
mutationEndpoint,
|
|
309
329
|
enableUrlSync = true,
|
|
330
|
+
defaultSort,
|
|
310
331
|
hideImport,
|
|
311
332
|
hideExport,
|
|
312
333
|
hiddenColumns = [],
|
|
313
334
|
allowedActionKeys,
|
|
314
335
|
onAction,
|
|
315
336
|
onRowClick,
|
|
337
|
+
isRowActionVisible: rowActionPredicate,
|
|
316
338
|
refreshTrigger,
|
|
317
339
|
defaultFilters,
|
|
318
340
|
extraColumns = [],
|
|
@@ -395,7 +417,12 @@ export function DynamicTable({
|
|
|
395
417
|
const [bulkDeleteTotal, setBulkDeleteTotal] = useState(0)
|
|
396
418
|
|
|
397
419
|
const [rowSelection, setRowSelection] = useState({})
|
|
398
|
-
const [sorting, setSorting] = useState<SortingState>(
|
|
420
|
+
const [sorting, setSorting] = useState<SortingState>(() => {
|
|
421
|
+
if (!defaultSort) return []
|
|
422
|
+
// A deep-linked ?sortBy= wins; skip the default so no request goes out with it first.
|
|
423
|
+
if (enableUrlSync && typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('sortBy')) return []
|
|
424
|
+
return [{ id: defaultSort.id, desc: defaultSort.desc ?? false }]
|
|
425
|
+
})
|
|
399
426
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => {
|
|
400
427
|
const initial: VisibilityState = {}
|
|
401
428
|
hiddenColumns.forEach(col => { initial[col] = false })
|
|
@@ -799,10 +826,11 @@ export function DynamicTable({
|
|
|
799
826
|
// filtered by `can(lowercase(model).<action>)`.
|
|
800
827
|
const can = useCan()
|
|
801
828
|
const permissionsActive = usePermissionsActive()
|
|
829
|
+
const roleGate = useRoleGate()
|
|
802
830
|
const viewMetadata = useMemo(() => {
|
|
803
831
|
if (!metadata || !permissionsActive) return metadata
|
|
804
|
-
return gateTableMetadata(metadata, model, can, (key, fallback) => t(key, { defaultValue: fallback }))
|
|
805
|
-
}, [metadata, permissionsActive, can, model, t])
|
|
832
|
+
return gateTableMetadata(metadata, model, can, (key, fallback) => t(key, { defaultValue: fallback }), roleGate)
|
|
833
|
+
}, [metadata, permissionsActive, can, model, t, roleGate])
|
|
806
834
|
|
|
807
835
|
// Row-action menus mount their icons on open: load them with the
|
|
808
836
|
// metadata so the first open draws every glyph at once.
|
|
@@ -1396,12 +1424,12 @@ export function DynamicTable({
|
|
|
1396
1424
|
}
|
|
1397
1425
|
return actions === viewMetadata.actions ? viewMetadata : { ...viewMetadata, actions }
|
|
1398
1426
|
})()
|
|
1399
|
-
const baseColumns = getDynamicColumns(rowMetadata, handleInternalAction, t, i18n.language, columnFilterConfigs, timeZone, currency)
|
|
1427
|
+
const baseColumns = getDynamicColumns(rowMetadata, handleInternalAction, t, i18n.language, columnFilterConfigs, timeZone, currency, rowActionPredicate)
|
|
1400
1428
|
const filteredBase = baseColumns.filter((col: ColumnDef<any>) => !effectiveHiddenColumns.includes(col.id as string))
|
|
1401
1429
|
const actionsCol = filteredBase.find((c: ColumnDef<any>) => c.id === 'actions')
|
|
1402
1430
|
const otherCols = filteredBase.filter((c: ColumnDef<any>) => c.id !== 'actions')
|
|
1403
1431
|
return [...otherCols, ...extraColumns, ...(actionsCol ? [actionsCol] : [])]
|
|
1404
|
-
}, [viewMetadata, handleInternalAction, effectiveHiddenColumns, allowedActionKeys, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns, timeZone, currency])
|
|
1432
|
+
}, [viewMetadata, handleInternalAction, effectiveHiddenColumns, allowedActionKeys, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns, timeZone, currency, rowActionPredicate])
|
|
1405
1433
|
|
|
1406
1434
|
const filters = useMemo(() => [], [])
|
|
1407
1435
|
|
|
@@ -1979,6 +2007,8 @@ export function DynamicTable({
|
|
|
1979
2007
|
selectedIds: bulkSelectedRows.map((r) => r.id).filter((id) => id != null),
|
|
1980
2008
|
clearSelection: () => table.resetRowSelection(),
|
|
1981
2009
|
refresh: handleRefresh,
|
|
2010
|
+
actions: viewMetadata?.actions ?? [],
|
|
2011
|
+
can: (actionKey: string) => !permissionsActive || can(modelCapability(model, actionKey)),
|
|
1982
2012
|
})
|
|
1983
2013
|
: extraBulkActions}
|
|
1984
2014
|
{!hideBulkDelete && (
|
package/src/index.ts
CHANGED
|
@@ -259,12 +259,15 @@ export {
|
|
|
259
259
|
PermissionsProvider,
|
|
260
260
|
useCan,
|
|
261
261
|
usePermissionsActive,
|
|
262
|
+
useRoleGate,
|
|
263
|
+
isActionAllowedForRoles,
|
|
262
264
|
makeCan,
|
|
263
265
|
capabilityForActionKey,
|
|
264
266
|
modelCapability,
|
|
265
267
|
gateTableMetadata,
|
|
266
268
|
resolveRowActions,
|
|
267
269
|
type CanFn,
|
|
270
|
+
type RoleGate,
|
|
268
271
|
type PermissionsProviderProps,
|
|
269
272
|
} from './permissions-context'
|
|
270
273
|
export {
|
|
@@ -423,6 +426,7 @@ export type {
|
|
|
423
426
|
ColumnFilterConfig,
|
|
424
427
|
FilterOption as DynamicColumnFilterOption,
|
|
425
428
|
GetDynamicColumns,
|
|
429
|
+
RowActionPredicate,
|
|
426
430
|
DynamicIconComponent,
|
|
427
431
|
} from './dynamic-columns-shim'
|
|
428
432
|
export {
|
|
@@ -430,6 +434,7 @@ export {
|
|
|
430
434
|
makeDefaultGetDynamicColumns,
|
|
431
435
|
relationKeyFor,
|
|
432
436
|
resolveAvatarSrc,
|
|
437
|
+
resolveImageSrc,
|
|
433
438
|
resolveMissingActorLabel,
|
|
434
439
|
resolveActorDisplayName,
|
|
435
440
|
resolveRelationLabel,
|
|
@@ -35,9 +35,44 @@ export interface PermissionsProviderProps {
|
|
|
35
35
|
permissions: string[]
|
|
36
36
|
/** Superrole bypass — admins/owners see everything, no filtering at all. */
|
|
37
37
|
isAdmin: boolean
|
|
38
|
+
/**
|
|
39
|
+
* Roles of the current user (e.g. `['doctor']`), used ONLY to honour the
|
|
40
|
+
* per-action `allowedRoles` declared in table metadata. Opt-in: while this
|
|
41
|
+
* is `undefined` (and `loading` is not set) role filtering is OFF and
|
|
42
|
+
* `allowedRoles` is ignored, exactly as before this prop existed. Once the
|
|
43
|
+
* host passes an array (even `[]` = "resolved, no roles") the filter is
|
|
44
|
+
* fail-closed: an action with a non-empty `allowedRoles` that shares no
|
|
45
|
+
* role with the user is hidden.
|
|
46
|
+
*
|
|
47
|
+
* Pass a referentially stable array (`useMemo`, or a value straight from a
|
|
48
|
+
* store): the table rebuilds its columns when `roles` / `superRoles` change
|
|
49
|
+
* identity, so an inline `roles={[role]}` recreates them on every render.
|
|
50
|
+
*/
|
|
51
|
+
roles?: string[]
|
|
52
|
+
/**
|
|
53
|
+
* Roles that bypass `allowedRoles` entirely (the host's superroles, e.g.
|
|
54
|
+
* `['admin', 'super_admin']`). Default `[]` = no role bypasses. `isAdmin`
|
|
55
|
+
* also bypasses. Same stable-reference advice as `roles`.
|
|
56
|
+
*/
|
|
57
|
+
superRoles?: string[]
|
|
58
|
+
/**
|
|
59
|
+
* The host is still hydrating the session roles. While true no action is
|
|
60
|
+
* hidden by `allowedRoles` (avoids a flash of missing actions); set it back
|
|
61
|
+
* to false once `roles` is resolved.
|
|
62
|
+
*/
|
|
63
|
+
rolesLoading?: boolean
|
|
38
64
|
children: React.ReactNode
|
|
39
65
|
}
|
|
40
66
|
|
|
67
|
+
/** Resolved role-gating state shared with the table surfaces. */
|
|
68
|
+
export interface RoleGate {
|
|
69
|
+
/** User roles; `undefined` while unresolved. */
|
|
70
|
+
roles?: string[]
|
|
71
|
+
superRoles: string[]
|
|
72
|
+
loading: boolean
|
|
73
|
+
isAdmin: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
41
76
|
// ---------------------------------------------------------------------------
|
|
42
77
|
// Core
|
|
43
78
|
// ---------------------------------------------------------------------------
|
|
@@ -56,10 +91,64 @@ export function makeCan(permissions: string[], isAdmin: boolean): CanFn {
|
|
|
56
91
|
const ALWAYS_ALLOW: CanFn = () => true
|
|
57
92
|
|
|
58
93
|
const PermissionsContext = createContext<CanFn | null>(null)
|
|
94
|
+
const RoleGateContext = createContext<RoleGate | null>(null)
|
|
95
|
+
|
|
96
|
+
const NO_ROLES: string[] = []
|
|
59
97
|
|
|
60
|
-
export function PermissionsProvider({
|
|
98
|
+
export function PermissionsProvider({
|
|
99
|
+
permissions,
|
|
100
|
+
isAdmin,
|
|
101
|
+
roles,
|
|
102
|
+
superRoles = NO_ROLES,
|
|
103
|
+
rolesLoading = false,
|
|
104
|
+
children,
|
|
105
|
+
}: PermissionsProviderProps) {
|
|
61
106
|
const can = useMemo(() => makeCan(permissions, isAdmin), [permissions, isAdmin])
|
|
62
|
-
|
|
107
|
+
const roleGate = useMemo<RoleGate>(
|
|
108
|
+
() => ({ roles, superRoles, loading: rolesLoading, isAdmin }),
|
|
109
|
+
[roles, superRoles, rolesLoading, isAdmin],
|
|
110
|
+
)
|
|
111
|
+
return (
|
|
112
|
+
<PermissionsContext.Provider value={can}>
|
|
113
|
+
<RoleGateContext.Provider value={roleGate}>{children}</RoleGateContext.Provider>
|
|
114
|
+
</PermissionsContext.Provider>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Role-gating state of the nearest <PermissionsProvider>, or `null` when none
|
|
120
|
+
* is mounted (no provider → no role filtering, legacy behaviour).
|
|
121
|
+
*/
|
|
122
|
+
export function useRoleGate(): RoleGate | null {
|
|
123
|
+
return useContext(RoleGateContext)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether `action` is visible for the user under `gate`. Pure.
|
|
128
|
+
*
|
|
129
|
+
* UX ONLY: this hides buttons, it does not authorize anything. The backend
|
|
130
|
+
* remains the authority and must enforce the same `allowedRoles` on execution.
|
|
131
|
+
*
|
|
132
|
+
* Rules:
|
|
133
|
+
* - no gate (no provider) → visible (unchanged behaviour).
|
|
134
|
+
* - action without `allowedRoles` (or empty) → visible. Reads both the
|
|
135
|
+
* camelCase `allowedRoles` and the snake_case `allowed_roles` wire forms.
|
|
136
|
+
* - `gate.loading` → visible (don't flash-hide while the session hydrates).
|
|
137
|
+
* - `gate.roles === undefined` and not loading → the host did not opt in to
|
|
138
|
+
* role gating → visible.
|
|
139
|
+
* - `gate.isAdmin` or any user role in `gate.superRoles` → visible.
|
|
140
|
+
* - otherwise visible only if the user shares a role with `allowedRoles`
|
|
141
|
+
* (fail-closed: `roles: []` sees none of the restricted actions).
|
|
142
|
+
*/
|
|
143
|
+
export function isActionAllowedForRoles(action: ActionDefinition, gate: RoleGate | null): boolean {
|
|
144
|
+
if (!gate) return true
|
|
145
|
+
const declared = action.allowedRoles ?? action.allowed_roles
|
|
146
|
+
if (!declared || declared.length === 0) return true
|
|
147
|
+
if (gate.loading || gate.roles === undefined) return true
|
|
148
|
+
if (gate.isAdmin) return true
|
|
149
|
+
const userRoles = gate.roles
|
|
150
|
+
if (userRoles.some((r) => gate.superRoles.includes(r))) return true
|
|
151
|
+
return userRoles.some((r) => declared.includes(r))
|
|
63
152
|
}
|
|
64
153
|
|
|
65
154
|
/**
|
|
@@ -113,6 +202,10 @@ const DEFAULT_TRIO: { key: string; i18nKey: string; fallback: string; icon: stri
|
|
|
113
202
|
* actions so individual entries can be dropped; `tx` resolves their labels
|
|
114
203
|
* (defaults to the Spanish fallbacks used by the column factory).
|
|
115
204
|
*
|
|
205
|
+
* When `roleGate` is given, actions declaring `allowedRoles` are also dropped
|
|
206
|
+
* for users outside those roles (see `isActionAllowedForRoles`; UX only, the
|
|
207
|
+
* backend stays the authority).
|
|
208
|
+
*
|
|
116
209
|
* Pure + idempotent. Callers should only invoke it when a provider is active
|
|
117
210
|
* (`usePermissionsActive()`), otherwise pass the metadata through untouched.
|
|
118
211
|
*/
|
|
@@ -121,6 +214,7 @@ export function gateTableMetadata(
|
|
|
121
214
|
model: string,
|
|
122
215
|
can: CanFn,
|
|
123
216
|
tx: (i18nKey: string, fallback: string) => string = (_k, fallback) => fallback,
|
|
217
|
+
roleGate: RoleGate | null = null,
|
|
124
218
|
): TableMetadata {
|
|
125
219
|
const allowed = (key: string) => can(modelCapability(model, key))
|
|
126
220
|
|
|
@@ -139,7 +233,7 @@ export function gateTableMetadata(
|
|
|
139
233
|
}) as ActionDefinition,
|
|
140
234
|
)
|
|
141
235
|
: []
|
|
142
|
-
const actions = base.filter((a) => allowed(a.key))
|
|
236
|
+
const actions = base.filter((a) => allowed(a.key) && isActionAllowedForRoles(a, roleGate))
|
|
143
237
|
|
|
144
238
|
return {
|
|
145
239
|
...metadata,
|
|
@@ -176,8 +270,9 @@ export function resolveRowActions(
|
|
|
176
270
|
can: CanFn,
|
|
177
271
|
permissionsActive: boolean,
|
|
178
272
|
tx: (i18nKey: string, fallback: string) => string = (_k, fallback) => fallback,
|
|
273
|
+
roleGate: RoleGate | null = null,
|
|
179
274
|
): ActionDefinition[] {
|
|
180
|
-
const gated = permissionsActive ? gateTableMetadata(metadata, model, can, tx) : metadata
|
|
275
|
+
const gated = permissionsActive ? gateTableMetadata(metadata, model, can, tx, roleGate) : metadata
|
|
181
276
|
const explicit = gated.actions ?? []
|
|
182
277
|
const hasExplicit = (gated.hasActions ?? explicit.length > 0) && explicit.length > 0
|
|
183
278
|
const base: ActionDefinition[] = hasExplicit
|
package/src/types.ts
CHANGED
|
@@ -905,6 +905,14 @@ export interface ActionDefinition {
|
|
|
905
905
|
/** Multi-step wizard form; when present the dispatcher renders a wizard. */
|
|
906
906
|
steps?: ActionStep[]
|
|
907
907
|
requiresState?: string[]
|
|
908
|
+
/**
|
|
909
|
+
* Roles allowed to see/run this action. Empty/absent = unrestricted. Only
|
|
910
|
+
* filters the UI when the host mounts <PermissionsProvider roles={...}>
|
|
911
|
+
* (see `isActionAllowedForRoles`); the backend remains the authority.
|
|
912
|
+
*/
|
|
913
|
+
allowedRoles?: string[]
|
|
914
|
+
/** snake_case wire form of `allowedRoles`, read as a fallback. */
|
|
915
|
+
allowed_roles?: string[]
|
|
908
916
|
/**
|
|
909
917
|
* Manifest `supervisor_policy`: the action needs the on-the-spot
|
|
910
918
|
* authorization of a supervisor for that policy (`general.approve_<policy>`).
|