§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
+13 -8
View File
@@ -5,13 +5,14 @@
// PRG redirect (mirrors the Users "trigger recovery" one-time code). `handleAdminClients` is the
// imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded.
import { ADMIN_CLIENTS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import { safeDecode } from "./admin-groups.ts";
import type { FieldConfig } from "./admin-users.ts";
import type { RequestContext, User } from "./context.ts";
import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts";
import { parseListQuery } from "./list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { paginate } from "./paginate.ts";
import type { RouteResult } from "./plugin.ts";
import { buildShellContext } from "./shell-context.ts";
@@ -110,6 +111,7 @@ export function buildClientsListModel(opts: {
clients: OAuth2Client[];
csrfToken?: string;
menu?: MenuConfig;
nav?: NavNode[];
url: URL | URLSearchParams | string;
user?: User | null;
}) {
@@ -127,7 +129,7 @@ export function buildClientsListModel(opts: {
return {
filterBar: listFilterBar(state),
nav: adminNav(opts.user?.roles ?? [], menu, "clients"),
nav: opts.nav ?? [],
pagination: listPagination(state, page),
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }],
@@ -192,6 +194,7 @@ export function buildClientFormModel(opts: {
csrfToken?: string;
error?: string;
menu?: MenuConfig;
nav?: NavNode[];
user?: User | null;
values?: Partial<ClientInput>;
}) {
@@ -217,7 +220,7 @@ export function buildClientFormModel(opts: {
scopeField,
submitLabel: "Register client",
},
nav: adminNav(opts.user?.roles ?? [], menu, "clients"),
nav: opts.nav ?? [],
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }],
csrfToken: opts.csrfToken ?? "",
@@ -233,6 +236,7 @@ export function buildClientDetailModel(opts: {
created?: boolean; // just registered → success banner + the one-time secret (if any)
csrfToken?: string;
menu?: MenuConfig;
nav?: NavNode[];
secret?: string; // one-time client_secret (confidential clients), shown once right after create
user?: User | null;
}) {
@@ -243,7 +247,7 @@ export function buildClientDetailModel(opts: {
created: opts.created ?? false,
csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` },
nav: adminNav(opts.user?.roles ?? [], menu, "clients"),
nav: opts.nav ?? [],
secret: opts.secret,
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }],
@@ -280,21 +284,22 @@ export async function handleAdminClients(ctx: RequestContext, csrfToken: string,
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
const { hydra, menu, render } = deps;
const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host
const method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_CLIENTS_BASE.length).split("/").filter(Boolean);
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
const renderForm = async (extra: { error?: string; values?: Partial<ClientInput> }): Promise<RouteResult> =>
({ html: await render("admin/client-form", { model: buildClientFormModel({ csrfToken, menu, user, ...extra }) }) });
({ html: await render("admin/client-form", { model: buildClientFormModel({ csrfToken, menu, nav, user, ...extra }) }) });
const renderDetail = async (client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): Promise<RouteResult> =>
({ html: await render("admin/client-detail", { model: buildClientDetailModel({ client: toClientView(client), csrfToken, menu, user, ...extra }) }) });
({ html: await render("admin/client-detail", { model: buildClientDetailModel({ client: toClientView(client), csrfToken, menu, nav, user, ...extra }) }) });
const notFound = async (): Promise<RouteResult> => ({ html: await render("404", { title: "Not found" }), status: 404 });
// /admin/clients — list (GET) · register (POST)
if (seg.length === 0) {
if (method === "GET") {
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
return { html: await render("admin/clients", { model: buildClientsListModel({ clients, csrfToken, menu, url: ctx.url, user }) }) };
return { html: await render("admin/clients", { model: buildClientsListModel({ clients, csrfToken, menu, nav, url: ctx.url, user }) }) };
}
if (method === "POST") {
const input = readClientInput(form!);
@@ -335,7 +340,7 @@ export async function handleAdminClients(ctx: RequestContext, csrfToken: string,
return { html: await render("admin/confirm", { model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client", csrfToken,
current: "clients", menu, message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, title: "Delete client", user,
menu, message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, nav, title: "Delete client", user,
}) }) };
}
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
+13 -8
View File
@@ -6,13 +6,14 @@
// is the imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded, mapping each action
// to a RouteResult.
import { ADMIN_GROUPS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import type { FieldConfig } from "./admin-users.ts";
import type { RequestContext, User } from "./context.ts";
import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
import { parseListQuery } from "./list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { paginate } from "./paginate.ts";
import type { RouteResult } from "./plugin.ts";
import { buildShellContext } from "./shell-context.ts";
@@ -120,6 +121,7 @@ export function buildGroupsListModel(opts: {
csrfToken?: string;
groups: GroupView[];
menu?: MenuConfig;
nav?: NavNode[];
url: URL | URLSearchParams | string;
user?: User | null;
}) {
@@ -147,7 +149,7 @@ export function buildGroupsListModel(opts: {
return {
filterBar: listFilterBar(state),
nav: adminNav(opts.user?.roles ?? [], menu, "groups"),
nav: opts.nav ?? [],
pagination: listPagination(state, page),
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Admin" }, { label: "Groups" }],
@@ -214,6 +216,7 @@ export function buildGroupFormModel(opts: {
error?: string;
memberOptions: MemberOption[];
menu?: MenuConfig;
nav?: NavNode[];
user?: User | null;
values?: { member?: string; name?: string };
}) {
@@ -233,7 +236,7 @@ export function buildGroupFormModel(opts: {
selectedMember: opts.values?.member ?? "",
submitLabel: "Create group",
},
nav: adminNav(opts.user?.roles ?? [], menu, "groups"),
nav: opts.nav ?? [],
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: "New" }],
csrfToken: opts.csrfToken ?? "",
@@ -251,6 +254,7 @@ export function buildGroupDetailModel(opts: {
group: { name: string };
members: MemberView[];
menu?: MenuConfig;
nav?: NavNode[];
user?: User | null;
}) {
const menu = opts.menu ?? DEFAULT_MENU;
@@ -266,7 +270,7 @@ export function buildGroupDetailModel(opts: {
error: opts.error,
group: { name },
members: { action: `${base}/members/delete`, rows: opts.members },
nav: adminNav(opts.user?.roles ?? [], menu, "groups"),
nav: opts.nav ?? [],
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: name }],
csrfToken: opts.csrfToken ?? "",
@@ -332,22 +336,23 @@ export async function handleAdminGroups(ctx: RequestContext, csrfToken: string,
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
const { keto, kratosAdmin, menu, render } = deps;
const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host
const method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_GROUPS_BASE.length).split("/").filter(Boolean);
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
const renderList = async (): Promise<RouteResult> => {
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
return { html: await render("admin/groups", { model: buildGroupsListModel({ csrfToken, groups, menu, url: ctx.url, user }) }) };
return { html: await render("admin/groups", { model: buildGroupsListModel({ csrfToken, groups, menu, nav, url: ctx.url, user }) }) };
};
const renderForm = async (extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(keto, kratosAdmin);
return { html: await render("admin/group-form", { model: buildGroupFormModel({ csrfToken, memberOptions: options, menu, user, ...extra }) }) };
return { html: await render("admin/group-form", { model: buildGroupFormModel({ csrfToken, memberOptions: options, menu, nav, user, ...extra }) }) };
};
const renderDetail = async (name: string): Promise<RouteResult> => {
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
return { html: await render("admin/group-detail", { model: buildGroupDetailModel({ candidates: options, csrfToken, group: { name }, members, menu, user }) }) };
return { html: await render("admin/group-detail", { model: buildGroupDetailModel({ candidates: options, csrfToken, group: { name }, members, menu, nav, user }) }) };
};
// /admin/groups — list (GET) · create (POST)
@@ -388,7 +393,7 @@ export async function handleAdminGroups(ctx: RequestContext, csrfToken: string,
return { html: await render("admin/confirm", { model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete group", csrfToken,
current: "groups", menu, message: `Delete group ${name}? This removes the group and all its memberships.`, title: "Delete group", user,
menu, message: `Delete group ${name}? This removes the group and all its memberships.`, nav, title: "Delete group", user,
}) }) };
}
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
+7 -13
View File
@@ -6,7 +6,7 @@ import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import { test } from "node:test";
import {
ADMIN_PERMISSION, ADMIN_USERS_BASE, adminNav, adminSection, buildConfirmModel, guardedForm, requireAdmin,
ADMIN_PERMISSION, ADMIN_USERS_BASE, adminSection, buildConfirmModel, guardedForm, requireAdmin,
} from "./admin-nav.ts";
import { buildContext, type RequestContext, type User } from "./context.ts";
import { CSRF_COOKIE, CSRF_FIELD, issueCsrfToken } from "./csrf.ts";
@@ -45,15 +45,8 @@ test("adminSection: gated Admin header over the four screens; current marks the
assert.equal(onRoles.children?.find((c) => c.id === "users")?.current, undefined);
});
test("adminNav: prepends Dashboard and role-filters the section (admin sees it, others get only Dashboard)", () => {
const forAdmin = adminNav(admin.roles, DEFAULT_MENU, "users");
assert.deepEqual(labels(forAdmin), ["Dashboard", "Admin"]);
// composeNav strips `id` from rendered nodes but keeps `current`/`href`, so match the active item by href.
assert.equal(forAdmin.find((n) => n.label === "Admin")!.children?.find((c) => c.href === ADMIN_USERS_BASE)?.current, true);
assert.deepEqual(labels(adminNav(member.roles, DEFAULT_MENU, "users")), ["Dashboard"]); // non-admin → gated section dropped
assert.deepEqual(labels(adminNav([], DEFAULT_MENU, "users")), ["Dashboard"]); // anonymous too
});
// (The in-screen admin sidebar is gone in §10 — every page renders the one global menu, built by
// buildPluginChrome; see chrome.test.ts. adminSection above is that menu's gated Admin fragment.)
// ---- auth gates ----
@@ -82,15 +75,16 @@ test("guardedForm: valid double-submit → the parsed body, bad/missing token
// ---- confirm-page model ----
test("buildConfirmModel wires the danger action, message, role-filtered nav and shell", () => {
test("buildConfirmModel wires the danger action, message, passed-in nav and shell", () => {
const nav = [{ label: "Dashboard" }, { label: "Admin" }];
const model = buildConfirmModel({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: "Delete" }],
cancelHref: ADMIN_USERS_BASE, confirmAction: `${ADMIN_USERS_BASE}/u1/delete`, confirmLabel: "Delete user",
csrfToken: "tok", current: "users", menu: DEFAULT_MENU, message: "Delete ada@x.io?", title: "Delete user", user: admin,
csrfToken: "tok", menu: DEFAULT_MENU, message: "Delete ada@x.io?", nav, title: "Delete user", user: admin,
});
assert.deepEqual(model.confirm, { action: `${ADMIN_USERS_BASE}/u1/delete`, label: "Delete user" });
assert.equal(model.message, "Delete ada@x.io?");
assert.equal(model.cancelHref, ADMIN_USERS_BASE);
assert.ok(labels(model.nav).includes("Admin")); // admin user ⇒ section present in the in-screen sidebar
assert.equal(model.nav, nav); // the host's one global menu, passed through verbatim
assert.equal(model.shell.title, "Delete user");
});
+13 -17
View File
@@ -1,15 +1,15 @@
// The built-in admin section of the menu (todo §5). One definition of the Users/Groups/Roles links
// + their gate, reused two ways so they can't drift: `adminSection()` is the permission-gated
// "Admin" header wired into the global dashboard menu (composeNav drops the whole header + subtree
// for a non-admin), and `adminNav()` is the in-screen sidebar each admin screen renders (a link home
// + the same section, with the active item marked `current`).
// The built-in admin section of the menu (todo §5). `adminSection()` is the one definition of the
// permission-gated "Admin" header (Users/Groups/Roles/clients) + its gate, composed into the single
// global menu (`buildPluginChrome`, §10) — composeNav drops the whole header + subtree for a
// non-admin. Every page (dashboard, admin, plugin, auth) renders that one menu, so there's no
// separate admin sidebar to drift.
import { readFormBody } from "./body.ts";
import type { RequestContext, User } from "./context.ts";
import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts";
import { GuardError, loginRedirect } from "./guards.ts";
import { type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode } from "./nav.ts";
import type { NavNode } from "./nav.ts";
import { buildShellContext } from "./shell-context.ts";
export const ADMIN_PERMISSION = "admin"; // role token gating the admin section
@@ -20,9 +20,9 @@ export const ADMIN_CLIENTS_BASE = "/admin/clients";
export type AdminScreen = "clients" | "groups" | "roles" | "users";
// The "Dashboard" link to the gated app home (/dashboard). One definition, reused by the in-screen
// admin sidebar and the plugin-page chrome (chrome.ts) so the two can't drift. It targets a gated
// route, so the chrome hides it from anonymous visitors (a non-signed-in click only dead-ends at /login).
// The "Dashboard" link to the gated app home (/dashboard), composed into the global menu by
// buildPluginChrome (chrome.ts). It targets a gated route, so the chrome hides it from anonymous
// visitors (a non-signed-in click only dead-ends at /login).
export const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" };
const ITEMS: { href: string; icon: string; id: AdminScreen; label: string }[] = [
@@ -45,11 +45,6 @@ export function adminSection(current?: AdminScreen): NavNode {
};
}
// In-screen sidebar for the admin screens: a link home + the admin section (active item marked).
export function adminNav(roles: string[], menu: MenuConfig, current: AdminScreen): NavNode[] {
return composeNav([[DASHBOARD_NAV, adminSection(current)]], menu.override, roles);
}
// The shared gate for every admin screen: a signed-in admin only. Throws GuardError that app.ts maps
// (anonymous → /login, non-admin → 403). Returns the (non-null) user for the handler to thread on.
export function requireAdmin(ctx: RequestContext): User {
@@ -70,16 +65,17 @@ export async function guardedForm(ctx: RequestContext, csrfSecret: string): Prom
}
// Build the model for the shared destructive-action confirm page (views/admin/confirm.ejs): a single
// danger action behind a deliberate second step, plus a cancel link. Reused by all three screens.
// danger action behind a deliberate second step, plus a cancel link. Reused by all admin screens.
// `nav` is the unified global menu (ctx.chrome.nav), passed in by the handler.
export function buildConfirmModel(opts: {
breadcrumbs: { href?: string; label: string }[];
cancelHref: string;
confirmAction: string;
confirmLabel: string;
csrfToken: string;
current: AdminScreen;
menu: MenuConfig;
message: string;
nav: NavNode[];
title: string;
user: User | null;
}) {
@@ -87,7 +83,7 @@ export function buildConfirmModel(opts: {
cancelHref: opts.cancelHref,
confirm: { action: opts.confirmAction, label: opts.confirmLabel },
message: opts.message,
nav: adminNav(opts.user?.roles ?? [], opts.menu, opts.current),
nav: opts.nav,
shell: buildShellContext({ breadcrumbs: opts.breadcrumbs, csrfToken: opts.csrfToken, menu: opts.menu, title: opts.title, user: opts.user }),
};
}
+13 -8
View File
@@ -8,7 +8,7 @@
// Kratos is read only to label members. `handleAdminRoles` is the imperative shell app.ts dispatches
// to — gated admin-only, CSRF-guarded.
import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import {
type GroupView,
groupsFromTuples,
@@ -27,6 +27,7 @@ import type { ExpandTree, KetoClient, RelationTuple } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
import { parseListQuery } from "./list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { paginate } from "./paginate.ts";
import type { RouteResult } from "./plugin.ts";
import { buildShellContext } from "./shell-context.ts";
@@ -105,6 +106,7 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
export function buildRolesListModel(opts: {
csrfToken?: string;
menu?: MenuConfig;
nav?: NavNode[];
roles: RoleView[];
url: URL | URLSearchParams | string;
user?: User | null;
@@ -133,7 +135,7 @@ export function buildRolesListModel(opts: {
return {
filterBar: listFilterBar(state),
nav: adminNav(opts.user?.roles ?? [], menu, "roles"),
nav: opts.nav ?? [],
pagination: listPagination(state, page),
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }],
@@ -200,6 +202,7 @@ export function buildRoleFormModel(opts: {
error?: string;
memberOptions: MemberOption[];
menu?: MenuConfig;
nav?: NavNode[];
user?: User | null;
values?: { member?: string; name?: string };
}) {
@@ -219,7 +222,7 @@ export function buildRoleFormModel(opts: {
selectedMember: opts.values?.member ?? "",
submitLabel: "Create role",
},
nav: adminNav(opts.user?.roles ?? [], menu, "roles"),
nav: opts.nav ?? [],
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }],
csrfToken: opts.csrfToken ?? "",
@@ -237,6 +240,7 @@ export function buildRoleDetailModel(opts: {
error?: string;
members: MemberView[];
menu?: MenuConfig;
nav?: NavNode[];
role: { name: string };
user?: User | null;
}) {
@@ -252,7 +256,7 @@ export function buildRoleDetailModel(opts: {
effective: opts.effective,
error: opts.error,
members: { action: `${base}/members/delete`, rows: opts.members },
nav: adminNav(opts.user?.roles ?? [], menu, "roles"),
nav: opts.nav ?? [],
role: { name },
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }],
@@ -304,24 +308,25 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
const { keto, kratosAdmin, menu, render } = deps;
const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host
const method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_ROLES_BASE.length).split("/").filter(Boolean);
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
const renderList = async (): Promise<RouteResult> => {
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
return { html: await render("admin/roles", { model: buildRolesListModel({ csrfToken, menu, roles, url: ctx.url, user }) }) };
return { html: await render("admin/roles", { model: buildRolesListModel({ csrfToken, menu, nav, roles, url: ctx.url, user }) }) };
};
const renderForm = async (extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(keto, kratosAdmin);
return { html: await render("admin/role-form", { model: buildRoleFormModel({ csrfToken, memberOptions: options, menu, user, ...extra }) }) };
return { html: await render("admin/role-form", { model: buildRoleFormModel({ csrfToken, memberOptions: options, menu, nav, user, ...extra }) }) };
};
const renderDetail = async (name: string, error?: string): Promise<RouteResult> => {
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
const tuples = await pagedTuples(keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
const members = tuples.map((t) => memberView(t, emailById));
const effective = await effectiveUsers(keto, name, tuples.length > 0, emailById);
const html = await render("admin/role-detail", { model: buildRoleDetailModel({ candidates: options, csrfToken, effective, members, menu, role: { name }, user, ...(error ? { error } : {}) }) });
const html = await render("admin/role-detail", { model: buildRoleDetailModel({ candidates: options, csrfToken, effective, members, menu, nav, role: { name }, user, ...(error ? { error } : {}) }) });
return error ? { html, status: 400 } : { html };
};
@@ -367,7 +372,7 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
return { html: await render("admin/confirm", { model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role", csrfToken,
current: "roles", menu, message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role", user,
menu, message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, nav, title: "Delete role", user,
}) }) };
}
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
+10 -6
View File
@@ -5,12 +5,13 @@
// CSRF-guarded, each action mapped to a RouteResult (render, or redirect after a write — PRG).
import { safeDecode } from "./admin-groups.ts";
import { ADMIN_USERS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import type { RequestContext, User } from "./context.ts";
import type { Identity, KratosAdmin, RecoveryCode } from "./kratos-admin.ts";
import { KratosError } from "./kratos-public.ts";
import { parseListQuery } from "./list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { paginate } from "./paginate.ts";
import type { RouteResult } from "./plugin.ts";
import { buildShellContext } from "./shell-context.ts";
@@ -118,6 +119,7 @@ export function buildUsersListModel(opts: {
csrfToken?: string;
identities: Identity[];
menu?: MenuConfig;
nav?: NavNode[]; // the unified global menu (ctx.chrome.nav); the host builds it once per request
url: URL | URLSearchParams | string;
user?: User | null;
}) {
@@ -145,7 +147,7 @@ export function buildUsersListModel(opts: {
return {
filterBar: listFilterBar(state, all.length),
nav: adminNav(opts.user?.roles ?? [], menu, "users"),
nav: opts.nav ?? [],
pagination: listPagination(state, page),
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Admin" }, { label: "Users" }],
@@ -237,6 +239,7 @@ export function buildUserFormModel(opts: {
error?: string;
identity?: Identity | null;
menu?: MenuConfig;
nav?: NavNode[];
recovery?: RecoveryCode;
user?: User | null;
values?: Partial<UserInput>;
@@ -267,7 +270,7 @@ export function buildUserFormModel(opts: {
} : undefined,
error: opts.error,
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? "Save changes" : "Create user" },
nav: adminNav(opts.user?.roles ?? [], menu, "users"),
nav: opts.nav ?? [],
recovery: opts.recovery,
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: editing ? "Edit" : "New" }],
@@ -306,16 +309,17 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
const { kratosAdmin, menu, render } = deps;
const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host
const method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_USERS_BASE.length).split("/").filter(Boolean);
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
const renderList = async (): Promise<RouteResult> => {
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
return { html: await render("admin/users", { model: buildUsersListModel({ csrfToken, identities, menu, url: ctx.url, user }) }) };
return { html: await render("admin/users", { model: buildUsersListModel({ csrfToken, identities, menu, nav, url: ctx.url, user }) }) };
};
const renderForm = async (extra: Parameters<typeof buildUserFormModel>[0]): Promise<RouteResult> =>
({ html: await render("admin/user-form", { model: buildUserFormModel({ csrfToken, menu, user, ...extra }) }) });
({ html: await render("admin/user-form", { model: buildUserFormModel({ csrfToken, menu, nav, user, ...extra }) }) });
// /admin/users — list (GET) · create (POST)
if (seg.length === 0) {
@@ -366,7 +370,7 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
return { html: await render("admin/confirm", { model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }],
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: "Delete user", csrfToken,
current: "users", menu, message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, title: "Delete user", user,
menu, message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, nav, title: "Delete user", user,
}) }) };
}
if (method === "POST") {
+11 -13
View File
@@ -48,20 +48,20 @@ before(async () => {
after(() => server.close());
test("the dashboard at /dashboard: the app-shell People list, gated to a session, filterable via the URL", async () => {
test("the dashboard at /dashboard: the instructional starter in the unified shell, gated to a session", async () => {
// The dashboard is gated to a signed-in user (§10), so present a session.
const res = await fetch(base + "/dashboard", { headers: { cookie: session() } });
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
const html = await res.text();
// Shell + building blocks composed around the mock data.
// The unified app shell (§10): the same sidebar/menu every page renders.
assert.match(html, /Plainpages/); // sidebar brand
assert.match(html, /<aside class="sidebar"/);
assert.match(html, /<form class="filters"/);
assert.match(html, /<table class="table"/);
assert.match(html, /<footer class="pager"/);
assert.match(html, /Avery Kline/); // a mock person on page 1
assert.match(html, /Starter dashboard/); // the default flags itself a demo to replace with a `dashboard` plugin (§10)
// The default is a short instructional starter, not a mock-data list.
assert.match(html, /Starter dashboard/);
assert.match(html, /export default definePlugin/); // shows how to replace it from a plugin
assert.doesNotMatch(html, /<form class="filters"/); // the old mock People list is gone
assert.doesNotMatch(html, /Avery Kline/);
// The Sign-out POST form carries a CSRF token matching the Set-Cookie issued for the page (§4).
const csrfCookie = (res.headers.get("set-cookie") ?? "").match(/plainpages_csrf=([^;]+)/)?.[1];
@@ -69,19 +69,17 @@ test("the dashboard at /dashboard: the app-shell People list, gated to a session
assert.match(res.headers.get("set-cookie") ?? "", /plainpages_csrf=[^;]+;.*HttpOnly/);
assert.match(html, /<form class="menu-item-form" method="post" action="\/logout">/);
assert.match(html, new RegExp(`name="_csrf" value="${csrfCookie!.replace(/[.]/g, "\\.")}"`));
// A search query filters server-side: a no-match query drops every row.
const empty = await fetch(base + "/dashboard?q=zzz-no-such-person", { headers: { cookie: session() } });
assert.doesNotMatch(await empty.text(), /Avery Kline/);
});
test("/ is the public landing (§10): anonymous → 200 with intro + sign-in/register links, no gate", async () => {
test("/ is the public landing (§10): anonymous → 200 with intro + sign-in/register links, in the unified shell", async () => {
const res = await fetch(base + "/", { redirect: "manual" });
assert.equal(res.status, 200); // public — no redirect to sign in
const html = await res.text();
assert.match(html, /href="\/login"/); // a prominent path to sign in
assert.match(html, /href="\/registration"/); // and to register
assert.doesNotMatch(html, /<aside class="sidebar"/); // standalone page, not the signed-in app shell
// §10: the same app shell every page renders — the menu shows even when signed out (role-filtered).
assert.match(html, /<aside class="sidebar"/);
assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1>
});
test("/dashboard is gated (§10): an anonymous visitor is bounced to sign in (return_to kept)", async () => {
+10 -5
View File
@@ -324,7 +324,10 @@ export function createApp(options: AppOptions = {}): Server {
}
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
}
sendHtml(res, 200, await render("auth", { brand: menu.branding.name, flow: buildFlowView(flow, flowType) }));
// Rendered inside the unified app shell (§10), so set a fresh CSRF cookie when minted — the
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
sendHtml(res, 200, await render("auth", { chrome: ctx.chrome, flow: buildFlowView(flow, flowType) }));
return;
}
@@ -484,8 +487,10 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data));
return;
}
// Default landing — no form, so no CSRF cookie. `user` lets it show "go to dashboard" vs sign in.
sendHtml(res, 200, await render("home", { brand: menu.branding.name, user }));
// Default landing in the unified app shell (§10): `user` picks "go to dashboard" vs sign-in,
// and the shell's Sign-out form (when signed in) needs a fresh CSRF cookie.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
sendHtml(res, 200, await render("home", { chrome: ctx.chrome, user }));
return;
}
@@ -504,8 +509,8 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
return;
}
// Roles from the verified JWT; branding/override come from config/menu.ts.
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu, csrf.token, user, plugins) }));
// The one global menu (ctx.chrome.nav) + branding/override from config/menu.ts.
sendHtml(res, 200, await render("index", { model: buildDashboardModel({ csrfToken: csrf.token, menu, nav: ctx.chrome.nav, user }) }));
return;
}
+8 -3
View File
@@ -45,9 +45,14 @@ test("a permission holder sees the Dashboard link + plugin nav; current path ope
assert.equal(chrome.user.name, "ada"); // email local part
});
test("an admin sees the gated admin section", () => {
const chrome = buildPluginChrome({ menu: DEFAULT_MENU, user: { email: "a@b.c", id: "u1", roles: ["admin"] } });
assert.ok(labels(chrome.nav).includes("Admin"));
test("an admin sees the gated admin section; a sub-path marks its base leaf current", () => {
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, user: { email: "a@b.c", id: "u1", roles: ["admin"] } });
const admin = chrome.nav.find((n) => n.label === "Admin")!;
assert.ok(admin); // gated section visible to an admin
assert.equal(admin.open, true); // ancestor of the current leaf opened
// /admin/users/new is under the Users base (/admin/users) → that leaf is current, not Groups/Roles.
assert.equal(admin.children!.find((c) => c.label === "Users")!.current, true);
assert.equal(admin.children!.find((c) => c.label === "Groups")!.current, undefined);
});
test("branding logo + default theme flow through when set", () => {
+27 -5
View File
@@ -37,7 +37,12 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
const roles = opts.user?.roles ?? [];
const nav = composeNav(fragments, opts.menu.override, roles);
if (opts.currentPath) markCurrent(nav, opts.currentPath);
if (opts.currentPath) {
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
const target = bestHref(nav, opts.currentPath);
if (target) markCurrent(nav, target);
}
const b = opts.menu.branding;
return {
@@ -51,14 +56,31 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
};
}
// Mark the leaf whose href equals `path` as current and open every ancestor header so the active
// The href of the leaf that owns `path`: an exact match, else the longest href that is a parent of
// it (href + "/" prefixes path), so /admin/users/123 resolves to the /admin/users leaf. "/" never
// counts as a parent (it would own everything). Returns undefined when nothing matches.
function bestHref(nodes: NavNode[], path: string): string | undefined {
let best: string | undefined;
const visit = (ns: NavNode[]): void => {
for (const n of ns) {
if (n.href != null && (n.href === path || (n.href !== "/" && path.startsWith(`${n.href}/`)))) {
if (best === undefined || n.href.length > best.length) best = n.href;
}
if (n.children) visit(n.children);
}
};
visit(nodes);
return best;
}
// Mark the leaf whose href equals `target` as current and open every ancestor header so the active
// page is revealed. Mutates the freshly-composed nodes (composeNav returns new objects each call).
// Returns whether this subtree contains the current node.
function markCurrent(nodes: NavNode[], path: string): boolean {
function markCurrent(nodes: NavNode[], target: string): boolean {
let hit = false;
for (const node of nodes) {
const here = node.href === path;
const inChild = node.children ? markCurrent(node.children, path) : false;
const here = node.href === target;
const inChild = node.children ? markCurrent(node.children, target) : false;
if (here) node.current = true;
if (here || inChild) {
if (node.children) node.open = true;
+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");
});
+13 -205
View File
@@ -1,217 +1,25 @@
// Dashboard view model (todo §1): the gated "/dashboard" app-shell "People" list. Pure — turns a
// request URL into the data the building-block partials render, wiring the §1 helpers end-to-end:
// parseListQuery → filter/sort/paginate a mock dataset → composeNav. This is the built-in *demo* home
// over mock data (a plugin owns the real one via a `dashboard` handler, §10); the filter form,
// sortable headers and pager all round-trip the URL (zero-JS).
// Dashboard view model (todo §10): the gated "/dashboard" app home. By default a short instructional
// starter — what the dashboard is and how to replace it by exporting a `dashboard` handler from a
// plugin (views/index.ejs holds the prose). A plugin owns the real one (PluginManifest.dashboard);
// this placeholder renders until then. Pure: `nav` is the one global menu (ctx.chrome.nav), built
// once per request by the host, so the dashboard shows the exact same menu as every other page.
import { adminSection } from "./admin-nav.ts";
import type { User } from "./context.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode, type NavOverride } from "./nav.ts";
import type { Plugin } from "./plugin.ts";
import { parseListQuery } from "./list-query.ts";
import { paginate } from "./paginate.ts";
import type { NavNode } from "./nav.ts";
import { buildShellContext } from "./shell-context.ts";
interface Person {
id: string;
email: string;
initials: string;
lastActive: string;
name: string;
role: string;
status: string;
team: string;
}
const FIRST = ["Avery", "Blair", "Casey", "Devon", "Emerson", "Finley", "Gray", "Harper", "Iris", "Jordan", "Kai", "Logan", "Morgan", "Noor", "Oakley", "Parker", "Quinn", "Riley", "Sage", "Tatum", "Uma", "Vance", "Wren", "Yuki", "Zarah", "Aria", "Beau", "Cleo", "Dane", "Esme"];
const LAST = ["Kline", "Mora", "Nguyen", "Patel", "Rossi", "Stone", "Vega", "Wu", "Ahmed", "Boyd", "Cruz", "Diaz", "Engel", "Frost", "Gomez", "Hale", "Ito", "Jain", "Khan", "Lund", "Marsh", "Novak", "Ortiz", "Pace", "Reed", "Sato", "Tran", "Udall", "Voss", "Webb"];
const ROLES = ["Admin", "Member", "Viewer"];
const TEAMS = ["Engineering", "Design", "Sales", "Support"];
const STATUSES = ["active", "invited", "suspended"];
const ACTIVE = ["2m ago", "1h ago", "3h ago", "Yesterday", "2d ago", "Last week"];
const TONE: Record<string, string> = { active: "pos", invited: "info", suspended: "warn" };
// Cycle a fixed, non-empty list by index (parallel mock arrays — always in range).
const at = <T>(arr: T[], i: number): T => arr[i % arr.length] as T;
const PEOPLE: Person[] = FIRST.map((first, i) => {
const last = LAST[i] as string;
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; user?: User | null } = {}) {
return {
id: `${first}-${last}`.toLowerCase(),
email: `${first}.${last}`.toLowerCase() + "@example.com",
initials: first.charAt(0) + last.charAt(0),
lastActive: at(ACTIVE, i),
name: `${first} ${last}`,
role: at(ROLES, i),
status: at(STATUSES, i),
team: at(TEAMS, i),
};
});
const DEFAULT_PAGE_SIZE = 12;
const PAGE_SIZES = [12, 25, 50];
// Sortable columns → the value to compare on (also gates `?sort=` to known fields).
const SORT: Record<string, (p: Person) => string> = {
email: (p) => p.email, name: (p) => p.name, role: (p) => p.role, status: (p) => p.status, team: (p) => p.team,
};
const COLUMNS: { key: string; label: string; sortable?: boolean }[] = [
{ key: "name", label: "Name", sortable: true },
{ key: "email", label: "Email", sortable: true },
{ key: "role", label: "Role", sortable: true },
{ key: "team", label: "Team", sortable: true },
{ key: "status", label: "Status", sortable: true },
{ key: "lastActive", label: "Last active" },
];
interface State { page: number; pageSize: number; q: string; sort: string | null; status: string; team: string; }
const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
// Canonical list URL from the current state plus per-link overrides; omits defaults so links stay tidy.
function href(state: State, overrides: Partial<State> = {}): string {
const s = { ...state, ...overrides };
const p = new URLSearchParams();
if (s.q) p.set("q", s.q);
if (s.status && s.status !== "all") p.set("status", s.status);
if (s.team) p.set("team", s.team);
if (s.sort) p.set("sort", s.sort);
if (s.page > 1) p.set("page", String(s.page));
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
const qs = p.toString();
return qs ? `?${qs}` : "?";
}
export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = [], menu: MenuConfig = DEFAULT_MENU, csrfToken = "", user: User | null = null, plugins: Plugin[] = []) {
const query = parseListQuery(url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const status = query.filters.status?.[0] ?? "all";
const team = query.filters.team?.[0] ?? "";
const sort = query.sort && SORT[query.sort.field] ? query.sort : null; // ignore unknown fields
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
const needle = query.q.toLowerCase();
let list = PEOPLE.filter((p) =>
(!needle || p.name.toLowerCase().includes(needle) || p.email.toLowerCase().includes(needle)) &&
(status === "all" || p.status === status) &&
(!team || p.team === team));
if (sort) {
const get = SORT[sort.field] as (p: Person) => string; // gated to known fields above
const dir = sort.dir === "desc" ? -1 : 1;
list = [...list].sort((a, b) => get(a).localeCompare(get(b)) * dir);
}
const page = paginate(list.length, query.page, query.pageSize, { boundaries: 1, siblings: 1 });
const start = (page.page - 1) * page.pageSize;
const rows = list.slice(start, start + page.pageSize);
const state: State = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken, status, team };
return {
filterBar: filterBar(state),
nav: nav(roles, menu.override, plugins),
pagination: pagination(state, page),
nav: opts.nav ?? [],
shell: buildShellContext({
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
csrfToken, // hidden field for the shell's Sign-out POST form (§4)
menu,
title: "People",
user, // real signed-in identity (§4); anonymous ⇒ Guest
breadcrumbs: [{ label: "Dashboard" }],
csrfToken: opts.csrfToken ?? "",
menu: opts.menu ?? DEFAULT_MENU,
title: "Dashboard",
user: opts.user ?? null,
}),
table: table(rows, state, sort),
};
}
export type DashboardModel = ReturnType<typeof buildDashboardModel>;
// Sidebar: the demo "Directory" fragment, then each discovered plugin's own nav fragment (so a
// plugin is reachable from the dashboard; gated nodes stay invisible to non-admins), then the gated
// admin section. composeNav applies the central override + per-user role filter.
function nav(roles: string[], override: NavOverride, plugins: Plugin[]): NavNode[] {
const pluginFragments = plugins.filter((p) => p.nav?.length).map((p) => p.nav as NavNode[]);
return composeNav([[
{ count: PEOPLE.length, current: true, href: "/dashboard", icon: "i-users", id: "people", label: "People" },
{ href: "#teams", icon: "i-grid", id: "teams", label: "Teams" },
{ children: [
{ href: "#activity", id: "activity", label: "Activity" },
{ href: "#exports", id: "exports", label: "Exports" },
], icon: "i-chart", id: "reports", label: "Reports", open: true },
{ href: "#settings", icon: "i-gear", id: "settings", label: "Settings", permission: "admin" },
], ...pluginFragments, [
adminSection(), // built-in Users/Groups/Roles screens; gated → invisible to non-admins
]], override, roles);
}
function table(rows: Person[], state: State, sort: { dir: "asc" | "desc"; field: string } | null) {
return {
actions: true,
caption: "People",
columns: COLUMNS.map((c) => {
if (!c.sortable) return { label: c.label };
const dir = sort && sort.field === c.key ? sort.dir : undefined;
const next = dir === "asc" ? `-${c.key}` : c.key; // asc→desc, else→asc
return { href: href(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
}),
rows: rows.map((p) => ({
cells: [
{ user: { initials: p.initials, name: p.name } },
p.email,
p.role,
p.team,
{ badge: { label: cap(p.status), tone: TONE[p.status] } },
p.lastActive,
],
name: p.name,
actions: [
{ href: "#", icon: "i-user", label: "View" },
{ href: "#", icon: "i-edit", label: "Edit" },
{ danger: true, icon: "i-trash", label: "Deactivate", separatorBefore: true },
],
})),
selectable: true,
};
}
function filterBar(state: State) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: "Search", remove: href(state, { page: 1, q: "" }), value: state.q });
if (state.status !== "all") pills.push({ label: "Status", remove: href(state, { page: 1, status: "all" }), value: cap(state.status) });
if (state.team) pills.push({ label: "Team", remove: href(state, { page: 1, team: "" }), value: state.team });
return {
applyLabel: "Apply filters",
clearHref: "?",
label: "Filter people",
pills,
rows: [[
{ label: "Search people", name: "q", placeholder: "Search people…", type: "search", value: state.q },
{ legend: "Status", name: "status", options: [
{ count: PEOPLE.length, label: "All", value: "all" },
{ label: "Active", value: "active" },
{ label: "Invited", value: "invited" },
{ label: "Suspended", value: "suspended" },
], type: "segmented", value: state.status },
{ label: "Team", name: "team", options: [{ label: "All teams", value: "" }, ...TEAMS.map((t) => ({ label: t, value: t }))], type: "select", value: state.team },
{ type: "spacer" },
]],
};
}
function pagination(state: State, page: ReturnType<typeof paginate>) {
// Hidden inputs carry the list state through the rows-per-page GET form (page resets on change).
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
if (state.status !== "all") hidden.push({ name: "status", value: state.status });
if (state.team) hidden.push({ name: "team", value: state.team });
if (state.sort) hidden.push({ name: "sort", value: state.sort });
return {
label: "People pagination",
next: { href: page.next ? href(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: href(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? href(state, { page: page.prev }) : undefined },
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
+24
View File
@@ -51,6 +51,10 @@ test("app shell offers Sign in (not Sign out) to an anonymous visitor — so a p
// When chrome supplies signInHref (the current page as return_to), the link carries it.
const withReturn = await render({ title: "Overview", brand: { name: "Acme" }, nav: "", body: "x", signInHref: "/login?return_to=%2Fscheduling" });
assert.match(withReturn, /href="\/login\?return_to=%2Fscheduling"[^>]*>[\s\S]*?Sign in/);
// hideSignIn (the auth pages, §10): no footer Sign-in — a Sign-in on the login page only loops back.
const onAuth = await render({ title: "", docTitle: "Sign in", brand: { name: "Acme" }, nav: "", body: "x", hideSignIn: true });
assert.doesNotMatch(onAuth, />[\s\S]*?Sign in<\/a>/);
});
test("app shell renders a configured logo + default theme, falls back to the brand mark", async () => {
@@ -73,6 +77,26 @@ test("app shell links extra per-page stylesheets via the styles slot (e.g. a plu
assert.equal((none.match(/rel="stylesheet"/g) ?? []).length, 1);
});
test("app shell can disable the menu (§10): no sidebar, focused single-column layout", async () => {
const bare = await render({ menu: false, title: "Focus", body: '<section id="b">x</section>', nav: '<a href="/x">Overview</a>' });
assert.doesNotMatch(bare, /<aside class="sidebar"/); // sidebar dropped
assert.doesNotMatch(bare, /class="hamburger"/); // and its mobile toggle
assert.match(bare, /<div class="app app-bare">/); // single-column variant
assert.match(bare, /<main class="content" id="main-content"/); // content still renders
assert.match(bare, /<section id="b">x<\/section>/);
});
test("app shell: an empty title yields no topbar <h1> so the body owns the single heading; docTitle sets <title> (§10)", async () => {
// Auth/landing pass title:"" (their card/hero is the <h1>) + an explicit docTitle for the tab.
const html = await render({ title: "", docTitle: "Sign in", brand: { name: "Acme" }, body: "<h1>Sign in</h1>" });
assert.doesNotMatch(html, /<h1 class="page-title"/); // topbar carries no heading
assert.match(html, /<title>Sign in<\/title>/); // explicit document title
assert.match(html, /<aside class="sidebar"/); // menu still shown (the point of §10)
const fallback = await render({ title: "", brand: { name: "Acme" } }); // no docTitle → brand
assert.match(fallback, /<title>Acme<\/title>/);
});
test("app shell escapes text but passes slot HTML through, and renders with defaults", async () => {
const escaped = await render({ title: "<x>", body: "<p>raw</p>" });
assert.match(escaped, /<title>&lt;x&gt;<\/title>/); // user text is escaped