From e22d24aa8a0507e395ea190e2245713ae5e2c0b9 Mon Sep 17 00:00:00 2001 From: lilleman Date: Tue, 23 Jun 2026 21:26:00 +0200 Subject: [PATCH] =?UTF-8?q?=C2=A710=20-=20one=20menu=20everywhere=20(build?= =?UTF-8?q?PluginChrome)=20+=20shell=20on=20every=20page;=20instructional?= =?UTF-8?q?=20starter=20dashboard;=20Kratos-native=20email=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- AGENTS.md | 15 +++ README.md | 34 ++++- docs/plugin-contract.md | 5 + e2e/visual.spec.ts | 36 ++---- public/css/styles.css | 6 + src/admin-clients.ts | 21 +-- src/admin-groups.ts | 21 +-- src/admin-nav.test.ts | 20 +-- src/admin-nav.ts | 30 ++--- src/admin-roles.ts | 21 +-- src/admin-users.ts | 16 ++- src/app.test.ts | 24 ++-- src/app.ts | 15 ++- src/chrome.test.ts | 11 +- src/chrome.ts | 32 ++++- src/dashboard.test.ts | 124 +++--------------- src/dashboard.ts | 218 ++------------------------------ src/shell.test.ts | 24 ++++ todo.md | 12 +- views/auth.ejs | 69 +++++----- views/home.ejs | 60 +++------ views/index.ejs | 42 +++--- views/partials/landing-body.ejs | 13 ++ views/partials/shell.ejs | 37 ++++-- 24 files changed, 377 insertions(+), 529 deletions(-) create mode 100644 views/partials/landing-body.ejs diff --git a/AGENTS.md b/AGENTS.md index 3785abb..1184a78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,21 @@ commands and layout. that way as it grows (never serialise on shared state); parallelism is what keeps it fast. E2E runs in Docker against the live stack — see `README.md`. +## Deliberate architectural deviations (don't re-flag) + +Intentional, reasoned choices — an architecture review should honor them, not re-raise +them. Revisit only if the stated reason stops holding. + +- **`src/` is flat on purpose.** The functional-core / imperative-shell split is + conceptual, not a directory layout; splitting into `core/`/`shell/` dirs was judged + premature for a scaffold this size. Revisit only if the flat tree stops being readable. +- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the + base request context. It protects the I/O-free hot path on the public, bot-hit landing + (`/`). (Declined twice.) +- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` + never touches SMTP. Customization is Kratos' built-in `courier.template_override_path`, + not app code — keeping `web` stateless and dependency-light (see [Email](README.md#email)). + ## Docker only — no host tooling **Everything** (install, typecheck, test, run, build, deploy) goes through Docker / diff --git a/README.md b/README.md index a7115bd..9a611cb 100644 --- a/README.md +++ b/README.md @@ -536,6 +536,34 @@ nav tree from the design foundation (header/leaf × clickable/static, counts, arbitrary depth). Branding (name, logo, default theme) renders in the app shell — the sidebar brand shows the configured logo (else a default mark), and the theme sets the theme-switch default. +**One menu, one shell, everywhere.** There is a single menu (`src/chrome.ts` `buildPluginChrome`), +rendered by the same app shell on **every** page — the dashboard, the admin screens, plugin pages, +and the login / registration / recovery / front (`/`) pages. So it looks identical signed in or out; +it just shows fewer items to an anonymous visitor (only `public` ones, plus a Sign-in link), filtered +by the same per-user rule. The sidebar collapses to a burger on a narrow screen. A page that wants a +focused, chrome-free layout (e.g. a print view) opts out with the shell's `menu: false`. + +## Email + +The only emails are the **recovery** and **verification** codes from Kratos' self-service flows, and +**Kratos renders and sends them** (delegated, like the rest of identity — `web` never touches SMTP). +Dev catches them in **mailpit** (http://localhost:8025); prod points Kratos at a real server via +`COURIER_SMTP_CONNECTION_URI` (`courier.smtp` in `ory/kratos/kratos.yml`). + +**Customizing the email content** is a built-in Kratos feature — no code here. Set +`courier.template_override_path` to a mounted directory and drop Go templates in it, keyed by type: + +``` +<override-path>/recovery_code/valid/email.subject.gotmpl +<override-path>/recovery_code/valid/email.body.gotmpl (+ email.body.plaintext.gotmpl) +<override-path>/verification_code/valid/email.subject.gotmpl +<override-path>/verification_code/valid/email.body.gotmpl +``` + +The `ory/kratos/` tree is already mounted into the Kratos container, so an override dir there is the +simplest place. See Ory's [courier message templates](https://www.ory.sh/docs/kratos/emails-sms/custom-message-templates) +docs for the full template-type list and the data each template receives. + ## Building blocks Plainpages is a **component library, not a page generator** — you assemble pages from partials @@ -805,14 +833,14 @@ src/logger.ts createLogger()/requestLogger() + the ambient request log (r src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms) src/context.ts RequestContext handed to handlers + buildContext() src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot -src/dashboard.ts buildDashboardModel(): the built-in "/dashboard" People list view model (mock data, wires the §1 helpers); /dashboard is gated to a session, "/" is the public landing — both replaceable by a plugin `dashboard`/`home` handler (§10) +src/dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler) (§10). Both render the one unified menu (ctx.chrome) src/admin-users.ts Built-in Users admin screen (§5): list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded src/admin-groups.ts Built-in Groups admin screen (§5): list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded src/admin-roles.ts Built-in Roles admin screen (§5): list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded src/admin-clients.ts Built-in OAuth2 clients admin screen (§6): list/register/delete Hydra OAuth2 clients (apps that log in through us); register shows the one-time client_secret; writes only to Hydra, gated + CSRF-guarded src/admin-nav.ts adminSection(): the permission-gated "Admin" menu section (Users · Groups · Roles · OAuth2 clients), wired into the global dashboard menu + the in-screen admin nav (adminNav) so they can't drift src/shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile) -src/chrome.ts buildPluginChrome(): the brand/global-nav/user/theme/csrf a plugin view renders the native shell from — exposed on ctx.chrome (§7) +src/chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (§7, unified across all pages in §10) — exposed on ctx.chrome src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs) src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize } src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model @@ -825,7 +853,7 @@ src/guards.ts requireSession()/can()/check(): in-handler authorization ( src/hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order (§2); no sandbox (a throwing hook fails loud), skipped when no plugin declares one src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2) src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2) -views/ Core EJS templates: home (public "/" landing), index (app-shell dashboard at /dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), error (Kratos self-service flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + consent + admin bodies, menu/popover, theme switch, icon sprite) +views/ Core EJS templates, all in the one app shell (§10): home (public "/" landing), index (instructional /dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent/admin bodies, menu/popover, theme switch, icon sprite) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) config/menu.ts Central menu override + branding (optional; defaults apply if absent) ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service) diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md index 616e37e..ba3ad67 100644 --- a/docs/plugin-contract.md +++ b/docs/plugin-contract.md @@ -252,6 +252,11 @@ state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then o body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the secret and sets the cookie; the plugin never touches it. (See the reference: `plugins/scheduling/`.) +The same shell renders **every** page (the dashboard, the admin screens, your plugin pages, and the +login/registration/front pages), so the menu looks identical signed in or out — it just role-filters. +A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the +sidebar, single column); everything else still renders. + **`ctx.log`** is a structured, request-scoped logger ([`@larvit/log`](https://www.npmjs.com/package/@larvit/log), §9) already in this request's trace: `ctx.log.info("…", { key: "value" })` (also `warn`/`error`/`debug`, metadata values are string/number/boolean), and **`ctx.log.fetch(url, init?)`** — a drop-in `fetch` diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts index 9c66709..b1b41a2 100644 --- a/e2e/visual.spec.ts +++ b/e2e/visual.spec.ts @@ -37,12 +37,10 @@ test.beforeEach(async ({ context }) => { test("captures live pages + reference mockups for side-by-side review", async ({ page }) => { await page.goto("/dashboard"); await expect(page.locator(".sidebar")).toBeVisible(); - await expect(page.locator("table.table tbody tr").first()).toBeVisible(); + // §10: the default /dashboard is the instructional starter, not a mock-data list. + await expect(page.getByRole("heading", { name: "Starter dashboard" })).toBeVisible(); await shot(page, "live-01-dashboard"); - await page.goto("/dashboard?sort=-name&status=active"); - await shot(page, "live-02-sorted-filtered"); - await page.goto("/dashboard"); await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio await shot(page, "live-03-dark"); @@ -72,7 +70,11 @@ test("live components compute the same design-system styles as the reference moc const ref = await context.newPage(); await ref.goto(APP_SHELL); - for (const selector of [".sidebar", ".topbar", ".brand", ".btn.btn-primary", ".theme-switch", ".filters", ".pager"]) { + // Shell components appear on every page, incl. the instructional /dashboard. The data components + // (.filters/.table/.pager) used to live on the mock dashboard (removed in §10); they're now covered + // by the mockup screenshots + their unit tests, and rendered live with real data by the full-flow + // E2E (the admin Users list) which the Ory-free visual suite can't stand up. + for (const selector of [".sidebar", ".topbar", ".brand", ".btn.btn-primary", ".theme-switch"]) { // .btn-primary = the dashboard's "Browse the example plugin" CTA expect(await styleOf(page, selector), `computed style mismatch for ${selector}`).toEqual(await styleOf(ref, selector)); } await ref.close(); @@ -89,20 +91,9 @@ test("every icon <use> resolves to a defined <symbol> (no broken graphics)", asy expect(missing).toEqual([]); }); -test("sorting and search drive the list through the URL (zero-JS)", async ({ page }) => { - await page.goto("/dashboard"); - const total = await page.locator("tbody tr").count(); - - await page.getByRole("link", { name: /Name/ }).first().click(); - await expect(page).toHaveURL(/sort=name/); - await expect(page.locator("thead th").filter({ hasText: "Name" })).toHaveAttribute("aria-sort", "ascending"); - - await page.goto("/dashboard"); - await page.locator('input[name="q"]').fill("Avery"); - await page.getByRole("button", { name: /Apply filters/ }).click(); - await expect(page).toHaveURL(/q=Avery/); - expect(await page.locator("tbody tr").count()).toBeLessThan(total); -}); +// (The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component +// (list-query/data-table/filter-bar) and exercised live with real data by the full-flow E2E's admin +// Users list. The mock-data dashboard that used to host it in this Ory-free suite is gone (§10).) test("theme switch flips the palette with no JavaScript", async ({ page }) => { await page.goto("/dashboard"); @@ -140,8 +131,9 @@ test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a t test("the public landing at / is ungated and links to sign in + register (§10)", async ({ page, context }) => { await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session) await page.goto("/"); - await expect(page.locator(".landing")).toBeVisible(); // the standalone landing, not the app shell - await expect(page.locator(".sidebar")).toHaveCount(0); + await expect(page.locator(".landing")).toBeVisible(); + // §10: the same app shell every page renders — the menu shows even signed out (role-filtered). + await expect(page.locator(".sidebar")).toBeVisible(); await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login"); await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration"); await shot(page, "live-05-public-landing"); @@ -180,7 +172,7 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red // The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav, // but the gated Shifts leaf is filtered out. await page.goto("/dashboard"); - await expect(page.locator(".sidebar")).toContainText("People"); // dashboard nav renders + await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders (§10) await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out }); diff --git a/public/css/styles.css b/public/css/styles.css index 6b1003d..c6d3d72 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -685,3 +685,9 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); } .btn-danger { color: var(--neg); border-color: var(--neg-bd); } .btn-danger:hover { background: var(--neg-bg); } .recovery-code code { font-size: 1.15rem; font-weight: 600; letter-spacing: 0.04em; } +.code-block { margin: 0; padding: 12px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); overflow-x: auto; font-size: var(--fz-sm); } +/* Chromeless shell (§10): a page may drop the sidebar for a focused single column. */ +.app-bare { grid-template-columns: minmax(0, 1fr); } +.app-bare .content { grid-column: 1; } +/* Auth/landing rendered inside the app shell (§10): a roomy, centered column in the content area. */ +.shell-auth { flex: 1 1 auto; overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; } diff --git a/src/admin-clients.ts b/src/admin-clients.ts index dcc2f44..4320619 100644 --- a/src/admin-clients.ts +++ b/src/admin-clients.ts @@ -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") { diff --git a/src/admin-groups.ts b/src/admin-groups.ts index b60e9fc..4463692 100644 --- a/src/admin-groups.ts +++ b/src/admin-groups.ts @@ -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") { diff --git a/src/admin-nav.test.ts b/src/admin-nav.test.ts index 9d59445..dfc8d40 100644 --- a/src/admin-nav.test.ts +++ b/src/admin-nav.test.ts @@ -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"); }); diff --git a/src/admin-nav.ts b/src/admin-nav.ts index 22e8931..fc3f451 100644 --- a/src/admin-nav.ts +++ b/src/admin-nav.ts @@ -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 }), }; } diff --git a/src/admin-roles.ts b/src/admin-roles.ts index 048757a..699e787 100644 --- a/src/admin-roles.ts +++ b/src/admin-roles.ts @@ -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") { diff --git a/src/admin-users.ts b/src/admin-users.ts index d9f562a..7973056 100644 --- a/src/admin-users.ts +++ b/src/admin-users.ts @@ -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") { diff --git a/src/app.test.ts b/src/app.test.ts index 477c89d..35a1ce6 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -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 () => { diff --git a/src/app.ts b/src/app.ts index e99abfc..fa3d14c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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; } diff --git a/src/chrome.test.ts b/src/chrome.test.ts index 803333d..edc5bd3 100644 --- a/src/chrome.test.ts +++ b/src/chrome.test.ts @@ -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", () => { diff --git a/src/chrome.ts b/src/chrome.ts index a3fafad..906885a 100644 --- a/src/chrome.ts +++ b/src/chrome.ts @@ -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; diff --git a/src/dashboard.test.ts b/src/dashboard.test.ts index 8c77d4f..224c638 100644 --- a/src/dashboard.test.ts +++ b/src/dashboard.test.ts @@ -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"); }); diff --git a/src/dashboard.ts b/src/dashboard.ts index d16291b..b6cf552 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -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 }, - }; -} diff --git a/src/shell.test.ts b/src/shell.test.ts index 2fcd93b..403e7a2 100644 --- a/src/shell.test.ts +++ b/src/shell.test.ts @@ -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><x><\/title>/); // user text is escaped diff --git a/todo.md b/todo.md index ec630fc..e87ada6 100644 --- a/todo.md +++ b/todo.md @@ -137,4 +137,14 @@ everything via Docker. ## 10. User added stuff - [x] The dashboard, the first landing page after logging in, should be gated to only logged in users. It should also be replaceable fully from a plugin. It is important that the ergonomics for the plugin writer is great. → **Two replaceable landing slots** (per the human follow-up: `/` public, `/dashboard` gated): `/` is now an **ungated public landing** (default `views/home.ejs`: brand + a short "what plainpages is" intro + prominent **Log in**/`/login` & **Create account**/`/registration` links, or **Go to your dashboard** when already signed in), and `/dashboard` is the **gated post-login app home** (anonymous → `/login?return_to=/dashboard` via `loginRedirect`; default = the mock-data People list). Both **fully replaceable by a plugin** via two optional `RouteHandler`s on `PluginManifest` (`src/plugin.ts`) — `home?` (public `/`) and `dashboard?` (gated `/dashboard`), the most ergonomic shape (same signature as any route). The host renders each against the plugin's own `views/` with the native shell via `ctx.chrome` (full parity with a plugin route: HEAD, `void`-return, response hooks, fresh CSRF cookie); a `home` handler is public so `ctx.user` may be null. **Single-slot, loud:** `findConflicts` errors when >1 plugin claims either slot (new `"home"`/`"dashboard"` conflict kinds), `discovery.shapeError` rejects a non-function handler, and `"dashboard"` is added to `RESERVED_PLUGIN_IDS` so a plugin folder can't shadow the built-in route (`/` can't be shadowed — route paths always carry the `/<id>` prefix). Post-login + already-signed-in redirects now target `/dashboard`; the global "Dashboard"/"People" nav hrefs moved `/`→`/dashboard` (chrome/admin-nav/dashboard). Tests-first (348 units, was 338): `app.test.ts` public-`/` (anon 200 + login/register, no gate) + gated-`/dashboard` (anon→303 return_to) + dual plugin-override; `plugin.test.ts` per-slot conflict; `discovery.test.ts` non-function + reserved-id + two-owners + valid-load. Docs: `docs/plugin-contract.md` ("The landing pages (home & dashboard)" section + manifest/conflict/reserved updates), README. **E2E:** the Ory-free `visual.spec.ts` plants a dev-signed session for the `/dashboard` design-system tests + a cookie-free public-`/` landing test (login/register links, screenshot verified); `full-flow.spec.ts` repointed its app-shell navigations to `/dashboard`; all five e2e healthchecks moved off the (now-public-but-formless) `/` to the auth-free `/public/css/styles.css`. stability-reviewer on the prod diff (both iterations): **APPROVE, no Critical/High/Medium** (gate moved correctly + stays closed, open-redirect-safe, public `/` prints no protected data, no shadowing either direction, render-branch parity). typecheck + 348 units + visual (10) + full-flow (7) E2E green, stacks torn down. -- [x] Make some pages optionally available publicly. A plugin should be able to set the permissions of a page (including the menu option) to publicly available. → A no-permission route/nav node is already anonymous-reachable; this **blesses** it as a first-class, explicit choice (per human pick: keep that default, add an explicit alias — not a secure-by-default flip). New optional **`public?: boolean`** on `Route` (`src/plugin.ts`) and `NavNode` (`src/nav.ts`) means "open to everyone, signed in or not" — honored explicitly in `isAuthorized` (`router.ts`) + `filterByRoles` (`nav.ts`), and **mutually exclusive with `permission`** (discovery `shapeError` recursively rejects a route/nav node setting both → fails the boot loud). `public` is filter-only (`toRenderNode` never emits it). The shell (`views/partials/shell.ejs`) now renders a **Sign in** link instead of the profile/sign-out block for an anonymous visitor, so a public page in the native shell (`ctx.chrome`, `ctx.user` may be null) isn't a broken "Guest / Sign out". Reference plugin demos it: a public `/scheduling` **Overview** route + public "Overview" nav child, with the shifts list still behind `scheduling:read` (so the "Scheduling" header now shows for everyone, the data doesn't). Hardened a latent gap the shell newly leans on: `claimsToUser` now rejects an **empty** email like it does an empty sub. Tests-first (348 → 354 units): router/nav/discovery (`public` open + reject-both + loads), shell (anonymous → Sign in, no logout form), app (public route anon-200), shifts (overview handler), jwt-middleware (empty email). Docs: `docs/plugin-contract.md` ("Public pages & menu items" + route shape + shape-error note) + README (menu system + reference snippet). E2E: `visual.spec` asserts the public Overview is anon-200 + shown in the member's nav while the gated Shifts redirects/filters. stability-reviewer: **APPROVE, no Critical/High/Medium** (addressed its one Low — the empty-email hardening). typecheck + 354 units + full `scripts/ci.sh` gate (visual 10 · auth 1 · oauth 2 · full 7) green. \ No newline at end of file +- [x] Make some pages optionally available publicly. A plugin should be able to set the permissions of a page (including the menu option) to publicly available. → A no-permission route/nav node is already anonymous-reachable; this **blesses** it as a first-class, explicit choice (per human pick: keep that default, add an explicit alias — not a secure-by-default flip). New optional **`public?: boolean`** on `Route` (`src/plugin.ts`) and `NavNode` (`src/nav.ts`) means "open to everyone, signed in or not" — honored explicitly in `isAuthorized` (`router.ts`) + `filterByRoles` (`nav.ts`), and **mutually exclusive with `permission`** (discovery `shapeError` recursively rejects a route/nav node setting both → fails the boot loud). `public` is filter-only (`toRenderNode` never emits it). The shell (`views/partials/shell.ejs`) now renders a **Sign in** link instead of the profile/sign-out block for an anonymous visitor, so a public page in the native shell (`ctx.chrome`, `ctx.user` may be null) isn't a broken "Guest / Sign out". Reference plugin demos it: a public `/scheduling` **Overview** route + public "Overview" nav child, with the shifts list still behind `scheduling:read` (so the "Scheduling" header now shows for everyone, the data doesn't). Hardened a latent gap the shell newly leans on: `claimsToUser` now rejects an **empty** email like it does an empty sub. Tests-first (348 → 354 units): router/nav/discovery (`public` open + reject-both + loads), shell (anonymous → Sign in, no logout form), app (public route anon-200), shifts (overview handler), jwt-middleware (empty email). Docs: `docs/plugin-contract.md` ("Public pages & menu items" + route shape + shape-error note) + README (menu system + reference snippet). E2E: `visual.spec` asserts the public Overview is anon-200 + shown in the member's nav while the gated Shifts redirects/filters. stability-reviewer: **APPROVE, no Critical/High/Medium** (addressed its one Low — the empty-email hardening). typecheck + 354 units + full `scripts/ci.sh` gate (visual 10 · auth 1 · oauth 2 · full 7) green. + +### 10.1 Unify the chrome (one menu, one shell, everywhere) — human follow-up +Decisions taken with the human: the menu must be the **exact same** sidebar everywhere — signed in or out — just role-filtered to clearance, collapsing to a burger on narrow screens (the existing `partials/shell.ejs`). Build order: unify the nav builders, then wrap the auth/landing pages in the shell, then the disable-menu opt-out, then the dashboard text. +- [x] **One menu everywhere — collapse the three nav builders into `buildPluginChrome`.** → `buildPluginChrome` (`ctx.chrome.nav`) is now the single menu source. Deleted `adminNav` + the dashboard's bespoke `nav()`; the 4 admin screens + dashboard take `nav` from `ctx.chrome.nav` (threaded into each model builder; `buildConfirmModel` takes `nav`, not `current`). `markCurrent` now resolves the **best (longest) href prefix** (`bestHref`) so `/admin/users/new` marks + opens the Users leaf. Tests-first: chrome sub-path marking, admin-nav (adminNav removed, buildConfirmModel passthrough); the 41 app HTTP integration tests stay green (the admin nav is unchanged output for an admin, just sourced once). +- [x] **Same shell on the login / registration / recovery / verification / settings / front (`/`) pages.** → `auth.ejs` + `home.ejs` now render inside `partials/shell.ejs` (new `partials/landing-body.ejs`), loading `auth.css` via the shell `styles` slot; `app.ts` passes `ctx.chrome` to both (+ sets the CSRF cookie so the shell's Sign-out form on `/settings` works). The shell gained **`docTitle`** (the `<title>`) separate from **`title`** (the topbar `<h1>`, omitted when empty) so the auth-card / landing hero owns the page's single `<h1>`; `.shell-auth` centers the card/intro in the content area. Failure pages (403/404/500/503/`/error`) stay standalone by design. Review fix: **`hideSignIn`** suppresses the footer Sign-in on the auth pages (a Sign-in on `/login` only looped to `/login`). Tests: shell (docTitle/empty-title, hideSignIn), app (`/` now in the shell). Validated live by the visual + full-flow E2E. +- [x] **A page can disable the menu.** → `partials/shell.ejs` `menu` local (default true); `menu:false` drops the sidebar + the mobile toggle and switches `.app` → `.app-bare` (single column). Documented in `docs/plugin-contract.md` (the `ctx.chrome` section). shell.test covers it. +- [x] **Dashboard demo → instructional starter text.** → Replaced the mock People list with a short "Starter dashboard" page (`views/index.ejs`): what `/dashboard` is, a `definePlugin({ dashboard })` snippet, and a primary "Browse the example plugin" CTA. `buildDashboardModel` is now `{ nav, shell }` (nav from `ctx.chrome`); dropped the mock dataset/filter/table/pager. dashboard.test rewritten; app + visual E2E assertions updated to the instructional page. + +### 10.2 Custom email templates — human follow-up +- [x] **Make the recovery/verification emails customizable.** → **First built** a web-renders-EJS pipeline (Kratos `delivery_strategy: http` → a bearer-guarded `/internal/courier` → web renders an EJS email, plugin-overridable, → nodemailer), proven end-to-end. → **Then reverted it** on the human's call: Kratos has **built-in** courier template overrides (`courier.template_override_path` + per-type `.gotmpl` files), so the web pipeline added a dependency (nodemailer), an internal endpoint, web→SMTP sending, and a web-availability coupling to buy only EJS-vs-Go-templates — against the project's "delegate to Ory / few deps / stateless" priorities. Final state: Kratos renders + sends mail (dev → mailpit, prod → `COURIER_SMTP_CONNECTION_URI`) exactly as before; customization is **operator config** via Kratos' native override, **documented** in the README **Email** section (with the template-type layout + a link to Ory's courier-template docs). Removed: `src/courier.ts`, the endpoint, the nodemailer dep, `views/emails/`, `Plugin.emails`/the email conflict + discovery scan + reserved `internal`, the `SMTP_URL`/`EMAIL_FROM`/`COURIER_SECRET` config, the Kratos http-delivery + `body.jsonnet`, and the email E2E. §10.1 (unified menu/shell) is unaffected. typecheck + **359 units** + visual(9)/auth(1)/oauth(2)/full-flow(7) E2E green. \ No newline at end of file diff --git a/views/auth.ejs b/views/auth.ejs index 2151c2f..aeaf78e 100644 --- a/views/auth.ejs +++ b/views/auth.ejs @@ -1,40 +1,33 @@ <%# - Themed Kratos self-service page (todo §4): sign-in / register / reset / verify / settings. - Renders a FlowView (src/flow-view.ts) into the html-css-foundation auth layout, reusing the - auth-card + field partials. The form posts straight to flow.ui.action — Kratos owns its CSRF. - Auto theme follows the OS (styles.css), so no theme switch is shown here. + Themed Kratos self-service page (todo §4) inside the unified app shell (§10): sign-in / register / + reset / verify / settings. Renders a FlowView (src/flow-view.ts) into the shell content, reusing the + auth-card + field partials. The form posts straight to flow.ui.action — Kratos owns its CSRF. The + shell's menu is role-filtered (anonymous ⇒ public items + Sign in); the topbar carries no heading, + so the card's own <h1> is the page's single heading. Data: chrome (PageChrome), flow (FlowView). %><% - const brand = locals.brand || "Plainpages"; - const body = include("partials/flow-body", { flow }); -%><!doctype html> -<html lang="en"> - <head> - <meta charset="utf-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1" /> - <title><%= flow.title %> - - - - - - <%- include("partials/icons") %> -
-
-
- - <%= brand %> -
- <%- include("partials/auth-card", { - action: flow.action, - alt: flow.alt, - back: flow.back, - body, - method: flow.method, - sso: { providers: flow.sso }, - sub: flow.sub, - title: flow.title, - }) %> -
-
- - + const nav = include("partials/nav-tree", { nodes: chrome.nav }); + const card = include("partials/auth-card", { + action: flow.action, + alt: flow.alt, + back: flow.back, + body: include("partials/flow-body", { flow }), + method: flow.method, + sso: { providers: flow.sso }, + sub: flow.sub, + title: flow.title, + }); + const body = `
${card}
`; +-%> +<%- include("partials/shell", { + body, + brand: chrome.brand, + csrfToken: chrome.csrfToken, + docTitle: flow.title, + hideSignIn: true, + nav, + signInHref: chrome.signInHref, + styles: ["/public/css/auth.css"], + theme: chrome.theme, + title: "", + user: chrome.user, +}) %> diff --git a/views/home.ejs b/views/home.ejs index 668612f..9529d33 100644 --- a/views/home.ejs +++ b/views/home.ejs @@ -1,43 +1,21 @@ <%# - Public landing page (todo §10): the ungated "/", what an anonymous visitor sees. A brief intro to - the product and prominent paths to sign in / register — or to the app dashboard when already signed - in. Standalone (no app shell — the sidebar/menu belong to the signed-in app). A plugin may replace - this via its `home` handler. Auto theme follows the OS (styles.css). Data: brand, user (or null). + Public landing page (todo §10): the ungated "/", rendered inside the unified app shell so the menu + shows (role-filtered — anonymous ⇒ public items + Sign in). A brief intro + a prominent way in, or a + dashboard link when already signed in. A plugin may replace this via its `home` handler. + Data: chrome (PageChrome), user (or null). %><% - const brand = locals.brand || "Plainpages"; -%> - - - - - <%= brand %> - - - - - - <%- include("partials/icons") %> -
-
-
- - <%= brand %> -
-

