§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
+11 -1
View File
@@ -137,4 +137,14 @@ everything via Docker.
## 10. User added stuff
- [x] The dashboard, the first landing page after logging in, should be gated to only logged in users. It should also be replaceable fully from a plugin. It is important that the ergonomics for the plugin writer is great. → **Two replaceable landing slots** (per the human follow-up: `/` public, `/dashboard` gated): `/` is now an **ungated public landing** (default `views/home.ejs`: brand + a short "what plainpages is" intro + prominent **Log in**/`/login` & **Create account**/`/registration` links, or **Go to your dashboard** when already signed in), and `/dashboard` is the **gated post-login app home** (anonymous → `/login?return_to=/dashboard` via `loginRedirect`; default = the mock-data People list). Both **fully replaceable by a plugin** via two optional `RouteHandler`s on `PluginManifest` (`src/plugin.ts`) — `home?` (public `/`) and `dashboard?` (gated `/dashboard`), the most ergonomic shape (same signature as any route). The host renders each against the plugin's own `views/` with the native shell via `ctx.chrome` (full parity with a plugin route: HEAD, `void`-return, response hooks, fresh CSRF cookie); a `home` handler is public so `ctx.user` may be null. **Single-slot, loud:** `findConflicts` errors when >1 plugin claims either slot (new `"home"`/`"dashboard"` conflict kinds), `discovery.shapeError` rejects a non-function handler, and `"dashboard"` is added to `RESERVED_PLUGIN_IDS` so a plugin folder can't shadow the built-in route (`/` can't be shadowed — route paths always carry the `/<id>` prefix). Post-login + already-signed-in redirects now target `/dashboard`; the global "Dashboard"/"People" nav hrefs moved `/``/dashboard` (chrome/admin-nav/dashboard). Tests-first (348 units, was 338): `app.test.ts` public-`/` (anon 200 + login/register, no gate) + gated-`/dashboard` (anon→303 return_to) + dual plugin-override; `plugin.test.ts` per-slot conflict; `discovery.test.ts` non-function + reserved-id + two-owners + valid-load. Docs: `docs/plugin-contract.md` ("The landing pages (home & dashboard)" section + manifest/conflict/reserved updates), README. **E2E:** the Ory-free `visual.spec.ts` plants a dev-signed session for the `/dashboard` design-system tests + a cookie-free public-`/` landing test (login/register links, screenshot verified); `full-flow.spec.ts` repointed its app-shell navigations to `/dashboard`; all five e2e healthchecks moved off the (now-public-but-formless) `/` to the auth-free `/public/css/styles.css`. stability-reviewer on the prod diff (both iterations): **APPROVE, no Critical/High/Medium** (gate moved correctly + stays closed, open-redirect-safe, public `/` prints no protected data, no shadowing either direction, render-branch parity). typecheck + 348 units + visual (10) + full-flow (7) E2E green, stacks torn down.
- [x] Make some pages optionally available publicly. A plugin should be able to set the permissions of a page (including the menu option) to publicly available. → A no-permission route/nav node is already anonymous-reachable; this **blesses** it as a first-class, explicit choice (per human pick: keep that default, add an explicit alias — not a secure-by-default flip). New optional **`public?: boolean`** on `Route` (`src/plugin.ts`) and `NavNode` (`src/nav.ts`) means "open to everyone, signed in or not" — honored explicitly in `isAuthorized` (`router.ts`) + `filterByRoles` (`nav.ts`), and **mutually exclusive with `permission`** (discovery `shapeError` recursively rejects a route/nav node setting both → fails the boot loud). `public` is filter-only (`toRenderNode` never emits it). The shell (`views/partials/shell.ejs`) now renders a **Sign in** link instead of the profile/sign-out block for an anonymous visitor, so a public page in the native shell (`ctx.chrome`, `ctx.user` may be null) isn't a broken "Guest / Sign out". Reference plugin demos it: a public `/scheduling` **Overview** route + public "Overview" nav child, with the shifts list still behind `scheduling:read` (so the "Scheduling" header now shows for everyone, the data doesn't). Hardened a latent gap the shell newly leans on: `claimsToUser` now rejects an **empty** email like it does an empty sub. Tests-first (348 → 354 units): router/nav/discovery (`public` open + reject-both + loads), shell (anonymous → Sign in, no logout form), app (public route anon-200), shifts (overview handler), jwt-middleware (empty email). Docs: `docs/plugin-contract.md` ("Public pages & menu items" + route shape + shape-error note) + README (menu system + reference snippet). E2E: `visual.spec` asserts the public Overview is anon-200 + shown in the member's nav while the gated Shifts redirects/filters. stability-reviewer: **APPROVE, no Critical/High/Medium** (addressed its one Low — the empty-email hardening). typecheck + 354 units + full `scripts/ci.sh` gate (visual 10 · auth 1 · oauth 2 · full 7) green.
- [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.