snipe-auth-rbac 0.3.0 → 0.4.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.
@@ -1,4 +1,4 @@
1
- import { R as ResourceScope, F as FrontendConfig, a as ResourceDescriptor, A as Action } from '../types-DxvFudPF.cjs';
1
+ import { A as Action, R as ResourceScope, F as FrontendConfig, a as ResourceDescriptor } from '../types-Oj9yfWvz.cjs';
2
2
  import * as react_jsx_runtime from 'react/jsx-runtime';
3
3
 
4
4
  /**
@@ -27,6 +27,32 @@ interface AdminRolePermission {
27
27
  can_write: boolean;
28
28
  can_update: boolean;
29
29
  can_delete: boolean;
30
+ /**
31
+ * `<action>_granted_via` columns from `rbac.role_permissions`.
32
+ * `null` = direct admin grant. Non-null = name of the parent
33
+ * resource whose `dependsOn` edge implied this cell. Used to
34
+ * render the "Implied by …" badge in the matrix UI.
35
+ *
36
+ * Available since 0.4.0. Pre-0.4.0 SQL omits these columns; the
37
+ * transport tolerates absent fields by treating them as null.
38
+ */
39
+ read_granted_via?: string | null;
40
+ write_granted_via?: string | null;
41
+ update_granted_via?: string | null;
42
+ delete_granted_via?: string | null;
43
+ }
44
+ /**
45
+ * One materialised cascade edge. Returned by
46
+ * `AdminTransport.listResourceDependencies()` and consumed by the
47
+ * matrix UI when an admin toggles a cell on — used to decide which
48
+ * implied rows to write alongside.
49
+ *
50
+ * Available since 0.4.0.
51
+ */
52
+ interface AdminResourceDependency {
53
+ parent_resource: string;
54
+ child_resource: string;
55
+ action: Action;
30
56
  }
31
57
  interface AdminCompany {
32
58
  id: string;
@@ -81,7 +107,43 @@ interface AdminTransport {
81
107
  resource: string;
82
108
  action: Action;
83
109
  value: boolean;
110
+ /**
111
+ * 0.4.0+. When this write is the result of a dependsOn cascade,
112
+ * pass the parent resource name so the row's
113
+ * `<action>_granted_via` column is recorded. Direct admin
114
+ * clicks should pass `null` (or omit) to set the column to NULL
115
+ * — which is how `canAccessSection` distinguishes direct grants
116
+ * from implied ones.
117
+ */
118
+ grantedVia?: string | null;
84
119
  }): Promise<void>;
120
+ /**
121
+ * 0.4.0+. Batch variant for the dependsOn cascade — apply the
122
+ * parent's toggle AND every implied child in a single round-trip.
123
+ * Default implementation falls back to calling
124
+ * `setRolePermissionCell` once per write; transports that can
125
+ * batch (e.g. via a single `UPSERT … VALUES (…), (…), (…)`) should
126
+ * override.
127
+ */
128
+ batchSetRolePermissionCells(writes: ReadonlyArray<{
129
+ role_id: string;
130
+ resource: string;
131
+ action: Action;
132
+ value: boolean;
133
+ grantedVia?: string | null;
134
+ }>): Promise<void>;
135
+ /**
136
+ * 0.4.0+. Push the `dependsOn` edges from the host's registry
137
+ * into `rbac.resource_dependencies`. Normally chained off
138
+ * `syncResources()` on app boot.
139
+ */
140
+ syncResourceDependencies(edges: ReadonlyArray<AdminResourceDependency>): Promise<number>;
141
+ /**
142
+ * 0.4.0+. Read the materialised dependency graph back. The matrix
143
+ * UI hook calls this once at mount to know which children to
144
+ * cascade on a parent toggle.
145
+ */
146
+ listResourceDependencies(): Promise<AdminResourceDependency[]>;
85
147
  /**
86
148
  * Materialise `rbac.role_permissions` rows from a template role's
87
149
  * `default_permissions` JSONB pattern. Calls the SQL function
@@ -187,6 +249,13 @@ interface SupabaseAdminClientOptions {
187
249
  /** Where the invitee should land after setting their password. */