Operational web apps, without the boilerplate.

-

- <%= brand %> is a self-hostable foundation for admin and operational UIs — sign-in, - a config-driven menu, and a server-rendered, zero-JS design system. You add the - domain-specific screens by dropping in plugin folders. -

-
- <% if (locals.user) { %> - Go to your dashboard - <% } else { %> - Log in - Create account - <% } %> -
-
-
- - + const nav = include("partials/nav-tree", { nodes: chrome.nav }); + const body = include("partials/landing-body", { brand: chrome.brand.name, signedIn: !!locals.user }); +-%> +<%- include("partials/shell", { + body, + brand: chrome.brand, + csrfToken: chrome.csrfToken, + docTitle: chrome.brand.name, + nav, + signInHref: chrome.signInHref, + styles: ["/public/css/auth.css"], + theme: chrome.theme, + title: "", + user: chrome.user, +}) %> diff --git a/views/index.ejs b/views/index.ejs index fb4fad7..f11694e 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -1,27 +1,39 @@ <%# - Dashboard (todo §1): the app-shell "People" list. Composes the building-block partials around - the shell using the view model from src/dashboard.ts. EJS `include()` returns the rendered - string, so each partial is captured and handed to the shell as a slot. The filter form, - sortable headers and pager are real GET links — q/sort/page round-trip the URL, zero-JS. + Dashboard (todo §10): the gated post-login app home. The built-in default is a short instructional + starter — what /dashboard is and how to replace it from a plugin. Composes the app shell (the one + global menu via model.nav) around the prose. A plugin owns the real dashboard via a `dashboard` + handler; this renders until then. Data: model { nav, shell }. %><% const nav = include("partials/nav-tree", { nodes: model.nav }); - // The default home is a demo over mock data (its rows/actions/links are illustrative, not wired) — - // say so up front so the inert affordances read as a starter to replace, not a half-built product. - const note = include("partials/alert", { text: "The built-in demo home over mock data. Replace it by exporting a dashboard handler from a plugin.", title: "Starter dashboard" }); - const filters = include("partials/filter-bar", model.filterBar); - const table = include("partials/data-table", model.table); - const pager = include("partials/pagination", model.pagination); - const actions = - '' + - ''; + const body = ` +
+
+

