Rename the coarse gate from role to permission, matching RBAC

This commit is contained in:
2026-08-03 17:02:47 +02:00
parent 04fe5b1e06
commit 096720904e
79 changed files with 744 additions and 738 deletions
+13 -13
View File
@@ -1,10 +1,10 @@
// composeNav: merge each plugin's nav fragment into one tree, apply the central
// override, then role-filter per user. Pure and I/O-free — menu gating reads the JWT
// `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
// declares no `role`, or `roles` includes that role name; a gated header hides its whole
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
// `permissions` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
// declares no `permission`, or `permissions` includes that permission name; a gated header hides its whole
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
// the override (+ branding); this helper only transforms data, so its result is per-deployment
// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/role).
// up to the final permission filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
export interface NavNode {
id?: string; // stable key for override targeting; stripped from the rendered tree
@@ -15,12 +15,12 @@ export interface NavNode {
icon?: string;
label: string;
open?: boolean;
role?: string; // required role token; consumed by the filter, never rendered
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no role", stated outright; consumed by the filter, never rendered. Mutually exclusive with role (discovery refuses both).
permission?: string; // required permission token; consumed by the filter, never rendered
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both).
}
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
// order → hide, then the per-user role filter runs last.
// order → hide, then the per-user permission filter runs last.
export interface NavOverride {
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id)
@@ -39,14 +39,14 @@ export interface NavGroupSpec {
export function composeNav(
fragments: NavNode[][] = [],
override: NavOverride = {},
roles: string[] = [],
permissions: string[] = [],
): NavNode[] {
let nodes: NavNode[] = fragments.flat();
if (override.rename) nodes = renameTree(nodes, override.rename);
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
if (override.order?.length) nodes = applyOrder(nodes, override.order);
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
return filterByRoles(nodes, new Set(roles)).map(toRenderNode);
return filterByRoles(nodes, new Set(permissions)).map(toRenderNode);
}
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
@@ -103,19 +103,19 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
return out;
}
function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
function filterByRoles(nodes: NavNode[], permissions: Set<string>): NavNode[] {
const out: NavNode[] = [];
for (const n of nodes) {
if (n.public !== true && n.role != null && !roles.has(n.role)) continue; // gated → drop node + subtree (public always shows)
if (n.public !== true && n.permission != null && !permissions.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
if (!n.children) { out.push(n); continue; }
const children = filterByRoles(n.children, roles);
const children = filterByRoles(n.children, permissions);
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
out.push({ ...n, children });
}
return out;
}
// Strip the helper-only fields (id/role) and drop absent ones, so the tree is exactly
// Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly
// what nav-tree.ejs reads.
function toRenderNode(n: NavNode): NavNode {
const out: NavNode = { label: n.label };