188
250
  inviteRedirectUrl?: string;
189
251
  }
252
+ /**
253
+ * Pull `dependsOn` edges out of a registry array and flatten them
254
+ * into one row per (parent, child, action). Shared helper used by
255
+ * `syncResources` and by adopters who want to sync dependencies
256
+ * manually.
257
+ */
258
+ declare function extractResourceDependencies(resources: ReadonlyArray<ResourceDescriptor>): AdminResourceDependency[];
190
259
  declare function createSupabaseAdminClient(opts: SupabaseAdminClientOptions): AdminTransport;
191
260
 
192
261
  interface AdminTransportProviderProps {
@@ -251,6 +320,7 @@ declare function useSetRolePermissionCell(): {
251
320
  resource: string;
252
321
  action: Action;
253
322
  value: boolean;
323
+ grantedVia?: string | null;
254
324
  }) => Promise<void>;
255
325
  };
256
326
  declare function useApplyTemplateDefaults(): {
@@ -261,6 +331,17 @@ declare function useApplyTemplateDefaults(): {
261
331
  only_missing?: boolean;
262
332
  }) => Promise<number>;
263
333
  };
334
+ /**
335
+ * 0.4.0+. Materialised dependency edges. Loaded once per admin
336
+ * session — the underlying table mutates only on app boot (via
337
+ * `syncResources` → `syncResourceDependencies`).
338
+ */
339
+ declare function useAdminResourceDependencies(): {
340
+ refresh: () => Promise<void>;
341
+ data: AdminResourceDependency[] | null;
342
+ isLoading: boolean;
343
+ error: Error | null;
344
+ };
264
345
  declare function useCreateCompany(): {
265
346
  isPending: boolean;
266
347
  error: Error | null;
@@ -286,8 +367,20 @@ interface RolePermissionGrid {
286
367
  [A in Action]: boolean;
287
368
  };
288
369
  }
