§10 - one menu everywhere (buildPluginChrome) + shell on every page; instructional starter dashboard; Kratos-native email docs

Collapse the three nav builders into buildPluginChrome: chrome.bestHref does longest-prefix matching so deep admin routes mark their leaf. Delete adminNav; buildConfirmModel and the admin model builders take the resolved nav. The same role-filtered sidebar now renders signed in or out, collapsing to a burger on narrow screens.

shell.ejs gains menu (default true; menu:false -> single-column .app-bare), docTitle (separate <title> from the topbar, so the body keeps the single <h1>), and hideSignIn (suppress the footer Sign-in on auth pages to avoid a login loop). auth/home/landing now render inside the shell.

Dashboard is a replaceable instructional starter (definePlugin snippet, no mock data). Email stays delegated to Kratos: documented its built-in courier.template_override_path instead of adding web-side SMTP.
This commit is contained in:
2026-06-23 21:26:00 +02:00
parent af097a8885
commit e22d24aa8a
24 changed files with 377 additions and 529 deletions
+19 -105
View File
@@ -1,114 +1,28 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildDashboardModel } from "./dashboard.ts";
import type { NavNode } from "./nav.ts";
// Pull the first data-table column ("Name") and the rendered row count for terse assertions.
const rowCount = (m: ReturnType<typeof buildDashboardModel>): number => m.table.rows.length;
const nameOf = (m: ReturnType<typeof buildDashboardModel>, i: number): string =>
// first cell is the user cell { user: { name } }
((m.table.rows[i] as { cells: unknown[] }).cells[0] as { user: { name: string } }).user.name;
const col0 = (m: ReturnType<typeof buildDashboardModel>) =>
m.table.columns[0] as { href?: string; label: string; sort?: string };
// The default /dashboard is an instructional starter (todo §10): no mock data, just the unified
// menu + shell. The host passes the one global menu (ctx.chrome.nav); the model passes it through.
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
test("dashboard default: page 1, mock data, nav + shell wired", () => {
const m = buildDashboardModel(new URL("http://x/"));
assert.equal(m.shell.title, "People");
assert.equal(m.shell.csrfToken, ""); // default empty; app.ts passes the per-request token
assert.equal(buildDashboardModel(new URL("http://x/"), [], undefined, "tok.sig").shell.csrfToken, "tok.sig");
assert.ok(m.nav.length > 0); // composeNav produced a tree
assert.equal(col0(m).label, "Name");
assert.equal(m.pagination.summary.total, 30); // full mock dataset
assert.equal(m.pagination.summary.from, 1);
assert.equal(rowCount(m), 12); // default page size
assert.equal(m.pagination.prev.href, undefined); // first page → prev disabled
assert.ok(m.pagination.next.href); // more pages → next enabled
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
const m = buildDashboardModel({ csrfToken: "tok.sig", nav: NAV, user: { email: "ada@x.io", id: "u1", roles: ["admin"] } });
assert.equal(m.shell.title, "Dashboard");
assert.equal(m.shell.csrfToken, "tok.sig");
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
assert.deepEqual(m.nav, NAV); // the host's menu is used verbatim — the dashboard builds no nav of its own
});
test("dashboard search filters rows, shrinks the total and shows a pill", () => {
const first = nameOf(buildDashboardModel(new URL("http://x/")), 0); // e.g. "Avery Kline"
const m = buildDashboardModel(new URL(`http://x/?q=${encodeURIComponent(first)}`));
test("dashboard model: sensible defaults (empty nav, no token, Guest) and branding from menu", () => {
const m = buildDashboardModel();
assert.deepEqual(m.nav, []);
assert.equal(m.shell.csrfToken, "");
assert.equal(m.shell.user.name, "Guest");
assert.equal(m.shell.brand.name, "Plainpages");
assert.equal(m.pagination.summary.total, 1);
assert.equal(rowCount(m), 1);
assert.equal(nameOf(m, 0), first);
assert.deepEqual(m.filterBar.pills.map((p) => p.label), ["Search"]);
// A no-match query yields an empty table, not an error.
const none = buildDashboardModel(new URL("http://x/?q=zzz-no-such-person"));
assert.equal(none.pagination.summary.total, 0);
assert.equal(rowCount(none), 0);
});
test("dashboard sorts by a column, reflects direction, and the header toggles", () => {
const asc = buildDashboardModel(new URL("http://x/?sort=name"));
const desc = buildDashboardModel(new URL("http://x/?sort=-name"));
// asc first name ≤ desc first name (reverse order).
assert.ok(nameOf(asc, 0) <= nameOf(asc, 1));
assert.ok(nameOf(desc, 0) >= nameOf(desc, 1));
// The Name column carries the current direction and its header links to the opposite.
assert.equal(col0(asc).sort, "asc");
assert.match(col0(asc).href ?? "", /sort=-name/); // asc → click flips to desc
assert.equal(col0(desc).sort, "desc");
assert.match(col0(desc).href ?? "", /sort=name(?!\w)/); // desc → click flips to asc
// An unknown sort field is ignored (no crash, no sort indicator).
const bad = buildDashboardModel(new URL("http://x/?sort=bogus"));
assert.equal(col0(bad).sort, undefined);
});
test("dashboard applies the central menu config: branding + nav override (rename/hide)", () => {
const m = buildDashboardModel(new URL("http://x/"), [], {
branding: { logo: "/public/logo.svg", name: "Acme Ops", sub: "Admin", theme: "dark" },
override: { hide: ["teams"], rename: { people: "Staff" } },
});
assert.deepEqual(m.shell.brand, { logo: "/public/logo.svg", name: "Acme Ops", sub: "Admin" });
assert.equal(m.shell.theme, "dark");
const labels = m.nav.map((n) => n.label);
assert.ok(labels.includes("Staff")); // "People" renamed
assert.ok(!labels.includes("Teams")); // "Teams" hidden
});
// The dashboard assembles its nav from two gated sources — the built-in Admin section and each
// discovered plugin's fragment (§7) — and role-filters both via composeNav. One contract: a section
// shows only for a holder of its permission, and each source is gated independently.
test("dashboard role-filters the gated Admin section and plugin fragments, each independently", () => {
const plugin = {
apiVersion: "1.0.0", id: "scheduling",
nav: [{ children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], icon: "i-cal", id: "scheduling", label: "Scheduling" }],
};
const section = (m: ReturnType<typeof buildDashboardModel>, label: string) => m.nav.find((n) => n.label === label);
// An admin sees the Admin section (the four built-in screens) but not the plugin's gated section.
const admin = buildDashboardModel(new URL("http://x/"), ["admin"], undefined, "", null, [plugin]);
assert.deepEqual(section(admin, "Admin")!.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]);
assert.equal(section(admin, "Scheduling"), undefined);
// A plugin-permission holder sees the plugin section (reachable from "/") but not Admin.
const member = buildDashboardModel(new URL("http://x/"), ["scheduling:read"], undefined, "", null, [plugin]);
assert.ok(section(member, "Scheduling")!.children?.some((c) => c.href === "/scheduling/shifts"));
assert.equal(section(member, "Admin"), undefined);
// Anonymous sees neither — composeNav drops a gated header and its whole subtree.
const anon = buildDashboardModel(new URL("http://x/"), [], undefined, "", null, [plugin]);
assert.equal(section(anon, "Admin"), undefined);
assert.equal(section(anon, "Scheduling"), undefined);
});
test("dashboard paginates: page 2 slices the next rows and preserves state in links", () => {
const p2 = buildDashboardModel(new URL("http://x/?sort=-name&page=2"));
assert.equal(p2.pagination.summary.from, 13); // 30 rows / 12 per page → page 2 starts at 13
assert.equal(rowCount(p2), 12);
// prev/next present on the middle page; both preserve the active sort.
assert.match(p2.pagination.prev.href ?? "", /sort=-name/);
assert.match(p2.pagination.next.href ?? "", /sort=-name/);
// Team filter actually narrows the set and adds a pill.
const eng = buildDashboardModel(new URL("http://x/?team=Engineering"));
assert.ok(eng.pagination.summary.total < 30);
assert.deepEqual(eng.filterBar.pills.map((p) => p.label), ["Team"]);
const branded = buildDashboardModel({ menu: { branding: { name: "Acme Ops", sub: "Admin", theme: "dark" }, override: {} } });
assert.equal(branded.shell.brand.name, "Acme Ops");
assert.equal(branded.shell.theme, "dark");
});