§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:
@@ -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 /
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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><x><\/title>/); // user text is escaped
|
assert.match(escaped, /<title><x><\/title>/); // user text is escaped
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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/<id>/views/<view>.ejs, rendered in this same shell
|
||||||
|
dashboard: (ctx) => ({ 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,
|
||||||
|
|||||||
@@ -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
@@ -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><% } %><% }) %>
|
||||||
|
|||||||
Reference in New Issue
Block a user