@asteby/metacore-runtime-react 49.9.1 → 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 +14 -0
- 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__/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/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/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>`).
|