@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/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({ permissions, isAdmin, children }: PermissionsProviderProps) {
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
- return <PermissionsContext.Provider value={can}>{children}</PermissionsContext.Provider>
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>`).