diff --git a/README.md b/README.md index 44055a5..ceff295 100644 --- a/README.md +++ b/README.md @@ -361,6 +361,7 @@ src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookie src/context.ts RequestContext handed to handlers + buildContext() src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs) +src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model src/plugin.ts definePlugin() + the host's plugin discovery/router (planned) views/ Core EJS templates (index, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) diff --git a/src/nav.test.ts b/src/nav.test.ts new file mode 100644 index 0000000..87fca94 --- /dev/null +++ b/src/nav.test.ts @@ -0,0 +1,69 @@ +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. +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: "/reports", id: "reports", label: "Reports", permission: "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. + assert.deepEqual(tree, [ + { icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] }, + ]); +}); + +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: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] }, + ]]; + assert.deepEqual(composeNav(gatedHeader, {}, []), [ + { label: "Free", children: [{ href: "/d", label: "Docs" }] }, + ]); + + // 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" }] }, + ]]; + assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]); + + // No fragments / no roles → empty tree, never throws. + assert.deepEqual(composeNav(), []); +}); + +test("composeNav applies the override: rename, group, order, hide (then filters)", () => { + const base: NavNode[][] = [[ + { 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" }, + ]]; + + const tree = composeNav(base, { + rename: { a: "First" }, // relabel by id + groups: [{ icon: "i-box", id: "grp", label: "Group", open: true, children: ["b", "c"] }], // wrap b+c + order: ["grp", "a"], // grp before the lone a + hide: ["c"], // remove c from inside the group + }, ["root"]); + + // grp emitted (b only, c hidden), reordered before a; Secret kept now that role "root" is present. + assert.deepEqual(tree, [ + { icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] }, + { href: "/a", label: "First" }, + { href: "/secret", label: "Secret" }, + ]); +}); diff --git a/src/nav.ts b/src/nav.ts new file mode 100644 index 0000000..b73bdee --- /dev/null +++ b/src/nav.ts @@ -0,0 +1,128 @@ +// composeNav (todo §1): 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 +// `roles` claim (README "The menu system"), never Keto. A node is visible iff it declares no +// `permission` or `roles` includes that permission token; a gated header hides its whole +// subtree, and a pure header left with no children is dropped. The §2 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). + +export interface NavNode { + id?: string; // stable key for override targeting; stripped from the rendered tree + children?: NavNode[]; + count?: number; + current?: boolean; + href?: string; + icon?: string; + label: string; + open?: boolean; + permission?: string; // required role token; consumed by the filter, never rendered +} + +// Central override (config/menu.ts, §2). Targets nodes by `id`; applied rename → group → +// 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) + order?: string[]; // reorder top-level nodes by id; unlisted keep their order, after + rename?: Record; // id → replacement label +} + +export interface NavGroupSpec { + id: string; + children: string[]; // ids of top-level nodes to pull under the group, in this order + icon?: string; + label: string; + open?: boolean; +} + +export function composeNav( + fragments: NavNode[][] = [], + override: NavOverride = {}, + roles: 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); +} + +function renameTree(nodes: NavNode[], rename: Record): NavNode[] { + return nodes.map((n) => { + const renamed = n.id != null ? rename[n.id] : undefined; + return { + ...n, + label: renamed != null ? renamed : n.label, + ...(n.children ? { children: renameTree(n.children, rename) } : {}), + }; + }); +} + +// Top-level only: each group becomes a header node placed where its first member sat; +// members are pulled out of the top level into the group, in the group's declared order. +function applyGroups(nodes: NavNode[], groups: NavGroupSpec[]): NavNode[] { + const ofChild = new Map(); + for (const g of groups) for (const id of g.children) ofChild.set(id, g); + const byId = new Map(nodes.filter((n) => n.id != null).map((n) => [n.id as string, n])); + + const build = (g: NavGroupSpec): NavNode => ({ + id: g.id, + label: g.label, + ...(g.icon ? { icon: g.icon } : {}), + ...(g.open ? { open: g.open } : {}), + children: g.children.map((id) => byId.get(id)).filter((n): n is NavNode => n != null), + }); + + const out: NavNode[] = []; + const emitted = new Set(); + for (const n of nodes) { + const g = n.id != null ? ofChild.get(n.id) : undefined; + if (!g) { out.push(n); continue; } + if (!emitted.has(g.id)) { out.push(build(g)); emitted.add(g.id); } + } + return out; +} + +function applyOrder(nodes: NavNode[], order: string[]): NavNode[] { + const rank = new Map(order.map((id, i) => [id, i])); + const rankOf = (n: NavNode): number => (n.id != null && rank.has(n.id) ? (rank.get(n.id) as number) : Infinity); + return nodes + .map((n, i) => ({ i, n })) + .sort((a, b) => rankOf(a.n) - rankOf(b.n) || a.i - b.i) // stable: equal ranks keep input order + .map((x) => x.n); +} + +function hideTree(nodes: NavNode[], hide: Set): NavNode[] { + const out: NavNode[] = []; + for (const n of nodes) { + if (n.id != null && hide.has(n.id)) continue; + out.push(n.children ? { ...n, children: hideTree(n.children, hide) } : n); + } + return out; +} + +function filterByRoles(nodes: NavNode[], roles: Set): NavNode[] { + const out: NavNode[] = []; + for (const n of nodes) { + if (n.permission != null && !roles.has(n.permission)) continue; // gated → drop node + subtree + 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 + out.push({ ...n, children }); + } + return out; +} + +// 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 }; + if (n.icon != null) out.icon = n.icon; + if (n.href != null) out.href = n.href; + if (n.count != null) out.count = n.count; + if (n.current != null) out.current = n.current; + if (n.open != null) out.open = n.open; + if (n.children && n.children.length) out.children = n.children.map(toRenderNode); + return out; +} diff --git a/todo.md b/todo.md index 5826dc9..b186c0e 100644 --- a/todo.md +++ b/todo.md @@ -34,7 +34,7 @@ everything via Docker. - [x] Pagination partial — rows-per-page + page numbers, query-param driven. → `views/partials/pagination.ejs`: data-driven, zero-JS. `summary {from,to,total}`, rows-per-page GET `
` (select + submit, `hidden[]` carries list state), `pages: {label,href?,current?,ellipsis?}[]` (links; current/ellipsis inert), `prev`/`next` (href ⇒ link, omit ⇒ disabled). Reuses the mockup's `.pager` CSS, no changes. `pagination.test.ts` covers the matrix + value reflection + empty defaults. - [x] Form-field partials (input/label/hint/error) + auth-card partial. → `views/partials/field.ejs`: data-driven `.field` — label (+ inline `link`/`Optional`), optional icon input (`has-ico`), `hint`, server-driven `error` (string | {text} | {html}) wiring `aria-invalid` + `aria-describedby`; added one CSS rule `.field.has-error .field-error{display:flex}` so a rendered field shows its own error. `views/partials/auth-card.ejs`: the `` shell — head (back/title/sub), optional `sso` providers (text logo or icon, link or button) + divider, `body` slot (fields + submit), `alt` footer. `field.test.ts`/`auth-card.test.ts` cover the matrix + escaping + defaults. - [x] Menu/popover + theme-switch partials (pure CSS `details`/`summary`). → `views/partials/menu.ejs`: data-driven `
` popover — `trigger` (icon/text/raw-html, `class:""` ⇒ bare kebab), `align`/`up` positioning, `width`; `items` = head · sep · link/button (icon, danger) · check-`group` (the columns/“more filters” menus filter-bar deferred here). `views/partials/theme-switch.ejs`: Light/Auto/Dark radiogroup with the fixed `theme-light/auto/dark` ids `styles.css` keys its `:has()` swaps off. Added `.menu-pop.up` (replaces the mockup's inline up-positioning); `shell.ejs` now reuses both partials. `menu.test.ts`/`theme-switch.test.ts` cover the matrix + escaping + defaults. -- [ ] Helper `composeNav(fragments, override, roles)` → merged, permission-filtered tree. +- [x] Helper `composeNav(fragments, override, roles)` → merged, permission-filtered tree. → `src/nav.ts`: pure, I/O-free. Flattens plugin fragments, applies the central override (rename → group → order → hide, all keyed by node `id`), then role-filters — a node shows iff it has no `permission` or `roles` includes it; a gated header drops its whole subtree, an emptied pure header is dropped. Emits clean nodes (no `id`/`permission`, absent fields omitted) ready for `nav-tree.ejs`. Filter runs last so everything above is per-deployment. `NavNode`/`NavOverride`/`NavGroupSpec` types exported; `nav.test.ts` covers merge/filter/empties/override matrix. - [ ] Helper `parseListQuery(url)` → `{ q, filters, sort, page, pageSize }`. - [ ] Helper `paginate(total, page, pageSize)` → page model. - [ ] Replace placeholder `index` with the app-shell dashboard.