Rename the plugin-API permission gate to role
This commit is contained in:
@@ -9,13 +9,13 @@ const scheduling: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
id: "scheduling",
|
||||
nav: [{
|
||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }],
|
||||
icon: "i-cal", id: "scheduling", label: "Scheduling",
|
||||
}],
|
||||
};
|
||||
// A plugin with a public nav node (reachable by anyone, signed in or not).
|
||||
const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] };
|
||||
// A gated section fragment like the admin plugin's nav: the header carries the permission, so
|
||||
// A gated section fragment like the admin plugin's nav: the header carries the role, so
|
||||
// composeNav drops the whole subtree for a non-holder (the admin screens ship as a drop-in plugin).
|
||||
const adminLike: Plugin = {
|
||||
apiVersion: "1.0.0", id: "admin",
|
||||
@@ -24,7 +24,7 @@ const adminLike: Plugin = {
|
||||
{ href: "/admin/users", id: "users", label: "Users" },
|
||||
{ href: "/admin/groups", id: "groups", label: "Groups" },
|
||||
],
|
||||
icon: "i-shield", id: "admin", label: "Admin", permission: "admin",
|
||||
icon: "i-shield", id: "admin", label: "Admin", role: "admin",
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ test("anonymous shell Sign-in link carries the current page as return_to", () =>
|
||||
assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
|
||||
});
|
||||
|
||||
test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
|
||||
test("a role holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
|
||||
const chrome = buildPluginChrome({
|
||||
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
|
||||
user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Central menu config: config/menu.ts lets an operator set branding (app name, logo,
|
||||
// default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/
|
||||
// group/hide part is the NavOverride composeNav already applies (the override always wins, before
|
||||
// the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at
|
||||
// the per-user role filter). Authored as TypeScript (defineMenu types it); loaded once at
|
||||
// boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config).
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
+11
-11
@@ -2,23 +2,23 @@ import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { composeNav, type NavNode } from "./nav.ts";
|
||||
|
||||
// Two plugin fragments; ids let the override target nodes, `permission` gates per role.
|
||||
// Two plugin fragments; ids let the override target nodes, `role` gates per role.
|
||||
const fragments: NavNode[][] = [
|
||||
[{
|
||||
icon: "i-cal", id: "sched", label: "Scheduling",
|
||||
children: [
|
||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
|
||||
{ href: "/scheduling/manage", id: "manage", label: "Manage", permission: "scheduling:admin" },
|
||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
|
||||
{ href: "/scheduling/manage", id: "manage", label: "Manage", role: "scheduling:admin" },
|
||||
],
|
||||
}],
|
||||
[{ href: "/reports", id: "reports", label: "Reports", permission: "reports:read" }],
|
||||
[{ href: "/reports", id: "reports", label: "Reports", role: "reports:read" }],
|
||||
];
|
||||
|
||||
test("composeNav merges fragments, filters by role, and emits clean render nodes", () => {
|
||||
const tree = composeNav(fragments, {}, ["scheduling:read"]);
|
||||
|
||||
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
|
||||
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
|
||||
// Output carries no `id`/`role` and omits absent fields — ready for nav-tree.ejs.
|
||||
assert.deepEqual(tree, [
|
||||
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] },
|
||||
]);
|
||||
@@ -27,7 +27,7 @@ test("composeNav merges fragments, filters by role, and emits clean render nodes
|
||||
test("composeNav drops gated subtrees, empty headers, and (with no roles) all gated nodes", () => {
|
||||
// A header the user can't reach takes its whole subtree, even visible children.
|
||||
const gatedHeader: NavNode[][] = [[
|
||||
{ id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
|
||||
{ id: "admin", label: "Admin", role: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
|
||||
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
|
||||
]];
|
||||
assert.deepEqual(composeNav(gatedHeader, {}, []), [
|
||||
@@ -36,8 +36,8 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga
|
||||
|
||||
// A pure header whose children are all filtered is dropped; a header with an href survives as a leaf.
|
||||
const emptyHeader: NavNode[][] = [[
|
||||
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] },
|
||||
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] },
|
||||
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", role: "x" }] },
|
||||
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", role: "y" }] },
|
||||
]];
|
||||
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
|
||||
|
||||
@@ -52,10 +52,10 @@ test("composeNav keeps a node marked public for everyone — the blessed public
|
||||
icon: "i-cal", id: "sched", label: "Scheduling",
|
||||
children: [
|
||||
{ href: "/scheduling", id: "overview", label: "Overview", public: true },
|
||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
|
||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
|
||||
],
|
||||
}]];
|
||||
// `public` is filter-only (like id/permission) — never rendered into the output node.
|
||||
// `public` is filter-only (like id/role) — never rendered into the output node.
|
||||
assert.deepEqual(composeNav(frag, {}, []), [
|
||||
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
|
||||
]);
|
||||
@@ -66,7 +66,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
|
||||
{ href: "/a", id: "a", label: "Alpha" },
|
||||
{ href: "/b", id: "b", label: "Beta" },
|
||||
{ href: "/c", id: "c", label: "Gamma" },
|
||||
{ href: "/secret", id: "secret", label: "Secret", permission: "root" },
|
||||
{ href: "/secret", id: "secret", label: "Secret", role: "root" },
|
||||
]];
|
||||
|
||||
const tree = composeNav(base, {
|
||||
|
||||
+8
-8
@@ -1,10 +1,10 @@
|
||||
// composeNav: merge each plugin's nav fragment into one tree, apply the central
|
||||
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
|
||||
// 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 `permission`, or `roles` includes that permission token; a gated header hides its whole
|
||||
// declares no `role`, or `roles` includes that role 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/permission).
|
||||
// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/role).
|
||||
|
||||
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;
|
||||
permission?: string; // required role 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).
|
||||
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).
|
||||
}
|
||||
|
||||
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
|
||||
// order → hide, then the per-user permission filter runs last.
|
||||
// order → hide, then the per-user role 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)
|
||||
@@ -106,7 +106,7 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
|
||||
function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
|
||||
const out: NavNode[] = [];
|
||||
for (const n of nodes) {
|
||||
if (n.public !== true && n.permission != null && !roles.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
|
||||
if (n.public !== true && n.role != null && !roles.has(n.role)) continue; // gated → drop node + subtree (public always shows)
|
||||
if (!n.children) { out.push(n); continue; }
|
||||
const children = filterByRoles(n.children, roles);
|
||||
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
|
||||
@@ -115,7 +115,7 @@ function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly
|
||||
// Strip the helper-only fields (id/role) 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 };
|
||||
|
||||
Reference in New Issue
Block a user