370
+ /**
371
+ * 0.4.0+. Per-cell origin tracking — `null` means a direct admin
372
+ * grant, a string is the name of the parent resource whose
373
+ * `dependsOn` edge implied the row. Used by the matrix UI to render
374
+ * the "Implied by …" badge.
375
+ */
376
+ interface RolePermissionOriginGrid {
377
+ [resource: string]: {
378
+ [A in Action]: string | null;
379
+ };
380
+ }
289
381
  declare function useRolePermissionGrid(roleId: string | null): {
290
382
  grid: RolePermissionGrid;
383
+ originGrid: RolePermissionOriginGrid;
291
384
  isLoading: boolean;
292
385
  error: Error | null;
293
386
  refresh: () => Promise<void>;
@@ -305,7 +398,23 @@ interface MatrixRenderArgs {
305
398
  groups: MatrixGroup[];
306
399
  /** Read a single cell from the current grid. */
307
400
  isCellEnabled: (resource: string, action: Action) => boolean;
308
- /** Write a single cell. Optimistic in the local cache + writes through. */
401
+ /**
402
+ * Origin of a single cell — `'direct'` for a direct admin grant
403
+ * (or off), or the name of the parent resource whose `dependsOn`
404
+ * edge implied the row. The consumer renders an "Implied by …"
405
+ * badge whenever this returns a non-`'direct'` value.
406
+ *
407
+ * Available since 0.4.0. With pre-0.4.0 SQL (no granted_via
408
+ * columns) this always returns `'direct'`.
409
+ */
410
+ cellOrigin: (resource: string, action: Action) => "direct" | string;
411
+ /**
412
+ * Write a single cell. Optimistic in the local cache + writes
413
+ * through. On toggle-on, also writes implied rows for every
414
+ * `dependsOn` edge whose `actions` include the toggled action —
415
+ * those rows carry the parent's name in
416
+ * `<action>_granted_via`. Toggle-off never cascades.
417
+ */
309
418
  setCell: (resource: string, action: Action, value: boolean) => Promise<void>;
310
419
  isLoading: boolean;
311
420
  isUpdating: boolean;
@@ -374,4 +483,4 @@ interface InviteMemberFormProps {
374
483
  }
375
484
  declare function InviteMemberForm(props: InviteMemberFormProps): react_jsx_runtime.JSX.Element;
376
485
 
377
- export { type AdminCompany, type AdminMember, type AdminRole, type AdminRolePermission, type AdminTransport, AdminTransportProvider, type AdminTransportProviderProps, InviteMemberForm, type InviteMemberFormProps, type InviteMemberFormRenderArgs, type MatrixGroup, type MatrixRenderArgs, PermissionsMatrix, type PermissionsMatrixProps, type RolePermissionGrid, RolesList, type RolesListProps, type RolesListRenderArgs, type SupabaseAdminClientOptions, createSupabaseAdminClient, useAdminCompanies, useAdminCompanyMembers, useAdminRolePermissions, useAdminRoles, useApplyTemplateDefaults, useCreateCompany, useCreateRole, useDeleteRole, useInviteCompanyMember, useRolePermissionGrid, useSetRolePermissionCell, useUpdateRole };
486
+ export { type AdminCompany, type AdminMember, type AdminResourceDependency, type AdminRole, type AdminRolePermission, type AdminTransport, AdminTransportProvider, type AdminTransportProviderProps, InviteMemberForm, type InviteMemberFormProps, type InviteMemberFormRenderArgs, type MatrixGroup, type MatrixRenderArgs, PermissionsMatrix, type PermissionsMatrixProps, type RolePermissionGrid, type RolePermissionOriginGrid, RolesList, type RolesListProps, type RolesListRenderArgs, type SupabaseAdminClientOptions, createSupabaseAdminClient, extractResourceDependencies, useAdminCompanies, useAdminCompanyMembers, useAdminResourceDependencies, useAdminRolePermissions, useAdminRoles, useApplyTemplateDefaults, useCreateCompany, useCreateRole, useDeleteRole, useInviteCompanyMember, useRolePermissionGrid, useSetRolePermissionCell, useUpdateRole };
@@ -1,4 +1,4 @@
1
- import { R as ResourceScope, F as FrontendConfig, a as ResourceDescriptor, A as Action } from '../types-DxvFudPF.js';
1
+ import { A as Action, R as ResourceScope, F as FrontendConfig, a as ResourceDescriptor } from '../types-Oj9yfWvz.js';
2
2
  import * as react_jsx_runtime from 'react/jsx-runtime';
3
3
 
4
4
  /**
@@ -27,6 +27,32 @@ interface AdminRolePermission {
27
27
  can_write: boolean;
28
28
  can_update: boolean;
29
29
  can_delete: boolean;
30
+ /**
31
+ * `<action>_granted_via` columns from `rbac.role_permissions`.
32
+ * `null` = direct admin grant. Non-null = name of the parent
33
+ * resource whose `dependsOn` edge implied this cell. Used to
34
+ * render the "Implied by …" badge in the matrix UI.
35
+ *
36
+ * Available since 0.4.0. Pre-0.4.0 SQL omits these columns; the
37
+ * transport tolerates absent fields by treating them as null.
38
+ */
39
+ read_granted_via?: string | null;
40
+ write_granted_via?: string | null;
41
+ update_granted_via?: string | null;
42
+ delete_granted_via?: string | null;
43
+ }
44
+ /**
45
+ * One materialised cascade edge. Returned by
46
+ * `AdminTransport.listResourceDependencies()` and consumed by the
47
+ * matrix UI when an admin toggles a cell on — used to decide which
48
+ * implied rows to write alongside.
49
+ *
50
+ * Available since 0.4.0.
51
+ */
52
+ interface AdminResourceDependency {
53
+ parent_resource: string;
54
+ child_resource: string;
55
+ action: Action;
30
56
  }
31
57
  interface AdminCompany {
32
58
  id: string;
@@ -81,7 +107,43 @@ interface AdminTransport {
81
107
  resource: string;
82
108
  action: Action;
83
109
  value: boolean;
110
+ /**
111
+ * 0.4.0+. When this write is the result of a dependsOn cascade,
112
+ * pass the parent resource name so the row's
113
+ * `<action>_granted_via` column is recorded. Direct admin
114
+ * clicks should pass `null` (or omit) to set the column to NULL
115
+ * — which is how `canAccessSection` distinguishes direct grants
116
+ * from implied ones.
117
+ */
118
+ grantedVia?: string | null;
84
119
  }): Promise<void>;
120
+ /**
121
+ * 0.4.0+. Batch variant for the dependsOn cascade — apply the
122
+ * parent's toggle AND every implied child in a single round-trip.
123
+ * Default implementation falls back to calling
124
+ * `setRolePermissionCell` once per write; transports that can
125
+ * batch (e.g. via a single `UPSERT … VALUES (…), (…), (…)`) should
126
+ * override.
127
+ */
128
+ batchSetRolePermissionCells(writes: ReadonlyArray<{
129
+ role_id: string;
130
+ resource: string;
131
+ action: Action;
132
+ value: boolean;
133
+ grantedVia?: string | null;
134
+ }>): Promise<void>;
135
+ /**
136
+ * 0.4.0+. Push the `dependsOn` edges from the host's registry
137
+ * into `rbac.resource_dependencies`. Normally chained off
138
+ * `syncResources()` on app boot.
139
+ */
140
+ syncResourceDependencies(edges: ReadonlyArray<AdminResourceDependency>): Promise<number>;
141
+ /**
142
+ * 0.4.0+. Read the materialised dependency graph back. The matrix
143
+ * UI hook calls this once at mount to know which children to
144
+ * cascade on a parent toggle.
145
+ */
146
+ listResourceDependencies(): Promise<AdminResourceDependency[]>;
85
147
  /**
86
148
  * Materialise `rbac.role_permissions` rows from a template role's
87
149
  * `default_permissions` JSONB pattern. Calls the SQL function
@@ -187,6 +249,13 @@ interface SupabaseAdminClientOptions {
187
249
  /** Where the invitee should land after setting their password. */
188
250
  inviteRedirectUrl?: string;
189
251
  }
