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

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

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

Dashboard is a replaceable instructional starter (definePlugin snippet, no mock data). Email stays delegated to Kratos: documented its built-in courier.template_override_path instead of adding web-side SMTP.
This commit is contained in:
2026-06-23 21:26:00 +02:00
parent af097a8885
commit e22d24aa8a
24 changed files with 377 additions and 529 deletions
+15
View File
@@ -32,6 +32,21 @@ commands and layout.
that way as it grows (never serialise on shared state); parallelism is what keeps it 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`. 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 ## Docker only — no host tooling
**Everything** (install, typecheck, test, run, build, deploy) goes through Docker / **Everything** (install, typecheck, test, run, build, deploy) goes through Docker /
+31 -3
View File
@@ -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 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. 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 ## Building blocks
Plainpages is a **component library, not a page generator** — you assemble pages from partials 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/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/context.ts RequestContext handed to handlers + buildContext()
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot 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-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-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-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-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/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/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/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/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 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/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/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) 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) 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) 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) 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)
+5
View File
@@ -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 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/`.) 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), **`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`, §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` metadata values are string/number/boolean), and **`ctx.log.fetch(url, init?)`** — a drop-in `fetch`
+14 -22
View File
@@ -37,12 +37,10 @@ test.beforeEach(async ({ context }) => {
test("captures live pages + reference mockups for side-by-side review", async ({ page }) => { test("captures live pages + reference mockups for side-by-side review", async ({ page }) => {
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.locator(".sidebar")).toBeVisible(); 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 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.goto("/dashboard");
await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio
await shot(page, "live-03-dark"); 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(); const ref = await context.newPage();
await ref.goto(APP_SHELL); 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)); expect(await styleOf(page, selector), `computed style mismatch for ${selector}`).toEqual(await styleOf(ref, selector));
} }
await ref.close(); await ref.close();
@@ -89,20 +91,9 @@ test("every icon <use> resolves to a defined <symbol> (no broken graphics)", asy
expect(missing).toEqual([]); expect(missing).toEqual([]);
}); });
test("sorting and search drive the list through the URL (zero-JS)", async ({ page }) => { // (The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component
await page.goto("/dashboard"); // (list-query/data-table/filter-bar) and exercised live with real data by the full-flow E2E's admin
const total = await page.locator("tbody tr").count(); // Users list. The mock-data dashboard that used to host it in this Ory-free suite is gone (§10).)
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);
});
test("theme switch flips the palette with no JavaScript", async ({ page }) => { test("theme switch flips the palette with no JavaScript", async ({ page }) => {
await page.goto("/dashboard"); 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 }) => { 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 context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
await page.goto("/"); await page.goto("/");
await expect(page.locator(".landing")).toBeVisible(); // the standalone landing, not the app shell await expect(page.locator(".landing")).toBeVisible();
await expect(page.locator(".sidebar")).toHaveCount(0); // §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: "Log in" })).toHaveAttribute("href", "/login");
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration"); await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
await shot(page, "live-05-public-landing"); 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, // The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav,
// but the gated Shifts leaf is filtered out. // but the gated Shifts leaf is filtered out.
await page.goto("/dashboard"); 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"]')).toHaveCount(1); // public Overview shown
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
}); });
+6
View File
@@ -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 { color: var(--neg); border-color: var(--neg-bd); }
.btn-danger:hover { background: var(--neg-bg); } .btn-danger:hover { background: var(--neg-bg); }
.recovery-code code { font-size: 1.15rem; font-weight: 600; letter-spacing: 0.04em; } .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; }
+13 -8
View File
@@ -5,13 +5,14 @@
// PRG redirect (mirrors the Users "trigger recovery" one-time code). `handleAdminClients` is the // PRG redirect (mirrors the Users "trigger recovery" one-time code). `handleAdminClients` is the
// imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded. // 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 { safeDecode } from "./admin-groups.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
import type { RequestContext, User } from "./context.ts"; import type { RequestContext, User } from "./context.ts";
import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts"; import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts";
import { parseListQuery } from "./list-query.ts"; import { parseListQuery } from "./list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { paginate } from "./paginate.ts"; import { paginate } from "./paginate.ts";
import type { RouteResult } from "./plugin.ts"; import type { RouteResult } from "./plugin.ts";
import { buildShellContext } from "./shell-context.ts"; import { buildShellContext } from "./shell-context.ts";
@@ -110,6 +111,7 @@ export function buildClientsListModel(opts: {
clients: OAuth2Client[]; clients: OAuth2Client[];
csrfToken?: string; csrfToken?: string;
menu?: MenuConfig; menu?: MenuConfig;
nav?: NavNode[];
url: URL | URLSearchParams | string; url: URL | URLSearchParams | string;
user?: User | null; user?: User | null;
}) { }) {
@@ -127,7 +129,7 @@ export function buildClientsListModel(opts: {
return { return {
filterBar: listFilterBar(state), filterBar: listFilterBar(state),
nav: adminNav(opts.user?.roles ?? [], menu, "clients"), nav: opts.nav ?? [],
pagination: listPagination(state, page), pagination: listPagination(state, page),
shell: buildShellContext({ shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }], breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }],
@@ -192,6 +194,7 @@ export function buildClientFormModel(opts: {
csrfToken?: string; csrfToken?: string;
error?: string; error?: string;
menu?: MenuConfig; menu?: MenuConfig;
nav?: NavNode[];
user?: User | null; user?: User | null;
values?: Partial<ClientInput>; values?: Partial<ClientInput>;
}) { }) {
@@ -217,7 +220,7 @@ export function buildClientFormModel(opts: {
scopeField, scopeField,
submitLabel: "Register client", submitLabel: "Register client",
}, },
nav: adminNav(opts.user?.roles ?? [], menu, "clients"), nav: opts.nav ?? [],
shell: buildShellContext({ shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }], breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }],
csrfToken: opts.csrfToken ?? "", csrfToken: opts.csrfToken ?? "",
@@ -233,6 +236,7 @@ export function buildClientDetailModel(opts: {
created?: boolean; // just registered → success banner + the one-time secret (if any) created?: boolean; // just registered → success banner + the one-time secret (if any)
csrfToken?: string; csrfToken?: string;
menu?: MenuConfig; menu?: MenuConfig;
nav?: NavNode[];
secret?: string; // one-time client_secret (confidential clients), shown once right after create secret?: string; // one-time client_secret (confidential clients), shown once right after create
user?: User | null; user?: User | null;
}) { }) {
@@ -243,7 +247,7 @@ export function buildClientDetailModel(opts: {
created: opts.created ?? false, created: opts.created ?? false,
csrfToken: opts.csrfToken ?? "", csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` }, delete: { action: `${base}/delete` },
nav: adminNav(opts.user?.roles ?? [], menu, "clients"), nav: opts.nav ?? [],
secret: opts.secret, secret: opts.secret,
shell: buildShellContext({ shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }], 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 user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
const { hydra, menu, render } = deps; 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 method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_CLIENTS_BASE.length).split("/").filter(Boolean); 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 form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
const renderForm = async (extra: { error?: string; values?: Partial<ClientInput> }): Promise<RouteResult> => 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> => 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 }); const notFound = async (): Promise<RouteResult> => ({ html: await render("404", { title: "Not found" }), status: 404 });
// /admin/clients — list (GET) · register (POST) // /admin/clients — list (GET) · register (POST)
if (seg.length === 0) { if (seg.length === 0) {
if (method === "GET") { if (method === "GET") {
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE }); 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") { if (method === "POST") {
const input = readClientInput(form!); const input = readClientInput(form!);
@@ -335,7 +340,7 @@ export async function handleAdminClients(ctx: RequestContext, csrfToken: string,
return { html: await render("admin/confirm", { model: buildConfirmModel({ return { html: await render("admin/confirm", { model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }], breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client", csrfToken, 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") { if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
+13 -8
View File
@@ -6,13 +6,14 @@
// is the imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded, mapping each action // is the imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded, mapping each action
// to a RouteResult. // 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 { FieldConfig } from "./admin-users.ts";
import type { RequestContext, User } from "./context.ts"; import type { RequestContext, User } from "./context.ts";
import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "./keto-client.ts"; import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts"; import type { KratosAdmin } from "./kratos-admin.ts";
import { parseListQuery } from "./list-query.ts"; import { parseListQuery } from "./list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { paginate } from "./paginate.ts"; import { paginate } from "./paginate.ts";
import type { RouteResult } from "./plugin.ts"; import type { RouteResult } from "./plugin.ts";
import { buildShellContext } from "./shell-context.ts"; import { buildShellContext } from "./shell-context.ts";
@@ -120,6 +121,7 @@ export function buildGroupsListModel(opts: {
csrfToken?: string; csrfToken?: string;
groups: GroupView[]; groups: GroupView[];
menu?: MenuConfig; menu?: MenuConfig;
nav?: NavNode[];
url: URL | URLSearchParams | string; url: URL | URLSearchParams | string;
user?: User | null; user?: User | null;
}) { }) {
@@ -147,7 +149,7 @@ export function buildGroupsListModel(opts: {
return { return {
filterBar: listFilterBar(state), filterBar: listFilterBar(state),
nav: adminNav(opts.user?.roles ?? [], menu, "groups"), nav: opts.nav ?? [],
pagination: listPagination(state, page), pagination: listPagination(state, page),
shell: buildShellContext({ shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Admin" }, { label: "Groups" }], breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Admin" }, { label: "Groups" }],
@@ -214,6 +216,7 @@ export function buildGroupFormModel(opts: {
error?: string; error?: string;
memberOptions: MemberOption[]; memberOptions: MemberOption[];
menu?: MenuConfig; menu?: MenuConfig;
nav?: NavNode[];
user?: User | null; user?: User | null;
values?: { member?: string; name?: string }; values?: { member?: string; name?: string };
}) { }) {
@@ -233,7 +236,7 @@ export function buildGroupFormModel(opts: {
selectedMember: opts.values?.member ?? "", selectedMember: opts.values?.member ?? "",
submitLabel: "Create group", submitLabel: "Create group",
}, },
nav: adminNav(opts.user?.roles ?? [], menu, "groups"), nav: opts.nav ?? [],
shell: buildShellContext({ shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: "New" }], breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: "New" }],
csrfToken: opts.csrfToken ?? "", csrfToken: opts.csrfToken ?? "",
@@ -251,6 +254,7 @@ export function buildGroupDetailModel(opts: {
group: { name: string }; group: { name: string };
members: MemberView[]; members: MemberView[];
menu?: MenuConfig; menu?: MenuConfig;
nav?: NavNode[];
user?: User | null; user?: User | null;
}) { }) {
const menu = opts.menu ?? DEFAULT_MENU; const menu = opts.menu ?? DEFAULT_MENU;
@@ -266,7 +270,7 @@ export function buildGroupDetailModel(opts: {
error: opts.error, error: opts.error,
group: { name }, group: { name },
members: { action: `${base}/members/delete`, rows: opts.members }, members: { action: `${base}/members/delete`, rows: opts.members },
nav: adminNav(opts.user?.roles ?? [], menu, "groups"), nav: opts.nav ?? [],
shell: buildShellContext({ shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: name }], breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: name }],
csrfToken: opts.csrfToken ?? "", 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 user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
const { keto, kratosAdmin, menu, render } = deps; 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 method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_GROUPS_BASE.length).split("/").filter(Boolean); 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 form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
const renderList = async (): Promise<RouteResult> => { const renderList = async (): Promise<RouteResult> => {
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS })); 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 renderForm = async (extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(keto, kratosAdmin); 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 renderDetail = async (name: string): Promise<RouteResult> => {
const { emailById, options } = await memberCandidates(keto, kratosAdmin); const { emailById, options } = await memberCandidates(keto, kratosAdmin);
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById)); 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) // /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({ return { html: await render("admin/confirm", { model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }], breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete group", csrfToken, 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") { if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
+7 -13
View File
@@ -6,7 +6,7 @@ import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net"; import { Socket } from "node:net";
import { test } from "node:test"; import { test } from "node:test";
import { import {
ADMIN_PERMISSION, ADMIN_USERS_BASE, adminNav, adminSection, buildConfirmModel, guardedForm, requireAdmin, ADMIN_PERMISSION, ADMIN_USERS_BASE, adminSection, buildConfirmModel, guardedForm, requireAdmin,
} from "./admin-nav.ts"; } from "./admin-nav.ts";
import { buildContext, type RequestContext, type User } from "./context.ts"; import { buildContext, type RequestContext, type User } from "./context.ts";
import { CSRF_COOKIE, CSRF_FIELD, issueCsrfToken } from "./csrf.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); 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)", () => { // (The in-screen admin sidebar is gone in §10 — every page renders the one global menu, built by
const forAdmin = adminNav(admin.roles, DEFAULT_MENU, "users"); // buildPluginChrome; see chrome.test.ts. adminSection above is that menu's gated Admin fragment.)
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
});
// ---- auth gates ---- // ---- auth gates ----
@@ -82,15 +75,16 @@ test("guardedForm: valid double-submit → the parsed body, bad/missing token
// ---- confirm-page model ---- // ---- 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({ const model = buildConfirmModel({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: "Delete" }], breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: "Delete" }],
cancelHref: ADMIN_USERS_BASE, confirmAction: `${ADMIN_USERS_BASE}/u1/delete`, confirmLabel: "Delete user", 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.deepEqual(model.confirm, { action: `${ADMIN_USERS_BASE}/u1/delete`, label: "Delete user" });
assert.equal(model.message, "Delete ada@x.io?"); assert.equal(model.message, "Delete ada@x.io?");
assert.equal(model.cancelHref, ADMIN_USERS_BASE); 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"); assert.equal(model.shell.title, "Delete user");
}); });
+13 -17
View File
@@ -1,15 +1,15 @@
// The built-in admin section of the menu (todo §5). One definition of the Users/Groups/Roles links // The built-in admin section of the menu (todo §5). `adminSection()` is the one definition of the
// + their gate, reused two ways so they can't drift: `adminSection()` is the permission-gated // permission-gated "Admin" header (Users/Groups/Roles/clients) + its gate, composed into the single
// "Admin" header wired into the global dashboard menu (composeNav drops the whole header + subtree // global menu (`buildPluginChrome`, §10) — composeNav drops the whole header + subtree for a
// for a non-admin), and `adminNav()` is the in-screen sidebar each admin screen renders (a link home // non-admin. Every page (dashboard, admin, plugin, auth) renders that one menu, so there's no
// + the same section, with the active item marked `current`). // separate admin sidebar to drift.
import { readFormBody } from "./body.ts"; import { readFormBody } from "./body.ts";
import type { RequestContext, User } from "./context.ts"; import type { RequestContext, User } from "./context.ts";
import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts"; import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts";
import { GuardError, loginRedirect } from "./guards.ts"; import { GuardError, loginRedirect } from "./guards.ts";
import { type MenuConfig } from "./menu-config.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"; import { buildShellContext } from "./shell-context.ts";
export const ADMIN_PERMISSION = "admin"; // role token gating the admin section 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"; export type AdminScreen = "clients" | "groups" | "roles" | "users";
// The "Dashboard" link to the gated app home (/dashboard). One definition, reused by the in-screen // The "Dashboard" link to the gated app home (/dashboard), composed into the global menu by
// admin sidebar and the plugin-page chrome (chrome.ts) so the two can't drift. It targets a gated // buildPluginChrome (chrome.ts). It targets a gated route, so the chrome hides it from anonymous
// route, so the chrome hides it from anonymous visitors (a non-signed-in click only dead-ends at /login). // 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" }; export const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" };
const ITEMS: { href: string; icon: string; id: AdminScreen; label: string }[] = [ 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 // 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. // (anonymous → /login, non-admin → 403). Returns the (non-null) user for the handler to thread on.
export function requireAdmin(ctx: RequestContext): User { 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 // 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: { export function buildConfirmModel(opts: {
breadcrumbs: { href?: string; label: string }[]; breadcrumbs: { href?: string; label: string }[];
cancelHref: string; cancelHref: string;
confirmAction: string; confirmAction: string;
confirmLabel: string; confirmLabel: string;
csrfToken: string; csrfToken: string;
current: AdminScreen;
menu: MenuConfig; menu: MenuConfig;
message: string; message: string;
nav: NavNode[];
title: string; title: string;
user: User | null; user: User | null;
}) { }) {
@@ -87,7 +83,7 @@ export function buildConfirmModel(opts: {
cancelHref: opts.cancelHref, cancelHref: opts.cancelHref,
confirm: { action: opts.confirmAction, label: opts.confirmLabel }, confirm: { action: opts.confirmAction, label: opts.confirmLabel },
message: opts.message, 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 }), shell: buildShellContext({ breadcrumbs: opts.breadcrumbs, csrfToken: opts.csrfToken, menu: opts.menu, title: opts.title, user: opts.user }),
}; };
} }
+13 -8
View File
@@ -8,7 +8,7 @@
// Kratos is read only to label members. `handleAdminRoles` is the imperative shell app.ts dispatches // Kratos is read only to label members. `handleAdminRoles` is the imperative shell app.ts dispatches
// to — gated admin-only, CSRF-guarded. // 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 { import {
type GroupView, type GroupView,
groupsFromTuples, groupsFromTuples,
@@ -27,6 +27,7 @@ import type { ExpandTree, KetoClient, RelationTuple } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts"; import type { KratosAdmin } from "./kratos-admin.ts";
import { parseListQuery } from "./list-query.ts"; import { parseListQuery } from "./list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { paginate } from "./paginate.ts"; import { paginate } from "./paginate.ts";
import type { RouteResult } from "./plugin.ts"; import type { RouteResult } from "./plugin.ts";
import { buildShellContext } from "./shell-context.ts"; import { buildShellContext } from "./shell-context.ts";
@@ -105,6 +106,7 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
export function buildRolesListModel(opts: { export function buildRolesListModel(opts: {
csrfToken?: string; csrfToken?: string;
menu?: MenuConfig; menu?: MenuConfig;
nav?: NavNode[];
roles: RoleView[]; roles: RoleView[];
url: URL | URLSearchParams | string; url: URL | URLSearchParams | string;
user?: User | null; user?: User | null;
@@ -133,7 +135,7 @@ export function buildRolesListModel(opts: {
return { return {
filterBar: listFilterBar(state), filterBar: listFilterBar(state),
nav: adminNav(opts.user?.roles ?? [], menu, "roles"), nav: opts.nav ?? [],
pagination: listPagination(state, page), pagination: listPagination(state, page),
shell: buildShellContext({ shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }], breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }],
@@ -200,6 +202,7 @@ export function buildRoleFormModel(opts: {
error?: string; error?: string;
memberOptions: MemberOption[]; memberOptions: MemberOption[];
menu?: MenuConfig; menu?: MenuConfig;
nav?: NavNode[];
user?: User | null; user?: User | null;
values?: { member?: string; name?: string }; values?: { member?: string; name?: string };
}) { }) {
@@ -219,7 +222,7 @@ export function buildRoleFormModel(opts: {
selectedMember: opts.values?.member ?? "", selectedMember: opts.values?.member ?? "",
submitLabel: "Create role", submitLabel: "Create role",
}, },
nav: adminNav(opts.user?.roles ?? [], menu, "roles"), nav: opts.nav ?? [],
shell: buildShellContext({ shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }], breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }],
csrfToken: opts.csrfToken ?? "", csrfToken: opts.csrfToken ?? "",
@@ -237,6 +240,7 @@ export function buildRoleDetailModel(opts: {
error?: string; error?: string;
members: MemberView[]; members: MemberView[];
menu?: MenuConfig; menu?: MenuConfig;
nav?: NavNode[];
role: { name: string }; role: { name: string };
user?: User | null; user?: User | null;
}) { }) {
@@ -252,7 +256,7 @@ export function buildRoleDetailModel(opts: {
effective: opts.effective, effective: opts.effective,
error: opts.error, error: opts.error,
members: { action: `${base}/members/delete`, rows: opts.members }, members: { action: `${base}/members/delete`, rows: opts.members },
nav: adminNav(opts.user?.roles ?? [], menu, "roles"), nav: opts.nav ?? [],
role: { name }, role: { name },
shell: buildShellContext({ shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }], 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 user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
const { keto, kratosAdmin, menu, render } = deps; 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 method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_ROLES_BASE.length).split("/").filter(Boolean); 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 form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
const renderList = async (): Promise<RouteResult> => { const renderList = async (): Promise<RouteResult> => {
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS })); 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 renderForm = async (extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(keto, kratosAdmin); 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 renderDetail = async (name: string, error?: string): Promise<RouteResult> => {
const { emailById, options } = await memberCandidates(keto, kratosAdmin); const { emailById, options } = await memberCandidates(keto, kratosAdmin);
const tuples = await pagedTuples(keto, { namespace: ROLE_NS, object: name, relation: MEMBERS }); const tuples = await pagedTuples(keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
const members = tuples.map((t) => memberView(t, emailById)); const members = tuples.map((t) => memberView(t, emailById));
const effective = await effectiveUsers(keto, name, tuples.length > 0, 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 }; 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({ return { html: await render("admin/confirm", { model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }], breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role", csrfToken, 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") { if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
+10 -6
View File
@@ -5,12 +5,13 @@
// CSRF-guarded, each action mapped to a RouteResult (render, or redirect after a write — PRG). // CSRF-guarded, each action mapped to a RouteResult (render, or redirect after a write — PRG).
import { safeDecode } from "./admin-groups.ts"; 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 { RequestContext, User } from "./context.ts";
import type { Identity, KratosAdmin, RecoveryCode } from "./kratos-admin.ts"; import type { Identity, KratosAdmin, RecoveryCode } from "./kratos-admin.ts";
import { KratosError } from "./kratos-public.ts"; import { KratosError } from "./kratos-public.ts";
import { parseListQuery } from "./list-query.ts"; import { parseListQuery } from "./list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { paginate } from "./paginate.ts"; import { paginate } from "./paginate.ts";
import type { RouteResult } from "./plugin.ts"; import type { RouteResult } from "./plugin.ts";
import { buildShellContext } from "./shell-context.ts"; import { buildShellContext } from "./shell-context.ts";
@@ -118,6 +119,7 @@ export function buildUsersListModel(opts: {
csrfToken?: string; csrfToken?: string;
identities: Identity[]; identities: Identity[];
menu?: MenuConfig; menu?: MenuConfig;
nav?: NavNode[]; // the unified global menu (ctx.chrome.nav); the host builds it once per request
url: URL | URLSearchParams | string; url: URL | URLSearchParams | string;
user?: User | null; user?: User | null;
}) { }) {
@@ -145,7 +147,7 @@ export function buildUsersListModel(opts: {
return { return {
filterBar: listFilterBar(state, all.length), filterBar: listFilterBar(state, all.length),
nav: adminNav(opts.user?.roles ?? [], menu, "users"), nav: opts.nav ?? [],
pagination: listPagination(state, page), pagination: listPagination(state, page),
shell: buildShellContext({ shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Admin" }, { label: "Users" }], breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Admin" }, { label: "Users" }],
@@ -237,6 +239,7 @@ export function buildUserFormModel(opts: {
error?: string; error?: string;
identity?: Identity | null; identity?: Identity | null;
menu?: MenuConfig; menu?: MenuConfig;
nav?: NavNode[];
recovery?: RecoveryCode; recovery?: RecoveryCode;
user?: User | null; user?: User | null;
values?: Partial<UserInput>; values?: Partial<UserInput>;
@@ -267,7 +270,7 @@ export function buildUserFormModel(opts: {
} : undefined, } : undefined,
error: opts.error, error: opts.error,
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? "Save changes" : "Create user" }, 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, recovery: opts.recovery,
shell: buildShellContext({ shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: editing ? "Edit" : "New" }], 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 user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
const { kratosAdmin, menu, render } = deps; 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 method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_USERS_BASE.length).split("/").filter(Boolean); 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 form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
const renderList = async (): Promise<RouteResult> => { const renderList = async (): Promise<RouteResult> => {
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE }); 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> => 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) // /admin/users — list (GET) · create (POST)
if (seg.length === 0) { 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({ return { html: await render("admin/confirm", { model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }], breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }],
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: "Delete user", csrfToken, 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") { if (method === "POST") {
+11 -13
View File
@@ -48,20 +48,20 @@ before(async () => {
after(() => server.close()); 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. // The dashboard is gated to a signed-in user (§10), so present a session.
const res = await fetch(base + "/dashboard", { headers: { cookie: session() } }); const res = await fetch(base + "/dashboard", { headers: { cookie: session() } });
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/html/); assert.match(res.headers.get("content-type") ?? "", /text\/html/);
const html = await res.text(); 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, /Plainpages/); // sidebar brand
assert.match(html, /<aside class="sidebar"/); assert.match(html, /<aside class="sidebar"/);
assert.match(html, /<form class="filters"/); // The default is a short instructional starter, not a mock-data list.
assert.match(html, /<table class="table"/); assert.match(html, /Starter dashboard/);
assert.match(html, /<footer class="pager"/); assert.match(html, /export default definePlugin/); // shows how to replace it from a plugin
assert.match(html, /Avery Kline/); // a mock person on page 1 assert.doesNotMatch(html, /<form class="filters"/); // the old mock People list is gone
assert.match(html, /Starter dashboard/); // the default flags itself a demo to replace with a `dashboard` plugin (§10) assert.doesNotMatch(html, /Avery Kline/);
// The Sign-out POST form carries a CSRF token matching the Set-Cookie issued for the page (§4). // 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]; 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(res.headers.get("set-cookie") ?? "", /plainpages_csrf=[^;]+;.*HttpOnly/);
assert.match(html, /<form class="menu-item-form" method="post" action="\/logout">/); assert.match(html, /<form class="menu-item-form" method="post" action="\/logout">/);
assert.match(html, new RegExp(`name="_csrf" value="${csrfCookie!.replace(/[.]/g, "\\.")}"`)); 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" }); const res = await fetch(base + "/", { redirect: "manual" });
assert.equal(res.status, 200); // public — no redirect to sign in assert.equal(res.status, 200); // public — no redirect to sign in
const html = await res.text(); const html = await res.text();
assert.match(html, /href="\/login"/); // a prominent path to sign in assert.match(html, /href="\/login"/); // a prominent path to sign in
assert.match(html, /href="\/registration"/); // and to register 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 () => { test("/dashboard is gated (§10): an anonymous visitor is bounced to sign in (return_to kept)", async () => {
+10 -5
View File
@@ -324,7 +324,10 @@ export function createApp(options: AppOptions = {}): Server {
} }
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected) 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; return;
} }
@@ -484,8 +487,10 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data)); await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data));
return; return;
} }
// Default landing — no form, so no CSRF cookie. `user` lets it show "go to dashboard" vs sign in. // Default landing in the unified app shell (§10): `user` picks "go to dashboard" vs sign-in,
sendHtml(res, 200, await render("home", { brand: menu.branding.name, user })); // 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; return;
} }
@@ -504,8 +509,8 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(res, result, (view, data) => renderView(dashboardPlugin.id, view, data)); await sendResult(res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
return; return;
} }
// Roles from the verified JWT; branding/override come from config/menu.ts. // The one global menu (ctx.chrome.nav) + branding/override from config/menu.ts.
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu, csrf.token, user, plugins) })); sendHtml(res, 200, await render("index", { model: buildDashboardModel({ csrfToken: csrf.token, menu, nav: ctx.chrome.nav, user }) }));
return; return;
} }
+8 -3
View File
@@ -45,9 +45,14 @@ test("a permission holder sees the Dashboard link + plugin nav; current path ope
assert.equal(chrome.user.name, "ada"); // email local part assert.equal(chrome.user.name, "ada"); // email local part
}); });
test("an admin sees the gated admin section", () => { test("an admin sees the gated admin section; a sub-path marks its base leaf current", () => {
const chrome = buildPluginChrome({ menu: DEFAULT_MENU, user: { email: "a@b.c", id: "u1", roles: ["admin"] } }); const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, user: { email: "a@b.c", id: "u1", roles: ["admin"] } });
assert.ok(labels(chrome.nav).includes("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", () => { test("branding logo + default theme flow through when set", () => {
+27 -5
View File
@@ -37,7 +37,12 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
const roles = opts.user?.roles ?? []; const roles = opts.user?.roles ?? [];
const nav = composeNav(fragments, opts.menu.override, 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; const b = opts.menu.branding;
return { 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). // page is revealed. Mutates the freshly-composed nodes (composeNav returns new objects each call).
// Returns whether this subtree contains the current node. // 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; let hit = false;
for (const node of nodes) { for (const node of nodes) {
const here = node.href === path; const here = node.href === target;
const inChild = node.children ? markCurrent(node.children, path) : false; const inChild = node.children ? markCurrent(node.children, target) : false;
if (here) node.current = true; if (here) node.current = true;
if (here || inChild) { if (here || inChild) {
if (node.children) node.open = true; if (node.children) node.open = true;
+19 -105
View File
@@ -1,114 +1,28 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { buildDashboardModel } from "./dashboard.ts"; 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. // The default /dashboard is an instructional starter (todo §10): no mock data, just the unified
const rowCount = (m: ReturnType<typeof buildDashboardModel>): number => m.table.rows.length; // menu + shell. The host passes the one global menu (ctx.chrome.nav); the model passes it through.
const nameOf = (m: ReturnType<typeof buildDashboardModel>, i: number): string => const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
// 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 };
test("dashboard default: page 1, mock data, nav + shell wired", () => { test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
const m = buildDashboardModel(new URL("http://x/")); 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.title, "People"); assert.equal(m.shell.csrfToken, "tok.sig");
assert.equal(m.shell.csrfToken, ""); // default empty; app.ts passes the per-request token assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
assert.equal(buildDashboardModel(new URL("http://x/"), [], undefined, "tok.sig").shell.csrfToken, "tok.sig"); assert.deepEqual(m.nav, NAV); // the host's menu is used verbatim — the dashboard builds no nav of its own
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 search filters rows, shrinks the total and shows a pill", () => { test("dashboard model: sensible defaults (empty nav, no token, Guest) and branding from menu", () => {
const first = nameOf(buildDashboardModel(new URL("http://x/")), 0); // e.g. "Avery Kline" const m = buildDashboardModel();
const m = buildDashboardModel(new URL(`http://x/?q=${encodeURIComponent(first)}`)); 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); const branded = buildDashboardModel({ menu: { branding: { name: "Acme Ops", sub: "Admin", theme: "dark" }, override: {} } });
assert.equal(rowCount(m), 1); assert.equal(branded.shell.brand.name, "Acme Ops");
assert.equal(nameOf(m, 0), first); assert.equal(branded.shell.theme, "dark");
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"]);
}); });
+13 -205
View File
@@ -1,217 +1,25 @@
// Dashboard view model (todo §1): the gated "/dashboard" app-shell "People" list. Pure — turns a // Dashboard view model (todo §10): the gated "/dashboard" app home. By default a short instructional
// request URL into the data the building-block partials render, wiring the §1 helpers end-to-end: // starter — what the dashboard is and how to replace it by exporting a `dashboard` handler from a
// parseListQuery → filter/sort/paginate a mock dataset → composeNav. This is the built-in *demo* home // plugin (views/index.ejs holds the prose). A plugin owns the real one (PluginManifest.dashboard);
// over mock data (a plugin owns the real one via a `dashboard` handler, §10); the filter form, // this placeholder renders until then. Pure: `nav` is the one global menu (ctx.chrome.nav), built
// sortable headers and pager all round-trip the URL (zero-JS). // 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 type { User } from "./context.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode, type NavOverride } from "./nav.ts"; import type { NavNode } from "./nav.ts";
import type { Plugin } from "./plugin.ts";
import { parseListQuery } from "./list-query.ts";
import { paginate } from "./paginate.ts";
import { buildShellContext } from "./shell-context.ts"; import { buildShellContext } from "./shell-context.ts";
interface Person { export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; user?: User | null } = {}) {
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;
return { return {
id: `${first}-${last}`.toLowerCase(), nav: opts.nav ?? [],
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),
shell: buildShellContext({ shell: buildShellContext({
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }], breadcrumbs: [{ label: "Dashboard" }],
csrfToken, // hidden field for the shell's Sign-out POST form (§4) csrfToken: opts.csrfToken ?? "",
menu, menu: opts.menu ?? DEFAULT_MENU,
title: "People", title: "Dashboard",
user, // real signed-in identity (§4); anonymous ⇒ Guest user: opts.user ?? null,
}), }),
table: table(rows, state, sort),
}; };
} }
export type DashboardModel = ReturnType<typeof buildDashboardModel>; export type DashboardModel = ReturnType<typeof buildDashboardModel>;
// Sidebar: the demo "Directory" fragment, then each discovered plugin's own nav fragment (so a
// plugin is reachable from the dashboard; gated nodes stay invisible to non-admins), then the gated
// admin section. composeNav applies the central override + per-user role filter.
function nav(roles: string[], override: NavOverride, plugins: Plugin[]): NavNode[] {
const pluginFragments = plugins.filter((p) => p.nav?.length).map((p) => p.nav as NavNode[]);
return composeNav([[
{ count: PEOPLE.length, current: true, href: "/dashboard", icon: "i-users", id: "people", label: "People" },
{ href: "#teams", icon: "i-grid", id: "teams", label: "Teams" },
{ children: [
{ href: "#activity", id: "activity", label: "Activity" },
{ href: "#exports", id: "exports", label: "Exports" },
], icon: "i-chart", id: "reports", label: "Reports", open: true },
{ href: "#settings", icon: "i-gear", id: "settings", label: "Settings", permission: "admin" },
], ...pluginFragments, [
adminSection(), // built-in Users/Groups/Roles screens; gated → invisible to non-admins
]], override, roles);
}
function table(rows: Person[], state: State, sort: { dir: "asc" | "desc"; field: string } | null) {
return {
actions: true,
caption: "People",
columns: COLUMNS.map((c) => {
if (!c.sortable) return { label: c.label };
const dir = sort && sort.field === c.key ? sort.dir : undefined;
const next = dir === "asc" ? `-${c.key}` : c.key; // asc→desc, else→asc
return { href: href(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
}),
rows: rows.map((p) => ({
cells: [
{ user: { initials: p.initials, name: p.name } },
p.email,
p.role,
p.team,
{ badge: { label: cap(p.status), tone: TONE[p.status] } },
p.lastActive,
],
name: p.name,
actions: [
{ href: "#", icon: "i-user", label: "View" },
{ href: "#", icon: "i-edit", label: "Edit" },
{ danger: true, icon: "i-trash", label: "Deactivate", separatorBefore: true },
],
})),
selectable: true,
};
}
function filterBar(state: State) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: "Search", remove: href(state, { page: 1, q: "" }), value: state.q });
if (state.status !== "all") pills.push({ label: "Status", remove: href(state, { page: 1, status: "all" }), value: cap(state.status) });
if (state.team) pills.push({ label: "Team", remove: href(state, { page: 1, team: "" }), value: state.team });
return {
applyLabel: "Apply filters",
clearHref: "?",
label: "Filter people",
pills,
rows: [[
{ label: "Search people", name: "q", placeholder: "Search people…", type: "search", value: state.q },
{ legend: "Status", name: "status", options: [
{ count: PEOPLE.length, label: "All", value: "all" },
{ label: "Active", value: "active" },
{ label: "Invited", value: "invited" },
{ label: "Suspended", value: "suspended" },
], type: "segmented", value: state.status },
{ label: "Team", name: "team", options: [{ label: "All teams", value: "" }, ...TEAMS.map((t) => ({ label: t, value: t }))], type: "select", value: state.team },
{ type: "spacer" },
]],
};
}
function pagination(state: State, page: ReturnType<typeof paginate>) {
// Hidden inputs carry the list state through the rows-per-page GET form (page resets on change).
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
if (state.status !== "all") hidden.push({ name: "status", value: state.status });
if (state.team) hidden.push({ name: "team", value: state.team });
if (state.sort) hidden.push({ name: "sort", value: state.sort });
return {
label: "People pagination",
next: { href: page.next ? href(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: href(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? href(state, { page: page.prev }) : undefined },
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
+24
View File
@@ -51,6 +51,10 @@ test("app shell offers Sign in (not Sign out) to an anonymous visitor — so a p
// When chrome supplies signInHref (the current page as return_to), the link carries it. // 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" }); 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/); 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 () => { 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); 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 () => { 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>" }); const escaped = await render({ title: "<x>", body: "<p>raw</p>" });
assert.match(escaped, /<title>&lt;x&gt;<\/title>/); // user text is escaped assert.match(escaped, /<title>&lt;x&gt;<\/title>/); // user text is escaped
+10
View File
@@ -138,3 +138,13 @@ everything via Docker.
## 10. User added stuff ## 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] 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. - [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.
+23 -30
View File
@@ -1,40 +1,33 @@
<%# <%#
Themed Kratos self-service page (todo §4): sign-in / register / reset / verify / settings. Themed Kratos self-service page (todo §4) inside the unified app shell (§10): sign-in / register /
Renders a FlowView (src/flow-view.ts) into the html-css-foundation auth layout, reusing the 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. auth-card + field partials. The form posts straight to flow.ui.action — Kratos owns its CSRF. The
Auto theme follows the OS (styles.css), so no theme switch is shown here. 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 nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/flow-body", { flow }); const card = include("partials/auth-card", {
%><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= flow.title %></title>
<link rel="stylesheet" href="/public/css/styles.css" />
<link rel="stylesheet" href="/public/css/auth.css" />
<link rel="icon" href="/public/favicon.svg" />
</head>
<body>
<%- include("partials/icons") %>
<main class="auth-stage">
<div class="auth">
<div class="auth-brand">
<span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span>
<span class="brand-name"><%= brand %></span>
</div>
<%- include("partials/auth-card", {
action: flow.action, action: flow.action,
alt: flow.alt, alt: flow.alt,
back: flow.back, back: flow.back,
body, body: include("partials/flow-body", { flow }),
method: flow.method, method: flow.method,
sso: { providers: flow.sso }, sso: { providers: flow.sso },
sub: flow.sub, sub: flow.sub,
title: flow.title, title: flow.title,
});
const body = `<div class="shell-auth"><div class="auth">${card}</div></div>`;
-%>
<%- 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,
}) %> }) %>
</div>
</main>
</body>
</html>
+19 -41
View File
@@ -1,43 +1,21 @@
<%# <%#
Public landing page (todo §10): the ungated "/", what an anonymous visitor sees. A brief intro to Public landing page (todo §10): the ungated "/", rendered inside the unified app shell so the menu
the product and prominent paths to sign in / register — or to the app dashboard when already signed shows (role-filtered — anonymous ⇒ public items + Sign in). A brief intro + a prominent way in, or a
in. Standalone (no app shell — the sidebar/menu belong to the signed-in app). A plugin may replace dashboard link when already signed in. A plugin may replace this via its `home` handler.
this via its `home` handler. Auto theme follows the OS (styles.css). Data: brand, user (or null). Data: chrome (PageChrome), user (or null).
%><% %><%
const brand = locals.brand || "Plainpages"; const nav = include("partials/nav-tree", { nodes: chrome.nav });
%><!doctype html> const body = include("partials/landing-body", { brand: chrome.brand.name, signedIn: !!locals.user });
<html lang="en"> -%>
<head> <%- include("partials/shell", {
<meta charset="utf-8" /> body,
<meta name="viewport" content="width=device-width, initial-scale=1" /> brand: chrome.brand,
<title><%= brand %></title> csrfToken: chrome.csrfToken,
<link rel="stylesheet" href="/public/css/styles.css" /> docTitle: chrome.brand.name,
<link rel="stylesheet" href="/public/css/auth.css" /> nav,
<link rel="icon" href="/public/favicon.svg" /> signInHref: chrome.signInHref,
</head> styles: ["/public/css/auth.css"],
<body> theme: chrome.theme,
<%- include("partials/icons") %> title: "",
<main class="auth-stage"> user: chrome.user,
<div class="landing"> }) %>
<div class="auth-brand">
<span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span>
<span class="brand-name"><%= brand %></span>
</div>
<h1 class="landing-title">Operational web apps, without the boilerplate.</h1>
<p class="landing-lead">
<%= 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.
</p>
<div class="landing-actions">
<% if (locals.user) { %>
<a class="btn btn-primary" href="/dashboard">Go to your dashboard</a>
<% } else { %>
<a class="btn btn-primary" href="/login">Log in</a>
<a class="btn" href="/registration">Create account</a>
<% } %>
</div>
</div>
</main>
</body>
</html>
+27 -15
View File
@@ -1,27 +1,39 @@
<%# <%#
Dashboard (todo §1): the app-shell "People" list. Composes the building-block partials around Dashboard (todo §10): the gated post-login app home. The built-in default is a short instructional
the shell using the view model from src/dashboard.ts. EJS `include()` returns the rendered starter — what /dashboard is and how to replace it from a plugin. Composes the app shell (the one
string, so each partial is captured and handed to the shell as a slot. The filter form, global menu via model.nav) around the prose. A plugin owns the real dashboard via a `dashboard`
sortable headers and pager are real GET links — q/sort/page round-trip the URL, zero-JS. handler; this renders until then. Data: model { nav, shell }.
%><% %><%
const nav = include("partials/nav-tree", { nodes: model.nav }); 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) — const body = `
// say so up front so the inert affordances read as a starter to replace, not a half-built product. <div class="form-page">
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" }); <section class="form-card">
const filters = include("partials/filter-bar", model.filterBar); <h2 class="card-title">Starter dashboard</h2>
const table = include("partials/data-table", model.table); <p>This is the built-in <code>/dashboard</code> — the gated home shown to a signed-in user.
const pager = include("partials/pagination", model.pagination); It's a placeholder so a fresh clone has something here; it holds no real data.</p>
const actions = <p>Replace it from a plugin: export a <code>dashboard</code> handler from your plugin's
'<button class="btn"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-download"/></svg>Export</button>' + manifest and it owns this page, rendered against your own views with the native app shell
'<button class="btn btn-primary"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add person</button>'; (the same menu you see now) via <code>ctx.chrome</code>.</p>
<pre class="code-block"><code>export default definePlugin({
apiVersion: "1.0.0",
// view names plugins/&lt;id&gt;/views/&lt;view&gt;.ejs, rendered in this same shell
dashboard: (ctx) =&gt; ({ view: "dashboard", data: { /* … */ } }),
});</code></pre>
<p>See the plugin contract in <code>docs/plugin-contract.md</code> (the landing-pages section)
and the bundled <code>plugins/scheduling/</code> reference.</p>
<div class="form-actions">
<a class="btn btn-primary" href="/scheduling"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-grid"/></svg>Browse the example plugin</a>
</div>
</section>
</div>`;
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, body,
body: note + filters + table + pager,
brand: model.shell.brand, brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs, breadcrumbs: model.shell.breadcrumbs,
csrfToken: model.shell.csrfToken, csrfToken: model.shell.csrfToken,
nav, nav,
signInHref: model.shell.signInHref,
theme: model.shell.theme, theme: model.shell.theme,
title: model.shell.title, title: model.shell.title,
user: model.shell.user, user: model.shell.user,
+13
View File
@@ -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).
%><div class="shell-auth">
<div class="landing">
<h1 class="landing-title">Operational web apps, without the boilerplate.</h1>
<p class="landing-lead"><%= 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.</p>
<div class="landing-actions">
<% if (signedIn) { %><a class="btn btn-primary" href="/dashboard">Go to your dashboard</a>
<% } else { %><a class="btn btn-primary" href="/login">Log in</a><a class="btn" href="/registration">Create account</a><% } %>
</div>
</div>
</div>
+24 -13
View File
@@ -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), 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 `actions` (topbar buttons), `body` (page content); `styles` is an optional array of
extra stylesheet hrefs (e.g. a plugin's own /public/<id>/x.css). Text locals: `title`, `brand` extra stylesheet hrefs (e.g. a plugin's own /public/<id>/x.css). Text locals: `title`
({ name, logo?, sub? } — logo image else the default mark), `theme` (default for the (topbar heading; empty ⇒ the page body owns the single <h1>, e.g. login/landing), `docTitle`
theme-switch), `user`, `breadcrumbs`, `csrfToken` (the Sign-out POST form's hidden field), (the <title> tag; defaults to title or the brand), `brand` ({ name, logo?, sub? }), `theme`
`signInHref` (anonymous "Sign in" target, carries return_to; default /login). (theme-switch default), `user`, `breadcrumbs`, `csrfToken` (the Sign-out form's hidden field),
Branding comes from config/menu.ts; `user`/`csrfToken` from §4 auth. `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 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 user = locals.user || { name: "Guest", initials: "G", email: "" };
const breadcrumbs = locals.breadcrumbs || []; const breadcrumbs = locals.breadcrumbs || [];
const nav = locals.nav || ""; const nav = locals.nav || "";
@@ -21,7 +27,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %></title> <title><%= docTitle %></title>
<link rel="stylesheet" href="/public/css/styles.css" /> <link rel="stylesheet" href="/public/css/styles.css" />
<% styles.forEach((href) => { %><link rel="stylesheet" href="<%= href %>" /> <% styles.forEach((href) => { %><link rel="stylesheet" href="<%= href %>" />
<% }) %><link rel="icon" href="/public/favicon.svg" /> <% }) %><link rel="icon" href="/public/favicon.svg" />
@@ -29,10 +35,13 @@
<body> <body>
<a class="skip-link" href="#main-content">Skip to content</a> <a class="skip-link" href="#main-content">Skip to content</a>
<%- include("icons") %> <%- include("icons") %>
<% if (menu) { %>
<!-- nav-toggle drives the mobile overlay (pure CSS) --> <!-- nav-toggle drives the mobile overlay (pure CSS) -->
<input type="checkbox" id="nav-toggle" aria-hidden="true" tabindex="-1" /> <input type="checkbox" id="nav-toggle" aria-hidden="true" tabindex="-1" />
<% } %>
<div class="app"> <div class="app<%= menu ? "" : " app-bare" %>">
<% if (menu) { %>
<aside class="sidebar" aria-label="Primary"> <aside class="sidebar" aria-label="Primary">
<div class="brand"> <div class="brand">
<% if (brand.logo) { %><img class="brand-logo" src="<%= brand.logo %>" alt="" /><% } else { %><span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span><% } %> <% if (brand.logo) { %><img class="brand-logo" src="<%= brand.logo %>" alt="" /><% } else { %><span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span><% } %>
@@ -66,9 +75,10 @@
</form> </form>
</div> </div>
</details> </details>
<% } else { %> <% } else if (!hideSignIn) { %>
<%# anonymous (a public page in the shell, §10): no session to end — offer a way in instead. <%# anonymous (a public page in the shell, §10): no session to end — offer a way in instead.
signInHref carries this page as return_to (chrome.signInHref); falls back to bare /login. %> signInHref carries this page as return_to (chrome.signInHref); falls back to bare /login.
Suppressed on the auth pages themselves (hideSignIn) — a Sign-in there only loops back. %>
<a class="btn btn-primary" href="<%= locals.signInHref || '/login' %>" style="flex:1 1 auto"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-user" /></svg>Sign in</a> <a class="btn btn-primary" href="<%= locals.signInHref || '/login' %>" style="flex:1 1 auto"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-user" /></svg>Sign in</a>
<% } %> <% } %>
@@ -83,11 +93,12 @@
<!-- scrim closes the mobile menu (label toggles the checkbox) --> <!-- scrim closes the mobile menu (label toggles the checkbox) -->
<label class="scrim" for="nav-toggle" aria-label="Close menu"></label> <label class="scrim" for="nav-toggle" aria-label="Close menu"></label>
<% } %>
<main class="content" id="main-content"> <main class="content" id="main-content">
<header class="topbar"> <header class="topbar">
<label class="btn icon-btn hamburger" for="nav-toggle" aria-label="Open menu"><svg class="ico"><use href="#i-menu" /></svg></label> <% if (menu) { %><label class="btn icon-btn hamburger" for="nav-toggle" aria-label="Open menu"><svg class="ico"><use href="#i-menu" /></svg></label><% } %>
<h1 class="page-title"><%= title %></h1> <% if (title) { %><h1 class="page-title"><%= title %></h1><% } %>
<% if (breadcrumbs.length) { %> <% if (breadcrumbs.length) { %>
<nav class="crumbs" aria-label="Breadcrumb"> <nav class="crumbs" aria-label="Breadcrumb">
<% breadcrumbs.forEach((c, i) => { %><% if (i) { %><span class="sep">/</span><% } %><% if (c.href) { %><a href="<%= c.href %>"><%= c.label %></a><% } else { %><span><%= c.label %></span><% } %><% }) %> <% breadcrumbs.forEach((c, i) => { %><% if (i) { %><span class="sep">/</span><% } %><% if (c.href) { %><a href="<%= c.href %>"><%= c.label %></a><% } else { %><span><%= c.label %></span><% } %><% }) %>