Starter dashboard

+

This is the built-in /dashboard — the gated home shown to a signed-in user. + It's a placeholder so a fresh clone has something here; it holds no real data.

+

Replace it from a plugin: export a dashboard handler from your plugin's + manifest and it owns this page, rendered against your own views with the native app shell + (the same menu you see now) via ctx.chrome.

+
export default definePlugin({
+  apiVersion: "1.0.0",
+  // view names plugins/<id>/views/<view>.ejs, rendered in this same shell
+  dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
+});
+

See the plugin contract in docs/plugin-contract.md (the landing-pages section) + and the bundled plugins/scheduling/ reference.

+ +
+
`; -%> <%- include("partials/shell", { - actions, - body: note + filters + table + pager, + body, brand: model.shell.brand, breadcrumbs: model.shell.breadcrumbs, csrfToken: model.shell.csrfToken, nav, + signInHref: model.shell.signInHref, theme: model.shell.theme, title: model.shell.title, user: model.shell.user, diff --git a/views/partials/landing-body.ejs b/views/partials/landing-body.ejs new file mode 100644 index 0000000..dd59d2d --- /dev/null +++ b/views/partials/landing-body.ejs @@ -0,0 +1,13 @@ +<%# + Public landing body (§10): hero + intro + a prominent way in (or a dashboard link when signed in). + Rendered into the app-shell content. Locals: brand (name), signedIn (bool). +%>
+
+

Operational web apps, without the boilerplate.

+

<%= brand %> is a self-hostable foundation for admin and operational UIs — sign-in, a config-driven menu, and a server-rendered, zero-JS design system. You add the domain-specific screens by dropping in plugin folders.

+
+ <% if (signedIn) { %>Go to your dashboard + <% } else { %>Log inCreate account<% } %> +
+
+
diff --git a/views/partials/shell.ejs b/views/partials/shell.ejs index 28c10cf..3598d5d 100644 --- a/views/partials/shell.ejs +++ b/views/partials/shell.ejs @@ -1,15 +1,21 @@ <%# - App shell: sidebar (brand + nav slot + footer) · topbar · content slot. + App shell: sidebar (brand + nav slot + footer) · topbar · content slot. The one chrome every page + renders (§10) — dashboard, admin, plugin, login/registration/front — so the menu is identical + everywhere, role-filtered to the visitor (anonymous ⇒ public items + Sign in). Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial), `actions` (topbar buttons), `body` (page content); `styles` is an optional array of - extra stylesheet hrefs (e.g. a plugin's own /public//x.css). Text locals: `title`, `brand` - ({ name, logo?, sub? } — logo image else the default mark), `theme` (default for the - theme-switch), `user`, `breadcrumbs`, `csrfToken` (the Sign-out POST form's hidden field), - `signInHref` (anonymous "Sign in" target, carries return_to; default /login). - Branding comes from config/menu.ts; `user`/`csrfToken` from §4 auth. + extra stylesheet hrefs (e.g. a plugin's own /public//x.css). Text locals: `title` + (topbar heading; empty ⇒ the page body owns the single

, e.g. login/landing), `docTitle` + (the tag; defaults to title or the brand), `brand` ({ name, logo?, sub? }), `theme` + (theme-switch default), `user`, `breadcrumbs`, `csrfToken` (the Sign-out form's hidden field), + `signInHref` (anonymous "Sign in" target; default /login). `menu` (default true) — set false to + drop the sidebar and render a focused single-column page (§10). %><% - const title = locals.title || "Plainpages"; const brand = locals.brand || { name: "Plainpages" }; + const title = locals.title || ""; // topbar heading; empty ⇒ no topbar <h1> (the body owns it) + const docTitle = locals.docTitle || title || brand.name; + const menu = locals.menu !== false; // sidebar shown by default; a page may opt out + const hideSignIn = locals.hideSignIn === true; // the auth pages are already a way in — no footer Sign-in there const user = locals.user || { name: "Guest", initials: "G", email: "" }; const breadcrumbs = locals.breadcrumbs || []; const nav = locals.nav || ""; @@ -21,7 +27,7 @@ <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> - <title><%= title %> + <%= docTitle %> <% styles.forEach((href) => { %> <% }) %> @@ -29,10 +35,13 @@ <%- include("icons") %> + <% if (menu) { %> + <% } %> -
+