252
+ /**
253
+ * Pull `dependsOn` edges out of a registry array and flatten them
254
+ * into one row per (parent, child, action). Shared helper used by
255
+ * `syncResources` and by adopters who want to sync dependencies
256
+ * manually.
257
+ */
258
+ declare function extractResourceDependencies(resources: ReadonlyArray<ResourceDescriptor>): AdminResourceDependency[];
190
259
  declare function createSupabaseAdminClient(opts: SupabaseAdminClientOptions): AdminTransport;
191
260
 
192
261
  interface AdminTransportProviderProps {
@@ -251,6 +320,7 @@ declare function useSetRolePermissionCell(): {
251
320
  resource: string;
252
321
  action: Action;
253
322
  value: boolean;
323
+ grantedVia?: string | null;
254
324
  }) => Promise<void>;
255
325
  };
256
326
  declare function useApplyTemplateDefaults(): {
@@ -261,6 +331,17 @@ declare function useApplyTemplateDefaults(): {
261
331
  only_missing?: boolean;
262
332
  }) => Promise<number>;
263
333
  };
334
+ /**
335
+ * 0.4.0+. Materialised dependency edges. Loaded once per admin
336
+ * session — the underlying table mutates only on app boot (via
337
+ * `syncResources` → `syncResourceDependencies`).
338
+ */
339
+ declare function useAdminResourceDependencies(): {
340
+ refresh: () => Promise<void>;
341
+ data: AdminResourceDependency[] | null;
342
+ isLoading: boolean;
343
+ error: Error | null;
344
+ };
264
345
  declare function useCreateCompany(): {
265
346
  isPending: boolean;
266
347
  error: Error | null;
@@ -286,8 +367,20 @@ interface RolePermissionGrid {
286
367
  [A in Action]: boolean;
287
368
  };
288
369
  }
