diff --git a/AGENTS.md b/AGENTS.md index 3785abb..1184a78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,21 @@ commands and layout. that way as it grows (never serialise on shared state); parallelism is what keeps it fast. E2E runs in Docker against the live stack — see `README.md`. +## Deliberate architectural deviations (don't re-flag) + +Intentional, reasoned choices — an architecture review should honor them, not re-raise +them. Revisit only if the stated reason stops holding. + +- **`src/` is flat on purpose.** The functional-core / imperative-shell split is + conceptual, not a directory layout; splitting into `core/`/`shell/` dirs was judged + premature for a scaffold this size. Revisit only if the flat tree stops being readable. +- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the + base request context. It protects the I/O-free hot path on the public, bot-hit landing + (`/`). (Declined twice.) +- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` + never touches SMTP. Customization is Kratos' built-in `courier.template_override_path`, + not app code — keeping `web` stateless and dependency-light (see [Email](README.md#email)). + ## Docker only — no host tooling **Everything** (install, typecheck, test, run, build, deploy) goes through Docker / diff --git a/README.md b/README.md index a7115bd..9a611cb 100644 --- a/README.md +++ b/README.md @@ -536,6 +536,34 @@ nav tree from the design foundation (header/leaf × clickable/static, counts, arbitrary depth). Branding (name, logo, default theme) renders in the app shell — the sidebar brand shows the configured logo (else a default mark), and the theme sets the theme-switch default. +**One menu, one shell, everywhere.** There is a single menu (`src/chrome.ts` `buildPluginChrome`), +rendered by the same app shell on **every** page — the dashboard, the admin screens, plugin pages, +and the login / registration / recovery / front (`/`) pages. So it looks identical signed in or out; +it just shows fewer items to an anonymous visitor (only `public` ones, plus a Sign-in link), filtered +by the same per-user rule. The sidebar collapses to a burger on a narrow screen. A page that wants a +focused, chrome-free layout (e.g. a print view) opts out with the shell's `menu: false`. + +## Email + +The only emails are the **recovery** and **verification** codes from Kratos' self-service flows, and +**Kratos renders and sends them** (delegated, like the rest of identity — `web` never touches SMTP). +Dev catches them in **mailpit** (http://localhost:8025); prod points Kratos at a real server via +`COURIER_SMTP_CONNECTION_URI` (`courier.smtp` in `ory/kratos/kratos.yml`). + +**Customizing the email content** is a built-in Kratos feature — no code here. Set +`courier.template_override_path` to a mounted directory and drop Go templates in it, keyed by type: + +``` +/recovery_code/valid/email.subject.gotmpl +/recovery_code/valid/email.body.gotmpl (+ email.body.plaintext.gotmpl) +/verification_code/valid/email.subject.gotmpl +/verification_code/valid/email.body.gotmpl +``` + +The `ory/kratos/` tree is already mounted into the Kratos container, so an override dir there is the +simplest place. See Ory's [courier message templates](https://www.ory.sh/docs/kratos/emails-sms/custom-message-templates) +docs for the full template-type list and the data each template receives. + ## Building blocks Plainpages is a **component library, not a page generator** — you assemble pages from partials @@ -805,14 +833,14 @@ src/logger.ts createLogger()/requestLogger() + the ambient request log (r src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms) src/context.ts RequestContext handed to handlers + buildContext() src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot -src/dashboard.ts buildDashboardModel(): the built-in "/dashboard" People list view model (mock data, wires the §1 helpers); /dashboard is gated to a session, "/" is the public landing — both replaceable by a plugin `dashboard`/`home` handler (§10) +src/dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler) (§10). Both render the one unified menu (ctx.chrome) src/admin-users.ts Built-in Users admin screen (§5): list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded src/admin-groups.ts Built-in Groups admin screen (§5): list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded src/admin-roles.ts Built-in Roles admin screen (§5): list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded src/admin-clients.ts Built-in OAuth2 clients admin screen (§6): list/register/delete Hydra OAuth2 clients (apps that log in through us); register shows the one-time client_secret; writes only to Hydra, gated + CSRF-guarded src/admin-nav.ts adminSection(): the permission-gated "Admin" menu section (Users · Groups · Roles · OAuth2 clients), wired into the global dashboard menu + the in-screen admin nav (adminNav) so they can't drift src/shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile) -src/chrome.ts buildPluginChrome(): the brand/global-nav/user/theme/csrf a plugin view renders the native shell from — exposed on ctx.chrome (§7) +src/chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (§7, unified across all pages in §10) — exposed on ctx.chrome src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs) src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize } src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model @@ -825,7 +853,7 @@ src/guards.ts requireSession()/can()/check(): in-handler authorization ( src/hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order (§2); no sandbox (a throwing hook fails loud), skipped when no plugin declares one src/view-resolver.ts renderPluginView(): render plugins//views/.ejs; plugin views can include() core partials (§2) src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2) -views/ Core EJS templates: home (public "/" landing), index (app-shell dashboard at /dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), error (Kratos self-service flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + consent + admin bodies, menu/popover, theme switch, icon sprite) +views/ Core EJS templates, all in the one app shell (§10): home (public "/" landing), index (instructional /dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent/admin bodies, menu/popover, theme switch, icon sprite) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) config/menu.ts Central menu override + branding (optional; defaults apply if absent) ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service) diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md index 616e37e..ba3ad67 100644 --- a/docs/plugin-contract.md +++ b/docs/plugin-contract.md @@ -252,6 +252,11 @@ state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then o body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the secret and sets the cookie; the plugin never touches it. (See the reference: `plugins/scheduling/`.) +The same shell renders **every** page (the dashboard, the admin screens, your plugin pages, and the +login/registration/front pages), so the menu looks identical signed in or out — it just role-filters. +A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the +sidebar, single column); everything else still renders. + **`ctx.log`** is a structured, request-scoped logger ([`@larvit/log`](https://www.npmjs.com/package/@larvit/log), §9) already in this request's trace: `ctx.log.info("…", { key: "value" })` (also `warn`/`error`/`debug`, metadata values are string/number/boolean), and **`ctx.log.fetch(url, init?)`** — a drop-in `fetch` diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts index 9c66709..b1b41a2 100644 --- a/e2e/visual.spec.ts +++ b/e2e/visual.spec.ts @@ -37,12 +37,10 @@ test.beforeEach(async ({ context }) => { test("captures live pages + reference mockups for side-by-side review", async ({ page }) => { await page.goto("/dashboard"); await expect(page.locator(".sidebar")).toBeVisible(); - await expect(page.locator("table.table tbody tr").first()).toBeVisible(); + // §10: the default /dashboard is the instructional starter, not a mock-data list. + await expect(page.getByRole("heading", { name: "Starter dashboard" })).toBeVisible(); await shot(page, "live-01-dashboard"); - await page.goto("/dashboard?sort=-name&status=active"); - await shot(page, "live-02-sorted-filtered"); - await page.goto("/dashboard"); await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio await shot(page, "live-03-dark"); @@ -72,7 +70,11 @@ test("live components compute the same design-system styles as the reference moc const ref = await context.newPage(); await ref.goto(APP_SHELL); - for (const selector of [".sidebar", ".topbar", ".brand", ".btn.btn-primary", ".theme-switch", ".filters", ".pager"]) { + // Shell components appear on every page, incl. the instructional /dashboard. The data components + // (.filters/.table/.pager) used to live on the mock dashboard (removed in §10); they're now covered + // by the mockup screenshots + their unit tests, and rendered live with real data by the full-flow + // E2E (the admin Users list) which the Ory-free visual suite can't stand up. + for (const selector of [".sidebar", ".topbar", ".brand", ".btn.btn-primary", ".theme-switch"]) { // .btn-primary = the dashboard's "Browse the example plugin" CTA expect(await styleOf(page, selector), `computed style mismatch for ${selector}`).toEqual(await styleOf(ref, selector)); } await ref.close(); @@ -89,20 +91,9 @@ test("every icon resolves to a defined (no broken graphics)", asy expect(missing).toEqual([]); }); -test("sorting and search drive the list through the URL (zero-JS)", async ({ page }) => { - await page.goto("/dashboard"); - const total = await page.locator("tbody tr").count(); - - await page.getByRole("link", { name: /Name/ }).first().click(); - await expect(page).toHaveURL(/sort=name/); - await expect(page.locator("thead th").filter({ hasText: "Name" })).toHaveAttribute("aria-sort", "ascending"); - - await page.goto("/dashboard"); - await page.locator('input[name="q"]').fill("Avery"); - await page.getByRole("button", { name: /Apply filters/ }).click(); - await expect(page).toHaveURL(/q=Avery/); - expect(await page.locator("tbody tr").count()).toBeLessThan(total); -}); +// (The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component +// (list-query/data-table/filter-bar) and exercised live with real data by the full-flow E2E's admin +// Users list. The mock-data dashboard that used to host it in this Ory-free suite is gone (§10).) test("theme switch flips the palette with no JavaScript", async ({ page }) => { await page.goto("/dashboard"); @@ -140,8 +131,9 @@ test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a t test("the public landing at / is ungated and links to sign in + register (§10)", async ({ page, context }) => { await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session) await page.goto("/"); - await expect(page.locator(".landing")).toBeVisible(); // the standalone landing, not the app shell - await expect(page.locator(".sidebar")).toHaveCount(0); + await expect(page.locator(".landing")).toBeVisible(); + // §10: the same app shell every page renders — the menu shows even signed out (role-filtered). + await expect(page.locator(".sidebar")).toBeVisible(); await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login"); await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration"); await shot(page, "live-05-public-landing"); @@ -180,7 +172,7 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red // The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav, // but the gated Shifts leaf is filtered out. await page.goto("/dashboard"); - await expect(page.locator(".sidebar")).toContainText("People"); // dashboard nav renders + await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders (§10) await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out }); diff --git a/public/css/styles.css b/public/css/styles.css index 6b1003d..c6d3d72 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -685,3 +685,9 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); } .btn-danger { color: var(--neg); border-color: var(--neg-bd); } .btn-danger:hover { background: var(--neg-bg); } .recovery-code code { font-size: 1.15rem; font-weight: 600; letter-spacing: 0.04em; } +.code-block { margin: 0; padding: 12px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); overflow-x: auto; font-size: var(--fz-sm); } +/* Chromeless shell (§10): a page may drop the sidebar for a focused single column. */ +.app-bare { grid-template-columns: minmax(0, 1fr); } +.app-bare .content { grid-column: 1; } +/* Auth/landing rendered inside the app shell (§10): a roomy, centered column in the content area. */ +.shell-auth { flex: 1 1 auto; overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; } diff --git a/src/admin-clients.ts b/src/admin-clients.ts index dcc2f44..4320619 100644 --- a/src/admin-clients.ts +++ b/src/admin-clients.ts @@ -5,13 +5,14 @@ // PRG redirect (mirrors the Users "trigger recovery" one-time code). `handleAdminClients` is the // imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded. -import { ADMIN_CLIENTS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts"; +import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts"; import { safeDecode } from "./admin-groups.ts"; import type { FieldConfig } from "./admin-users.ts"; import type { RequestContext, User } from "./context.ts"; import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts"; import { parseListQuery } from "./list-query.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; +import type { NavNode } from "./nav.ts"; import { paginate } from "./paginate.ts"; import type { RouteResult } from "./plugin.ts"; import { buildShellContext } from "./shell-context.ts"; @@ -110,6 +111,7 @@ export function buildClientsListModel(opts: { clients: OAuth2Client[]; csrfToken?: string; menu?: MenuConfig; + nav?: NavNode[]; url: URL | URLSearchParams | string; user?: User | null; }) { @@ -127,7 +129,7 @@ export function buildClientsListModel(opts: { return { filterBar: listFilterBar(state), - nav: adminNav(opts.user?.roles ?? [], menu, "clients"), + nav: opts.nav ?? [], pagination: listPagination(state, page), shell: buildShellContext({ breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }], @@ -192,6 +194,7 @@ export function buildClientFormModel(opts: { csrfToken?: string; error?: string; menu?: MenuConfig; + nav?: NavNode[]; user?: User | null; values?: Partial; }) { @@ -217,7 +220,7 @@ export function buildClientFormModel(opts: { scopeField, submitLabel: "Register client", }, - nav: adminNav(opts.user?.roles ?? [], menu, "clients"), + nav: opts.nav ?? [], shell: buildShellContext({ breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }], csrfToken: opts.csrfToken ?? "", @@ -233,6 +236,7 @@ export function buildClientDetailModel(opts: { created?: boolean; // just registered → success banner + the one-time secret (if any) csrfToken?: string; menu?: MenuConfig; + nav?: NavNode[]; secret?: string; // one-time client_secret (confidential clients), shown once right after create user?: User | null; }) { @@ -243,7 +247,7 @@ export function buildClientDetailModel(opts: { created: opts.created ?? false, csrfToken: opts.csrfToken ?? "", delete: { action: `${base}/delete` }, - nav: adminNav(opts.user?.roles ?? [], menu, "clients"), + nav: opts.nav ?? [], secret: opts.secret, shell: buildShellContext({ breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }], @@ -280,21 +284,22 @@ export async function handleAdminClients(ctx: RequestContext, csrfToken: string, const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403) const { hydra, menu, render } = deps; + const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host const method = (ctx.req.method ?? "GET").toUpperCase(); const seg = path.slice(ADMIN_CLIENTS_BASE.length).split("/").filter(Boolean); const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined const renderForm = async (extra: { error?: string; values?: Partial }): Promise => - ({ 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 => - ({ 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 => ({ html: await render("404", { title: "Not found" }), status: 404 }); // /admin/clients — list (GET) · register (POST) if (seg.length === 0) { if (method === "GET") { const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE }); - return { html: await render("admin/clients", { model: buildClientsListModel({ clients, csrfToken, menu, url: ctx.url, user }) }) }; + return { html: await render("admin/clients", { model: buildClientsListModel({ clients, csrfToken, menu, nav, url: ctx.url, user }) }) }; } if (method === "POST") { const input = readClientInput(form!); @@ -335,7 +340,7 @@ export async function handleAdminClients(ctx: RequestContext, csrfToken: string, return { html: await render("admin/confirm", { model: buildConfirmModel({ breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }], cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client", csrfToken, - current: "clients", menu, message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, title: "Delete client", user, + menu, message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, nav, title: "Delete client", user, }) }) }; } if (seg.length === 2 && seg[1] === "delete" && method === "POST") { diff --git a/src/admin-groups.ts b/src/admin-groups.ts index b60e9fc..4463692 100644 --- a/src/admin-groups.ts +++ b/src/admin-groups.ts @@ -6,13 +6,14 @@ // is the imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded, mapping each action // to a RouteResult. -import { ADMIN_GROUPS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts"; +import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts"; import type { FieldConfig } from "./admin-users.ts"; import type { RequestContext, User } from "./context.ts"; import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "./keto-client.ts"; import type { KratosAdmin } from "./kratos-admin.ts"; import { parseListQuery } from "./list-query.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; +import type { NavNode } from "./nav.ts"; import { paginate } from "./paginate.ts"; import type { RouteResult } from "./plugin.ts"; import { buildShellContext } from "./shell-context.ts"; @@ -120,6 +121,7 @@ export function buildGroupsListModel(opts: { csrfToken?: string; groups: GroupView[]; menu?: MenuConfig; + nav?: NavNode[]; url: URL | URLSearchParams | string; user?: User | null; }) { @@ -147,7 +149,7 @@ export function buildGroupsListModel(opts: { return { filterBar: listFilterBar(state), - nav: adminNav(opts.user?.roles ?? [], menu, "groups"), + nav: opts.nav ?? [], pagination: listPagination(state, page), shell: buildShellContext({ breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Admin" }, { label: "Groups" }], @@ -214,6 +216,7 @@ export function buildGroupFormModel(opts: { error?: string; memberOptions: MemberOption[]; menu?: MenuConfig; + nav?: NavNode[]; user?: User | null; values?: { member?: string; name?: string }; }) { @@ -233,7 +236,7 @@ export function buildGroupFormModel(opts: { selectedMember: opts.values?.member ?? "", submitLabel: "Create group", }, - nav: adminNav(opts.user?.roles ?? [], menu, "groups"), + nav: opts.nav ?? [], shell: buildShellContext({ breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: "New" }], csrfToken: opts.csrfToken ?? "", @@ -251,6 +254,7 @@ export function buildGroupDetailModel(opts: { group: { name: string }; members: MemberView[]; menu?: MenuConfig; + nav?: NavNode[]; user?: User | null; }) { const menu = opts.menu ?? DEFAULT_MENU; @@ -266,7 +270,7 @@ export function buildGroupDetailModel(opts: { error: opts.error, group: { name }, members: { action: `${base}/members/delete`, rows: opts.members }, - nav: adminNav(opts.user?.roles ?? [], menu, "groups"), + nav: opts.nav ?? [], shell: buildShellContext({ breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: name }], csrfToken: opts.csrfToken ?? "", @@ -332,22 +336,23 @@ export async function handleAdminGroups(ctx: RequestContext, csrfToken: string, const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403) const { keto, kratosAdmin, menu, render } = deps; + const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host const method = (ctx.req.method ?? "GET").toUpperCase(); const seg = path.slice(ADMIN_GROUPS_BASE.length).split("/").filter(Boolean); const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined const renderList = async (): Promise => { 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 => { 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 => { const { emailById, options } = await memberCandidates(keto, kratosAdmin); const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById)); - return { html: await render("admin/group-detail", { model: buildGroupDetailModel({ candidates: options, csrfToken, group: { name }, members, menu, user }) }) }; + return { html: await render("admin/group-detail", { model: buildGroupDetailModel({ candidates: options, csrfToken, group: { name }, members, menu, nav, user }) }) }; }; // /admin/groups — list (GET) · create (POST) @@ -388,7 +393,7 @@ export async function handleAdminGroups(ctx: RequestContext, csrfToken: string, return { html: await render("admin/confirm", { model: buildConfirmModel({ breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }], cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete group", csrfToken, - current: "groups", menu, message: `Delete group ${name}? This removes the group and all its memberships.`, title: "Delete group", user, + menu, message: `Delete group ${name}? This removes the group and all its memberships.`, nav, title: "Delete group", user, }) }) }; } if (seg.length === 2 && seg[1] === "delete" && method === "POST") { diff --git a/src/admin-nav.test.ts b/src/admin-nav.test.ts index 9d59445..dfc8d40 100644 --- a/src/admin-nav.test.ts +++ b/src/admin-nav.test.ts @@ -6,7 +6,7 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Socket } from "node:net"; import { test } from "node:test"; import { - ADMIN_PERMISSION, ADMIN_USERS_BASE, adminNav, adminSection, buildConfirmModel, guardedForm, requireAdmin, + ADMIN_PERMISSION, ADMIN_USERS_BASE, adminSection, buildConfirmModel, guardedForm, requireAdmin, } from "./admin-nav.ts"; import { buildContext, type RequestContext, type User } from "./context.ts"; import { CSRF_COOKIE, CSRF_FIELD, issueCsrfToken } from "./csrf.ts"; @@ -45,15 +45,8 @@ test("adminSection: gated Admin header over the four screens; current marks the assert.equal(onRoles.children?.find((c) => c.id === "users")?.current, undefined); }); -test("adminNav: prepends Dashboard and role-filters the section (admin sees it, others get only Dashboard)", () => { - const forAdmin = adminNav(admin.roles, DEFAULT_MENU, "users"); - assert.deepEqual(labels(forAdmin), ["Dashboard", "Admin"]); - // composeNav strips `id` from rendered nodes but keeps `current`/`href`, so match the active item by href. - assert.equal(forAdmin.find((n) => n.label === "Admin")!.children?.find((c) => c.href === ADMIN_USERS_BASE)?.current, true); - - assert.deepEqual(labels(adminNav(member.roles, DEFAULT_MENU, "users")), ["Dashboard"]); // non-admin → gated section dropped - assert.deepEqual(labels(adminNav([], DEFAULT_MENU, "users")), ["Dashboard"]); // anonymous too -}); +// (The in-screen admin sidebar is gone in §10 — every page renders the one global menu, built by +// buildPluginChrome; see chrome.test.ts. adminSection above is that menu's gated Admin fragment.) // ---- auth gates ---- @@ -82,15 +75,16 @@ test("guardedForm: valid double-submit → the parsed body, bad/missing token // ---- confirm-page model ---- -test("buildConfirmModel wires the danger action, message, role-filtered nav and shell", () => { +test("buildConfirmModel wires the danger action, message, passed-in nav and shell", () => { + const nav = [{ label: "Dashboard" }, { label: "Admin" }]; const model = buildConfirmModel({ breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: "Delete" }], cancelHref: ADMIN_USERS_BASE, confirmAction: `${ADMIN_USERS_BASE}/u1/delete`, confirmLabel: "Delete user", - csrfToken: "tok", current: "users", menu: DEFAULT_MENU, message: "Delete ada@x.io?", title: "Delete user", user: admin, + csrfToken: "tok", menu: DEFAULT_MENU, message: "Delete ada@x.io?", nav, title: "Delete user", user: admin, }); assert.deepEqual(model.confirm, { action: `${ADMIN_USERS_BASE}/u1/delete`, label: "Delete user" }); assert.equal(model.message, "Delete ada@x.io?"); assert.equal(model.cancelHref, ADMIN_USERS_BASE); - assert.ok(labels(model.nav).includes("Admin")); // admin user ⇒ section present in the in-screen sidebar + assert.equal(model.nav, nav); // the host's one global menu, passed through verbatim assert.equal(model.shell.title, "Delete user"); }); diff --git a/src/admin-nav.ts b/src/admin-nav.ts index 22e8931..fc3f451 100644 --- a/src/admin-nav.ts +++ b/src/admin-nav.ts @@ -1,15 +1,15 @@ -// The built-in admin section of the menu (todo §5). One definition of the Users/Groups/Roles links -// + their gate, reused two ways so they can't drift: `adminSection()` is the permission-gated -// "Admin" header wired into the global dashboard menu (composeNav drops the whole header + subtree -// for a non-admin), and `adminNav()` is the in-screen sidebar each admin screen renders (a link home -// + the same section, with the active item marked `current`). +// The built-in admin section of the menu (todo §5). `adminSection()` is the one definition of the +// permission-gated "Admin" header (Users/Groups/Roles/clients) + its gate, composed into the single +// global menu (`buildPluginChrome`, §10) — composeNav drops the whole header + subtree for a +// non-admin. Every page (dashboard, admin, plugin, auth) renders that one menu, so there's no +// separate admin sidebar to drift. import { readFormBody } from "./body.ts"; import type { RequestContext, User } from "./context.ts"; import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts"; import { GuardError, loginRedirect } from "./guards.ts"; import { type MenuConfig } from "./menu-config.ts"; -import { composeNav, type NavNode } from "./nav.ts"; +import type { NavNode } from "./nav.ts"; import { buildShellContext } from "./shell-context.ts"; export const ADMIN_PERMISSION = "admin"; // role token gating the admin section @@ -20,9 +20,9 @@ export const ADMIN_CLIENTS_BASE = "/admin/clients"; export type AdminScreen = "clients" | "groups" | "roles" | "users"; -// The "Dashboard" link to the gated app home (/dashboard). One definition, reused by the in-screen -// admin sidebar and the plugin-page chrome (chrome.ts) so the two can't drift. It targets a gated -// route, so the chrome hides it from anonymous visitors (a non-signed-in click only dead-ends at /login). +// The "Dashboard" link to the gated app home (/dashboard), composed into the global menu by +// buildPluginChrome (chrome.ts). It targets a gated route, so the chrome hides it from anonymous +// visitors (a non-signed-in click only dead-ends at /login). export const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" }; const ITEMS: { href: string; icon: string; id: AdminScreen; label: string }[] = [ @@ -45,11 +45,6 @@ export function adminSection(current?: AdminScreen): NavNode { }; } -// In-screen sidebar for the admin screens: a link home + the admin section (active item marked). -export function adminNav(roles: string[], menu: MenuConfig, current: AdminScreen): NavNode[] { - return composeNav([[DASHBOARD_NAV, adminSection(current)]], menu.override, roles); -} - // The shared gate for every admin screen: a signed-in admin only. Throws GuardError that app.ts maps // (anonymous → /login, non-admin → 403). Returns the (non-null) user for the handler to thread on. export function requireAdmin(ctx: RequestContext): User { @@ -70,16 +65,17 @@ export async function guardedForm(ctx: RequestContext, csrfSecret: string): Prom } // Build the model for the shared destructive-action confirm page (views/admin/confirm.ejs): a single -// danger action behind a deliberate second step, plus a cancel link. Reused by all three screens. +// danger action behind a deliberate second step, plus a cancel link. Reused by all admin screens. +// `nav` is the unified global menu (ctx.chrome.nav), passed in by the handler. export function buildConfirmModel(opts: { breadcrumbs: { href?: string; label: string }[]; cancelHref: string; confirmAction: string; confirmLabel: string; csrfToken: string; - current: AdminScreen; menu: MenuConfig; message: string; + nav: NavNode[]; title: string; user: User | null; }) { @@ -87,7 +83,7 @@ export function buildConfirmModel(opts: { cancelHref: opts.cancelHref, confirm: { action: opts.confirmAction, label: opts.confirmLabel }, message: opts.message, - nav: adminNav(opts.user?.roles ?? [], opts.menu, opts.current), + nav: opts.nav, shell: buildShellContext({ breadcrumbs: opts.breadcrumbs, csrfToken: opts.csrfToken, menu: opts.menu, title: opts.title, user: opts.user }), }; } diff --git a/src/admin-roles.ts b/src/admin-roles.ts index 048757a..699e787 100644 --- a/src/admin-roles.ts +++ b/src/admin-roles.ts @@ -8,7 +8,7 @@ // Kratos is read only to label members. `handleAdminRoles` is the imperative shell app.ts dispatches // to — gated admin-only, CSRF-guarded. -import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts"; +import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts"; import { type GroupView, groupsFromTuples, @@ -27,6 +27,7 @@ import type { ExpandTree, KetoClient, RelationTuple } from "./keto-client.ts"; import type { KratosAdmin } from "./kratos-admin.ts"; import { parseListQuery } from "./list-query.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; +import type { NavNode } from "./nav.ts"; import { paginate } from "./paginate.ts"; import type { RouteResult } from "./plugin.ts"; import { buildShellContext } from "./shell-context.ts"; @@ -105,6 +106,7 @@ function listHref(state: ListState, overrides: Partial = {}): string export function buildRolesListModel(opts: { csrfToken?: string; menu?: MenuConfig; + nav?: NavNode[]; roles: RoleView[]; url: URL | URLSearchParams | string; user?: User | null; @@ -133,7 +135,7 @@ export function buildRolesListModel(opts: { return { filterBar: listFilterBar(state), - nav: adminNav(opts.user?.roles ?? [], menu, "roles"), + nav: opts.nav ?? [], pagination: listPagination(state, page), shell: buildShellContext({ breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }], @@ -200,6 +202,7 @@ export function buildRoleFormModel(opts: { error?: string; memberOptions: MemberOption[]; menu?: MenuConfig; + nav?: NavNode[]; user?: User | null; values?: { member?: string; name?: string }; }) { @@ -219,7 +222,7 @@ export function buildRoleFormModel(opts: { selectedMember: opts.values?.member ?? "", submitLabel: "Create role", }, - nav: adminNav(opts.user?.roles ?? [], menu, "roles"), + nav: opts.nav ?? [], shell: buildShellContext({ breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }], csrfToken: opts.csrfToken ?? "", @@ -237,6 +240,7 @@ export function buildRoleDetailModel(opts: { error?: string; members: MemberView[]; menu?: MenuConfig; + nav?: NavNode[]; role: { name: string }; user?: User | null; }) { @@ -252,7 +256,7 @@ export function buildRoleDetailModel(opts: { effective: opts.effective, error: opts.error, members: { action: `${base}/members/delete`, rows: opts.members }, - nav: adminNav(opts.user?.roles ?? [], menu, "roles"), + nav: opts.nav ?? [], role: { name }, shell: buildShellContext({ breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }], @@ -304,24 +308,25 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403) const { keto, kratosAdmin, menu, render } = deps; + const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host const method = (ctx.req.method ?? "GET").toUpperCase(); const seg = path.slice(ADMIN_ROLES_BASE.length).split("/").filter(Boolean); const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined const renderList = async (): Promise => { 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 => { 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 => { const { emailById, options } = await memberCandidates(keto, kratosAdmin); const tuples = await pagedTuples(keto, { namespace: ROLE_NS, object: name, relation: MEMBERS }); const members = tuples.map((t) => memberView(t, emailById)); const effective = await effectiveUsers(keto, name, tuples.length > 0, emailById); - const html = await render("admin/role-detail", { model: buildRoleDetailModel({ candidates: options, csrfToken, effective, members, menu, role: { name }, user, ...(error ? { error } : {}) }) }); + const html = await render("admin/role-detail", { model: buildRoleDetailModel({ candidates: options, csrfToken, effective, members, menu, nav, role: { name }, user, ...(error ? { error } : {}) }) }); return error ? { html, status: 400 } : { html }; }; @@ -367,7 +372,7 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d return { html: await render("admin/confirm", { model: buildConfirmModel({ breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }], cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role", csrfToken, - current: "roles", menu, message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role", user, + menu, message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, nav, title: "Delete role", user, }) }) }; } if (seg.length === 2 && seg[1] === "delete" && method === "POST") { diff --git a/src/admin-users.ts b/src/admin-users.ts index d9f562a..7973056 100644 --- a/src/admin-users.ts +++ b/src/admin-users.ts @@ -5,12 +5,13 @@ // CSRF-guarded, each action mapped to a RouteResult (render, or redirect after a write — PRG). import { safeDecode } from "./admin-groups.ts"; -import { ADMIN_USERS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts"; +import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts"; import type { RequestContext, User } from "./context.ts"; import type { Identity, KratosAdmin, RecoveryCode } from "./kratos-admin.ts"; import { KratosError } from "./kratos-public.ts"; import { parseListQuery } from "./list-query.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; +import type { NavNode } from "./nav.ts"; import { paginate } from "./paginate.ts"; import type { RouteResult } from "./plugin.ts"; import { buildShellContext } from "./shell-context.ts"; @@ -118,6 +119,7 @@ export function buildUsersListModel(opts: { csrfToken?: string; identities: Identity[]; menu?: MenuConfig; + nav?: NavNode[]; // the unified global menu (ctx.chrome.nav); the host builds it once per request url: URL | URLSearchParams | string; user?: User | null; }) { @@ -145,7 +147,7 @@ export function buildUsersListModel(opts: { return { filterBar: listFilterBar(state, all.length), - nav: adminNav(opts.user?.roles ?? [], menu, "users"), + nav: opts.nav ?? [], pagination: listPagination(state, page), shell: buildShellContext({ breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Admin" }, { label: "Users" }], @@ -237,6 +239,7 @@ export function buildUserFormModel(opts: { error?: string; identity?: Identity | null; menu?: MenuConfig; + nav?: NavNode[]; recovery?: RecoveryCode; user?: User | null; values?: Partial; @@ -267,7 +270,7 @@ export function buildUserFormModel(opts: { } : undefined, error: opts.error, form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? "Save changes" : "Create user" }, - nav: adminNav(opts.user?.roles ?? [], menu, "users"), + nav: opts.nav ?? [], recovery: opts.recovery, shell: buildShellContext({ breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: editing ? "Edit" : "New" }], @@ -306,16 +309,17 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403) const { kratosAdmin, menu, render } = deps; + const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host const method = (ctx.req.method ?? "GET").toUpperCase(); const seg = path.slice(ADMIN_USERS_BASE.length).split("/").filter(Boolean); const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined const renderList = async (): Promise => { 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[0]): Promise => - ({ html: await render("admin/user-form", { model: buildUserFormModel({ csrfToken, menu, user, ...extra }) }) }); + ({ html: await render("admin/user-form", { model: buildUserFormModel({ csrfToken, menu, nav, user, ...extra }) }) }); // /admin/users — list (GET) · create (POST) if (seg.length === 0) { @@ -366,7 +370,7 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d return { html: await render("admin/confirm", { model: buildConfirmModel({ breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }], cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: "Delete user", csrfToken, - current: "users", menu, message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, title: "Delete user", user, + menu, message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, nav, title: "Delete user", user, }) }) }; } if (method === "POST") { diff --git a/src/app.test.ts b/src/app.test.ts index 477c89d..35a1ce6 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -48,20 +48,20 @@ before(async () => { after(() => server.close()); -test("the dashboard at /dashboard: the app-shell People list, gated to a session, filterable via the URL", async () => { +test("the dashboard at /dashboard: the instructional starter in the unified shell, gated to a session", async () => { // The dashboard is gated to a signed-in user (§10), so present a session. const res = await fetch(base + "/dashboard", { headers: { cookie: session() } }); assert.equal(res.status, 200); assert.match(res.headers.get("content-type") ?? "", /text\/html/); const html = await res.text(); - // Shell + building blocks composed around the mock data. + // The unified app shell (§10): the same sidebar/menu every page renders. assert.match(html, /Plainpages/); // sidebar brand assert.match(html, /