From 7787ed4ea483d8cf5e227e8b7cf8805d6a7e1c01 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sat, 20 Jun 2026 17:43:01 +0200 Subject: [PATCH] =?UTF-8?q?=C2=A710=20split=20landing=20into=20a=20public?= =?UTF-8?q?=20"/"=20+=20gated=20"/dashboard",=20both=20plugin-replaceable?= =?UTF-8?q?=20(todo=20=C2=A710=20follow-up);=20per=20human=20feedback,=20"?= =?UTF-8?q?/"=20is=20now=20an=20ungated=20public=20landing=20(default=20vi?= =?UTF-8?q?ews/home.ejs:=20brand=20+=20intro=20+=20prominent=20Log=20in=20?= =?UTF-8?q?/=20Create=20account=20links,=20or=20"go=20to=20dashboard"=20wh?= =?UTF-8?q?en=20signed=20in)=20and=20"/dashboard"=20is=20the=20gated=20pos?= =?UTF-8?q?t-login=20app=20home=20(anonymous=20=E2=86=92=20/login=3Freturn?= =?UTF-8?q?=5Fto=3D/dashboard).=20Both=20are=20fully=20replaceable=20via?= =?UTF-8?q?=20two=20optional=20RouteHandlers=20on=20PluginManifest=20?= =?UTF-8?q?=E2=80=94=20home=3F=20(public=20/)=20and=20dashboard=3F=20(gate?= =?UTF-8?q?d=20/dashboard)=20=E2=80=94=20rendered=20against=20the=20plugin?= =?UTF-8?q?'s=20own=20views=20with=20the=20native=20shell=20via=20ctx.chro?= =?UTF-8?q?me=20(full=20route=20parity:=20HEAD,=20void-return,=20response?= =?UTF-8?q?=20hooks,=20fresh=20CSRF=20cookie;=20a=20home=20handler=20is=20?= =?UTF-8?q?public=20so=20ctx.user=20may=20be=20null).=20Single-slot=20+=20?= =?UTF-8?q?loud:=20findConflicts=20errors=20on=20>1=20owner=20of=20either?= =?UTF-8?q?=20slot=20(new=20"home"/"dashboard"=20kinds),=20discovery=20rej?= =?UTF-8?q?ects=20a=20non-function=20handler,=20and=20"dashboard"=20is=20r?= =?UTF-8?q?eserved=20so=20a=20plugin=20folder=20can't=20shadow=20it=20("/"?= =?UTF-8?q?=20can't=20be=20shadowed=20=E2=80=94=20route=20paths=20carry=20?= =?UTF-8?q?the=20/=20prefix).=20Post-login=20+=20already-signed-in=20r?= =?UTF-8?q?edirects=20and=20the=20global=20Dashboard/People=20nav=20hrefs?= =?UTF-8?q?=20moved=20to=20/dashboard.=20Tests-first=20(348=20units):=20pu?= =?UTF-8?q?blic-/=20+=20gated-/dashboard=20+=20dual=20plugin-override=20in?= =?UTF-8?q?=20app.test;=20per-slot=20conflict=20in=20plugin.test;=20non-fu?= =?UTF-8?q?nction/reserved/two-owners=20in=20discovery.test.=20Docs:=20plu?= =?UTF-8?q?gin-contract=20"The=20landing=20pages"=20section=20+=20README.?= =?UTF-8?q?=20E2E:=20visual.spec=20plants=20a=20session=20for=20/dashboard?= =?UTF-8?q?=20design-system=20tests=20+=20a=20cookie-free=20public-landing?= =?UTF-8?q?=20test;=20full-flow=20repointed=20to=20/dashboard.=20stability?= =?UTF-8?q?-reviewer:=20APPROVE,=20no=20Critical/High/Medium.=20typecheck?= =?UTF-8?q?=20+=20348=20units=20+=20visual(10)=20+=20full-flow(7)=20green.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 ++++--- docs/plugin-contract.md | 56 ++++++++++++--------- e2e/full-flow.spec.ts | 9 ++-- e2e/visual.spec.ts | 36 +++++++++----- public/css/auth.css | 11 +++++ src/admin-nav.ts | 2 +- src/app.test.ts | 105 ++++++++++++++++++++++------------------ src/app.ts | 47 ++++++++++++------ src/chrome.ts | 2 +- src/dashboard.ts | 6 +-- src/discovery.test.ts | 10 ++-- src/discovery.ts | 7 ++- src/plugin.test.ts | 14 +++--- src/plugin.ts | 29 ++++++----- todo.md | 2 +- views/home.ejs | 43 ++++++++++++++++ 16 files changed, 262 insertions(+), 134 deletions(-) create mode 100644 views/home.ejs diff --git a/README.md b/README.md index 26ec87a..82f4ad2 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,9 @@ The bar for a first usable release: **clone, run one command, get a working register/login, and start building your own plugin** — no manual key generation, no hand-edited Ory config, no separate database. That command brings up the whole stack (web + Ory + Postgres), generates signing keys, seeds an admin on first boot, and drops -you at a login screen; from there you copy the example plugin folder and write your own -page. SSO and the OAuth2-provider role (Hydra) come after — not required to start. +you at a public landing page with a one-click path to sign in (the gated dashboard lives at +`/dashboard`); from there you copy the example plugin folder and write your own page. SSO and +the OAuth2-provider role (Hydra) come after — not required to start. ## Architecture @@ -361,9 +362,11 @@ hooks, and the dev/test story — is **[docs/plugin-contract.md](docs/plugin-con a CSRF-guarded form forwarding writes upstream, and permission-gated nav. Copy it and adapt. The sketch below is the shape. -The landing page `/` (the **dashboard**) is gated to a signed-in session; a plugin can **fully -replace** the built-in default by exporting a `home` handler in its manifest (one plugin may own it). -See the contract's [dashboard section](docs/plugin-contract.md#the-dashboard-home). +There are two replaceable landing slots: `/` is a **public** front page (default: an intro with +sign-in / register links) and `/dashboard` is the **gated** post-login app home (default: the People +list). A plugin owns either by exporting a `home` (public `/`) or `dashboard` (gated `/dashboard`) +handler — one owner each. See the contract's +[landing pages section](docs/plugin-contract.md#the-landing-pages-home--dashboard). ``` plugins/scheduling/ # folder name = the plugin id; mounted at /scheduling @@ -745,7 +748,7 @@ 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 "/" People list view model (mock data, wires the §1 helpers); "/" is gated to a session and replaceable by a plugin `home` handler (§10) +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/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 @@ -765,7 +768,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: index (app-shell dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), 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: 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), 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) 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 bb82197..a0936e5 100644 --- a/docs/plugin-contract.md +++ b/docs/plugin-contract.md @@ -43,11 +43,11 @@ anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malfo at discovery. The id also namespaces the plugin's `views/`, its `/public//` assets, and (by convention) its nav/permission tokens. -A handful of ids are **reserved** for the host's own first-party mounts — the Kratos auth flows -(`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`), the `admin` -screens, the `oauth2` provider routes, and `public` (static). Since plugin routes resolve first, a -folder claiming one would silently shadow a built-in route, so discovery refuses it loud -(`RESERVED_PLUGIN_IDS`). +A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the +Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`), +the `admin` screens, the `oauth2` provider routes, and `public` (static). Since plugin routes resolve +first, a folder claiming one would silently shadow a built-in route, so discovery refuses it loud +(`RESERVED_PLUGIN_IDS`). (`/` is owned by the `home` field, not a route, so it needs no reservation.) Installing a plugin is "drop the folder, restart." Removing one is "delete the folder, restart." Nothing else references it; the operator stays in control through the central menu override @@ -98,7 +98,8 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder | Field | Required | Notes | | --- | --- | --- | | `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). | -| `home` | no | A `RouteHandler` that owns the dashboard `/` (the post-login landing page). At most one plugin may declare it. See [The dashboard](#the-dashboard-home). | +| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | +| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/icons.ts`); node `id`s must be globally unique. | | `permissions` | no | Tokens this plugin introduces; declared for docs, conflict detection, and bootstrap seeding (see [Nav & permissions](#nav--permissions)). | | `routes` | no | See [Routes & handlers](#routes--handlers). | @@ -176,32 +177,43 @@ safety of the data it renders**: return { view: "list", data: { rows: rows.map((r) => ({ ...r, href: safeUrl(r.href) })) } }; ``` -## The dashboard ("home") +## The landing pages (`home` & `dashboard`) -`/` is the **post-login landing page**. The host gates it to a **signed-in session** (an anonymous -visitor is redirected to `/login`) and, by default, renders a built-in mock-data dashboard. A plugin -**fully replaces** it by exporting a `home` handler: +The host has two replaceable landing slots, and a plugin may own either or both: + +| Slot | Path | Gate | Default | +| --- | --- | --- | --- | +| `home` | `/` | **public** — anyone | An intro page with prominent sign-in / register links. | +| `dashboard` | `/dashboard` | **signed-in session** (anonymous → `/login`, with `/dashboard` as `return_to`) | The built-in mock-data People list. | ```ts import { definePlugin } from "../../src/plugin-api.ts"; -import { dashboard } from "./dashboard.ts"; +import { landing, board } from "./pages.ts"; export default definePlugin({ apiVersion: "1.0.0", - home: dashboard, // owns "/" — the post-login landing page + home: landing, // owns "/" — the public front page + dashboard: board, // owns "/dashboard" — the post-login app home }); ``` -`home` is a `RouteHandler` like any route's — it receives the [`RequestContext`](#requestcontext) -and returns a `RouteResult`, typically a `view` rendered from the plugin's own `views/` against the -native app shell (`ctx.chrome`), exactly as a route handler does. The host enforces the session gate -first, so `ctx.user` is non-null; branch on `ctx.roles` *inside* to tailor the page per role. Don't -gate `home` itself behind a single permission — there's no second dashboard to fall back to, so a -user lacking it would land on a 403 instead of a home page. (`GET /` also answers `HEAD`.) +Each is a `RouteHandler` like any route's — it receives the [`RequestContext`](#requestcontext) and +returns a `RouteResult`, typically a `view` from the plugin's own `views/`. A `dashboard` handler +renders against the native app shell via `ctx.chrome` exactly as a route handler does; a `home` +handler is a **public** page, so `ctx.user` may be `null` (use it to show a "go to dashboard" link to +a signed-in visitor, or sign-in / register to an anonymous one). After login the user lands on +`/dashboard` (or the `return_to` they were headed to), and the global menu's **Dashboard** link +points there. -Only **one** plugin may own the dashboard: two declaring `home` is a boot-stopping conflict -([below](#conflict-rules)), never last-write-wins. The plugin needs no `routes` entry for `/` — the -host mounts `home` at the root, above the `/` route namespace. +For the gated `dashboard`, the host enforces the session gate first, so `ctx.user` is non-null; +branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a +single permission — there's no second dashboard to fall back to, so a user lacking it would land on a +403. (Both slots answer `GET` and `HEAD`.) + +Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a +boot-stopping conflict ([below](#conflict-rules)), never last-write-wins. Neither needs a `routes` +entry — the host mounts them above the `/` route namespace, and `/` can't be shadowed by a plugin +route at all (route paths always carry the `/` prefix). ## RequestContext @@ -306,7 +318,7 @@ with `findConflicts` and resolves them **loudly — never last-write-wins**. `er | `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. | | `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/` prefix is unique), so this catches a plugin duplicating one of its own. | | `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. | -| `home` | error | More than one plugin declares `home`. The dashboard `/` is a single slot, so only one may own it ([The dashboard](#the-dashboard-home)). | +| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). | | `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `:` if unintended. | There is **no separate `basePath` rule**: the mount path is the derived `/`, so its diff --git a/e2e/full-flow.spec.ts b/e2e/full-flow.spec.ts index 32282be..b00d2a6 100644 --- a/e2e/full-flow.spec.ts +++ b/e2e/full-flow.spec.ts @@ -39,7 +39,7 @@ test.describe.serial("authenticated admin journey", () => { test("menu filters by role: an admin sees the gated Admin section + the plugin", async () => { // The signed-in admin holds admin + scheduling:read/write, so both gated sections are present // in the menu (collapsed by default → assert they're in the DOM, not necessarily visible). - await page.goto("/"); + await page.goto("/dashboard"); await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1); await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1); }); @@ -92,12 +92,13 @@ test.describe.serial("authenticated admin journey", () => { }); test("logout: signing out ends the session and returns to the login page", async () => { - await page.goto("/"); + await page.goto("/dashboard"); await page.locator("summary.profile").click(); // open the profile dropdown await page.locator('form[action="/logout"] button[type="submit"]').click(); await page.waitForURL(/\/login(\?|$)/); - // The session is gone: the dashboard no longer shows the admin nav. - await page.goto("/"); + // The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav). + await page.goto("/dashboard"); + await expect(page).toHaveURL(/\/login(\?|$)/); await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0); }); }); diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts index ca199fd..64b2833 100644 --- a/e2e/visual.spec.ts +++ b/e2e/visual.spec.ts @@ -35,20 +35,20 @@ test.beforeEach(async ({ context }) => { }); test("captures live pages + reference mockups for side-by-side review", async ({ page }) => { - await page.goto("/"); + await page.goto("/dashboard"); await expect(page.locator(".sidebar")).toBeVisible(); await expect(page.locator("table.table tbody tr").first()).toBeVisible(); await shot(page, "live-01-dashboard"); - await page.goto("/?sort=-name&status=active"); + await page.goto("/dashboard?sort=-name&status=active"); await shot(page, "live-02-sorted-filtered"); - await page.goto("/"); + await page.goto("/dashboard"); await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio await shot(page, "live-03-dark"); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto("/"); + await page.goto("/dashboard"); await shot(page, "live-04-mobile"); await page.setViewportSize({ width: 1280, height: 800 }); @@ -68,7 +68,7 @@ const styleOf = (page: Page, selector: string): Promise> }, PROPS as unknown as string[]); test("live components compute the same design-system styles as the reference mockup", async ({ page, context }) => { - await page.goto("/"); + await page.goto("/dashboard"); const ref = await context.newPage(); await ref.goto(APP_SHELL); @@ -79,7 +79,7 @@ test("live components compute the same design-system styles as the reference moc }); test("every icon resolves to a defined (no broken graphics)", async ({ page }) => { - await page.goto("/"); + await page.goto("/dashboard"); const missing = await page.evaluate(() => { const ids = new Set([...document.querySelectorAll("symbol[id]")].map((s) => s.id)); return [...document.querySelectorAll("use")] @@ -90,14 +90,14 @@ test("every icon resolves to a defined (no broken graphics)", asy }); test("sorting and search drive the list through the URL (zero-JS)", async ({ page }) => { - await page.goto("/"); + 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("/"); + 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/); @@ -105,7 +105,7 @@ test("sorting and search drive the list through the URL (zero-JS)", async ({ pag }); test("theme switch flips the palette with no JavaScript", async ({ page }) => { - await page.goto("/"); + await page.goto("/dashboard"); const light = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); await page.locator("#theme-dark").check({ force: true }); const dark = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); @@ -114,7 +114,7 @@ test("theme switch flips the palette with no JavaScript", async ({ page }) => { test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); - await page.goto("/"); + await page.goto("/dashboard"); await expect(page.locator(".hamburger")).toBeVisible(); const offCanvas = await page.locator(".sidebar").evaluate((el) => { @@ -125,10 +125,10 @@ test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ }); test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => { - await page.goto("/"); + await page.goto("/dashboard"); // The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit). const cookie = (await page.context().cookies()).find((c) => c.name === "plainpages_csrf"); - expect(cookie?.value, "GET / issues a plainpages_csrf cookie").toBeTruthy(); + expect(cookie?.value, "GET /dashboard issues a plainpages_csrf cookie").toBeTruthy(); const field = await page.locator('form[action="/logout"] input[name="_csrf"]').getAttribute("value"); expect(field).toBe(cookie!.value); @@ -137,6 +137,16 @@ test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a t expect(res.status()).toBe(403); }); +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.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"); +}); + test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => { const res = await page.goto("/no-such-page"); expect(res?.status()).toBe(404); @@ -156,7 +166,7 @@ test("the reference plugin is permission-gated: anonymous → redirect to /login expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts"); // The signed-in member (no scheduling role) sees the dashboard, but the gated leaf is filtered out. - await page.goto("/"); + await page.goto("/dashboard"); await expect(page.locator(".sidebar")).toContainText("People"); // dashboard nav renders await expect(page.locator(".sidebar")).not.toContainText("Scheduling"); // gated leaf filtered out }); diff --git a/public/css/auth.css b/public/css/auth.css index 501c6bc..931c1eb 100644 --- a/public/css/auth.css +++ b/public/css/auth.css @@ -23,6 +23,17 @@ } .auth-brand .brand-name { font-size: 16px; } +/* public landing — the ungated "/" (§10): centered intro + prominent sign-in/register actions */ +.landing { + width: 100%; max-width: 560px; + display: flex; flex-direction: column; align-items: center; gap: 18px; + text-align: center; +} +.landing-title { margin: 6px 0 0; font-size: 27px; font-weight: 700; letter-spacing: -.02em; } +.landing-lead { margin: 0; max-width: 480px; color: var(--text-muted); font-size: var(--fz); line-height: 1.65; } +.landing-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 4px; } +.landing-actions .btn { height: 40px; padding: 0 20px; font-size: var(--fz); } + /* ---- screen switching via :target (login is the default) ---- */ .auth-view { display: none; } #login { display: block; } diff --git a/src/admin-nav.ts b/src/admin-nav.ts index 8f0da9f..d5a99f0 100644 --- a/src/admin-nav.ts +++ b/src/admin-nav.ts @@ -43,7 +43,7 @@ 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([[ - { href: "/", icon: "i-grid", id: "dashboard", label: "Dashboard" }, + { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" }, adminSection(current), ]], menu.override, roles); } diff --git a/src/app.test.ts b/src/app.test.ts index 119d30f..95e30aa 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -47,9 +47,9 @@ before(async () => { after(() => server.close()); -test("serves the home page: the app-shell People dashboard, filterable via the URL", async () => { +test("the dashboard at /dashboard: the app-shell People list, gated to a session, filterable via the URL", async () => { // The dashboard is gated to a signed-in user (§10), so present a session. - const res = await fetch(base + "/", { headers: { cookie: 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(); @@ -63,33 +63,44 @@ test("serves the home page: the app-shell People dashboard, filterable via the U // 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]; - assert.ok(csrfCookie, "GET / issues a CSRF cookie"); + assert.ok(csrfCookie, "GET /dashboard issues a CSRF cookie"); assert.match(res.headers.get("set-cookie") ?? "", /plainpages_csrf=[^;]+;.*HttpOnly/); assert.match(html, /