370
+ /**
371
+ * 0.4.0+. Per-cell origin tracking — `null` means a direct admin
372
+ * grant, a string is the name of the parent resource whose
373
+ * `dependsOn` edge implied the row. Used by the matrix UI to render
374
+ * the "Implied by …" badge.
375
+ */
376
+ interface RolePermissionOriginGrid {
377
+ [resource: string]: {
378
+ [A in Action]: string | null;
379
+ };
380
+ }
289
381
  declare function useRolePermissionGrid(roleId: string | null): {
290
382
  grid: RolePermissionGrid;
383
+ originGrid: RolePermissionOriginGrid;
291
384
  isLoading: boolean;
292
385
  error: Error | null;
293
386
  refresh: () => Promise<void>;
@@ -305,7 +398,23 @@ interface MatrixRenderArgs {
305
398
  groups: MatrixGroup[];
306
399
  /** Read a single cell from the current grid. */
307
400
  isCellEnabled: (resource: string, action: Action) => boolean;
308
- /** Write a single cell. Optimistic in the local cache + writes through. */
401
+ /**
402
+ * Origin of a single cell — `'direct'` for a direct admin grant
403
+ * (or off), or the name of the parent resource whose `dependsOn`
404
+ * edge implied the row. The consumer renders an "Implied by …"
405
+ * badge whenever this returns a non-`'direct'` value.
406
+ *
407
+ * Available since 0.4.0. With pre-0.4.0 SQL (no granted_via
408
+ * columns) this always returns `'direct'`.
409
+ */
410
+ cellOrigin: (resource: string, action: Action) => "direct" | string;
411
+ /**
412
+ * Write a single cell. Optimistic in the local cache + writes
413
+ * through. On toggle-on, also writes implied rows for every
414
+ * `dependsOn` edge whose `actions` include the toggled action —
415
+ * those rows carry the parent's name in
416
+ * `<action>_granted_via`. Toggle-off never cascades.
417
+ */
309
418
  setCell: (resource: string, action: Action, value: boolean) => Promise<void>;
310
419
  isLoading: boolean;
311
420
  isUpdating: boolean;
@@ -374,4 +483,4 @@ interface InviteMemberFormProps {
374
483
  }
375
484
  declare function InviteMemberForm(props: InviteMemberFormProps): react_jsx_runtime.JSX.Element;
376
485
 
377
- export { type AdminCompany, type AdminMember, type AdminRole, type AdminRolePermission, type AdminTransport, AdminTransportProvider, type AdminTransportProviderProps, InviteMemberForm, type InviteMemberFormProps, type InviteMemberFormRenderArgs, type MatrixGroup, type MatrixRenderArgs, PermissionsMatrix, type PermissionsMatrixProps, type RolePermissionGrid, RolesList, type RolesListProps, type RolesListRenderArgs, type SupabaseAdminClientOptions, createSupabaseAdminClient, useAdminCompanies, useAdminCompanyMembers, useAdminRolePermissions, useAdminRoles, useApplyTemplateDefaults, useCreateCompany, useCreateRole, useDeleteRole, useInviteCompanyMember, useRolePermissionGrid, useSetRolePermissionCell, useUpdateRole };
486
+ export { type AdminCompany, type AdminMember, type AdminResourceDependency, type AdminRole, type AdminRolePermission, type AdminTransport, AdminTransportProvider, type AdminTransportProviderProps, InviteMemberForm, type InviteMemberFormProps, type InviteMemberFormRenderArgs, type MatrixGroup, type MatrixRenderArgs, PermissionsMatrix, type PermissionsMatrixProps, type RolePermissionGrid, type RolePermissionOriginGrid, RolesList, type RolesListProps, type RolesListRenderArgs, type SupabaseAdminClientOptions, createSupabaseAdminClient, extractResourceDependencies, useAdminCompanies, useAdminCompanyMembers, useAdminResourceDependencies, useAdminRolePermissions, useAdminRoles, useApplyTemplateDefaults, useCreateCompany, useCreateRole, useDeleteRole, useInviteCompanyMember, useRolePermissionGrid, useSetRolePermissionCell, useUpdateRole };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  groupResources
3
- } from "../chunk-C76JHCKM.js";
3
+ } from "../chunk-XHPBUCFN.js";
4
4
 
5
5
  // src/admin/transport.ts
6
6
  var ACTION_COLUMN = {
@@ -9,9 +9,46 @@ var ACTION_COLUMN = {
9
9
  update: "can_update",
10
10
  delete: "can_delete"
11
11
  };
12
+ var GRANTED_VIA_COLUMN = {
13
+ read: "read_granted_via",
14
+ write: "write_granted_via",
15
+ update: "update_granted_via",
16
+ delete: "delete_granted_via"
17
+ };
18
+ function extractResourceDependencies(resources) {
19
+ const out = [];
20
+ for (const r of resources) {
21
+ for (const edge of r.dependsOn ?? []) {
22
+ const child = typeof edge === "string" ? edge : edge.resource;
23
+ const actions = typeof edge === "string" ? ["read"] : edge.actions ?? ["read"];
24
+ for (const action of actions) {
25
+ out.push({
26
+ parent_resource: r.resource,
27
+ child_resource: child,
28
+ action
29
+ });
30
+ }
31
+ }
32
+ }
33
+ return out;
34
+ }
12
35
  function createSupabaseAdminClient(opts) {
13
36
  const sb = opts.supabase;
14
37
  const rbac = sb.schema("rbac");
38
+ const syncResourceDependencies = async (edges) => {
39
+ const payload = edges.map((e) => ({
40
+ parent_resource: e.parent_resource,
41
+ child_resource: e.child_resource,
42
+ action: e.action
43
+ }));
44
+ const { error } = await rbac.rpc("replace_resource_dependencies", {
45
+ p_edges: payload
46
+ });
47
+ if (error) {
48
+ throw new Error(`syncResourceDependencies: ${error.message}`);
49
+ }
50
+ return edges.length;
51
+ };
15
52
  return {
16
53
  async syncResources(resources) {
17
54
  if (resources.length === 0) {
@@ -28,6 +65,15 @@ function createSupabaseAdminClient(opts) {
28
65
  if (error) {
29
66
  throw new Error(`syncResources: ${error.message}`);
30
67
  }
68
+ const edges = extractResourceDependencies(resources);
69
+ try {
70
+ await syncResourceDependencies(edges);
71
+ } catch (err) {
72
+ if (err instanceof Error && /resource_dependencies/i.test(err.message) && /(does not exist|relation .* does not exist)/i.test(err.message)) {
73
+ } else {
74
+ throw err;
75
+ }
76
+ }
31
77
  return resources.length;
32
78
  },
33
79
  async listRoles({ scope, companyId, templatesOnly }) {
@@ -77,18 +123,68 @@ function createSupabaseAdminClient(opts) {
77
123
  throw new Error(`deleteRole: ${error.message}`);
78
124
  }
79
125
  },
80
- async setRolePermissionCell({ role_id, resource, action, value }) {
81
- const column = ACTION_COLUMN[action];
126
+ async setRolePermissionCell({ role_id, resource, action, value, grantedVia }) {
127
+ const actionCol = ACTION_COLUMN[action];
128
+ const originCol = GRANTED_VIA_COLUMN[action];
82
129
  const row = {
83
130
  role_id,
84
131
  resource,
85
- [column]: value
132
+ [actionCol]: value
86
133
  };
134
+ if (grantedVia !== void 0) {
135
+ row[originCol] = value ? grantedVia : null;
136
+ }
87
137
  const { error } = await rbac.from("role_permissions").upsert(row, { onConflict: "role_id,resource" });
88
138
  if (error) {
139
+ if (grantedVia !== void 0 && /column .*granted_via.* does not exist/i.test(error.message)) {
140
+ const fallbackRow = {
141
+ role_id,
142
+ resource,
143
+ [actionCol]: value
144
+ };
145
+ const { error: retryErr } = await rbac.from("role_permissions").upsert(fallbackRow, { onConflict: "role_id,resource" });
146
+ if (retryErr) {
147
+ throw new Error(`setRolePermissionCell: ${retryErr.message}`);
148
+ }
149
+ return;
150
+ }
89
151
  throw new Error(`setRolePermissionCell: ${error.message}`);
90
152
  }
91
153
  },
154
+ async batchSetRolePermissionCells(writes) {
155
+ if (writes.length === 0) {
156
+ return;
157
+ }
158
+ const byKey = /* @__PURE__ */ new Map();
159
+ for (const w of writes) {
160
+ const key = `${w.role_id}::${w.resource}`;
161
+ const existing = byKey.get(key) ?? {
162
+ role_id: w.role_id,
163
+ resource: w.resource
164
+ };
165
+ existing[ACTION_COLUMN[w.action]] = w.value;
166
+ if (w.grantedVia !== void 0) {
167
+ existing[GRANTED_VIA_COLUMN[w.action]] = w.value ? w.grantedVia : null;
168
+ }
169
+ byKey.set(key, existing);
170
+ }
171
+ const payload = Array.from(byKey.values());
172
+ const { error } = await rbac.from("role_permissions").upsert(payload, { onConflict: "role_id,resource" });
173
+ if (error) {
174
+ throw new Error(`batchSetRolePermissionCells: ${error.message}`);
175
+ }
176
+ },
177
+ syncResourceDependencies,
178
+ async listResourceDependencies() {
179
+ const { data, error } = await rbac.from("resource_dependencies").select("parent_resource, child_resource, action").order("parent_resource", { ascending: true });
180
+ if (error) {
181
+ if (/resource_dependencies/i.test(error.message) && /does not exist/i.test(error.message)) {
182
+ return [];
183
+ }
184
+ throw new Error(`listResourceDependencies: ${error.message}`);
185
+ }
186
+ return data ?? [];
187
+ },
92
188
  async applyTemplateDefaults({ role_id, only_missing = true }) {
93
189
  const { data, error } = await rbac.rpc("apply_template_defaults", {
94
190
  p_role_id: role_id,
@@ -265,6 +361,13 @@ function useApplyTemplateDefaults() {
265
361
  const transport = useAdminTransport();
266
362
  return useMutation(transport.applyTemplateDefaults);
267
363
  }
364
+ function useAdminResourceDependencies() {
365
+ const transport = useAdminTransport();
366
+ return useAsync(
367
+ () => transport.listResourceDependencies(),
368
+ [transport]
369
+ );
370
+ }
268
371
  function useCreateCompany() {
269
372
  const transport = useAdminTransport();
270
373
  return useMutation(transport.createCompany);
@@ -275,7 +378,11 @@ function useInviteCompanyMember() {
275
378
  }
276
379
  function useRolePermissionGrid(roleId) {
277
380
  const { data, isLoading, error, refresh } = useAdminRolePermissions(roleId);
381
+ const dependencies = useAdminResourceDependencies();
278
382
  const setCell = useSetRolePermissionCell();
383
+ const transport = useAdminTransport();
384
+ const [isCascading, setCascading] = useState(false);
385
+ const [cascadeError, setCascadeError] = useState(null);
279
386
  const grid = useMemo(() => {
280
387
  const out = {};
281
388
  for (const row of data ?? []) {
@@ -288,33 +395,107 @@ function useRolePermissionGrid(roleId) {
288
395
  }
289
396
  return out;
290
397
  }, [data]);
398
+ const originGrid = useMemo(() => {
399
+ const out = {};
400
+ for (const row of data ?? []) {
401
+ out[row.resource] = {
402
+ read: row.read_granted_via ?? null,
403
+ write: row.write_granted_via ?? null,
404
+ update: row.update_granted_via ?? null,
405
+ delete: row.delete_granted_via ?? null
406
+ };
407
+ }
408
+ return out;
409
+ }, [data]);
410
+ const edgesByParent = useMemo(() => {
411
+ const map = /* @__PURE__ */ new Map();
412
+ for (const edge of dependencies.data ?? []) {
413
+ const list = map.get(edge.parent_resource) ?? [];
414
+ map.set(edge.parent_resource, [
415
+ ...list,
416
+ { child: edge.child_resource, action: edge.action }
417
+ ]);
418
+ }
419
+ return map;
420
+ }, [dependencies.data]);
291
421
  const updateCell = useCallback(
292
422
  async (resource, action, value) => {
293
423
  if (!roleId) {
294
424
  return;
295
425
  }
296
- await setCell.mutate({ role_id: roleId, resource, action, value });
297
- void refresh();
426
+ const writes = [
427
+ { role_id: roleId, resource, action, value, grantedVia: null }
428
+ ];
429
+ if (value) {
430
+ const edges = edgesByParent.get(resource) ?? [];
431
+ for (const edge of edges) {
432
+ if (edge.action !== action) {
433
+ continue;
434
+ }
435
+ const childRow = (data ?? []).find((r) => r.resource === edge.child);
436
+ const childValue = childRow?.[ACTION_FIELD[action]] === true;
437
+ const childOrigin = childRow?.[ORIGIN_FIELD[action]] ?? null;
438
+ if (childValue && childOrigin == null) {
439
+ continue;
440
+ }
441
+ writes.push({
442
+ role_id: roleId,
443
+ resource: edge.child,
444
+ action,
445
+ value: true,
446
+ grantedVia: resource
447
+ });
448
+ }
449
+ }
450
+ setCascading(true);
451
+ setCascadeError(null);
452
+ try {
453
+ const [first, ...rest] = writes;
454
+ if (first && rest.length === 0) {
455
+ await setCell.mutate(first);
456
+ } else {
457
+ await transport.batchSetRolePermissionCells(writes);
458
+ }
459
+ void refresh();
460
+ } catch (e) {
461
+ setCascadeError(e instanceof Error ? e : new Error(String(e)));
462
+ throw e;
463
+ } finally {
464
+ setCascading(false);
465
+ }
298
466
  },
299
- [roleId, setCell, refresh]
467
+ [roleId, setCell, refresh, edgesByParent, data, transport]
300
468
  );
301
469
  return {
302
470
  grid,
303
- isLoading,
304
- error,
471
+ originGrid,
472
+ isLoading: isLoading || dependencies.isLoading,
473
+ error: error ?? dependencies.error,
305
474
  refresh,
306
475
  updateCell,
307
- isUpdating: setCell.isPending,
308
- updateError: setCell.error
476
+ isUpdating: setCell.isPending || isCascading,
477
+ updateError: setCell.error ?? cascadeError
309
478
  };
310
479
  }
480
+ var ACTION_FIELD = {
481
+ read: "can_read",
482
+ write: "can_write",
483
+ update: "can_update",
484
+ delete: "can_delete"
485
+ };
486
+ var ORIGIN_FIELD = {
487
+ read: "read_granted_via",
488
+ write: "write_granted_via",
489
+ update: "update_granted_via",
490
+ delete: "delete_granted_via"
491
+ };
311
492
 
312
493
  // src/admin/PermissionsMatrix.tsx
313
494
  import { useMemo as useMemo2 } from "react";
314
495
  import { Fragment, jsx as jsx2 } from "react/jsx-runtime";
315
496
  var ACTIONS = ["read", "write", "update", "delete"];
316
497
  function PermissionsMatrix(props) {
317
- const { grid, isLoading, error, updateCell, isUpdating } = useRolePermissionGrid(props.roleId);
498
+ const { grid, originGrid, isLoading, error, updateCell, isUpdating } = useRolePermissionGrid(props.roleId);
318
499
  const groups = useMemo2(
319
500
  () => groupResources(props.resources),
320
501
  [props.resources]
@@ -322,12 +503,17 @@ function PermissionsMatrix(props) {
322
503
  const isCellEnabled = (resource, action) => {
323
504
  return grid[resource]?.[action] ?? false;
324
505
  };
506
+ const cellOrigin = (resource, action) => {
507
+ const origin = originGrid[resource]?.[action];
508
+ return origin == null ? "direct" : origin;
509
+ };
325
510
  const setCell = async (resource, action, value) => {
326
511
  await updateCell(resource, action, value);
327
512
  };
328
513
  return /* @__PURE__ */ jsx2(Fragment, { children: props.children({
329
514
  groups,
330
515
  isCellEnabled,
516
+ cellOrigin,
331
517
  setCell,
332
518
  isLoading,
333
519
  isUpdating,
@@ -461,8 +647,10 @@ export {
461
647
  PermissionsMatrix,
462
648
  RolesList,
463
649
  createSupabaseAdminClient,
650
+ extractResourceDependencies,
464
651
  useAdminCompanies,
465
652
  useAdminCompanyMembers,
653
+ useAdminResourceDependencies,
466
654
  useAdminRolePermissions,
467
655
  useAdminRoles,
468
656
  useApplyTemplateDefaults,