diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md index dde3456..616e37e 100644 --- a/docs/plugin-contract.md +++ b/docs/plugin-contract.md @@ -21,8 +21,9 @@ time, not in production. > reachable via `include()`), **per-plugin static serving** (`/public//` → the plugin's > `public/`, `routePublic` in `src/static.ts`), and the **central menu override + branding** > (`config/menu.ts`, loaded by `src/menu-config.ts`, with branding — name, logo, default theme — -> rendered in the app shell) are wired. The §2 plugin host is feature-complete; the remaining §2 -> items are a project-wide review and comment/test cleanup. +> rendered in the app shell) are wired and in use by the built-in screens and the reference plugin. +> Later phases extended this contract: the replaceable [landing pages](#the-landing-pages-home--dashboard) +> and [public pages & menu items](#public-pages--menu-items) (§10), both documented below. ## Anatomy of a plugin @@ -237,11 +238,16 @@ interface RequestContext { } ``` -**`ctx.chrome`** is the page chrome the host builds per request — `{ brand, csrfToken, nav, theme, -user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same +**`ctx.chrome`** is the page chrome the host builds per request — `{ brand, csrfToken, nav, signInHref, +theme, user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same sidebar, branding, theme switch and signed-in profile as the built-in screens); `chrome.nav` is the global menu — your plugin's nav fragment plus the others and the admin section — already composed, -role-filtered, and current-marked for this request. **`ctx.verifyCsrf(submitted)`** guards a +role-filtered, and current-marked for this request (the gated **Dashboard** link is omitted for an +anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the +current page baked in as `return_to`. Map each `chrome.*` to the matching `partials/shell` local — +`brand`, `csrfToken`, `nav` (the rendered nav-tree), `signInHref`, `theme`, `user` — exactly as the +reference `plugins/scheduling/views/overview.ejs` does; a value you forget simply falls back to its +shell default (e.g. a bare `/login`), it does not error. **`ctx.verifyCsrf(submitted)`** guards a state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then on POST read your own 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/`.) @@ -280,8 +286,9 @@ accident of a forgotten gate**. `public` and `permission` are **mutually exclusi both is contradictory and discovery refuses the plugin at boot. A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor -`ctx.user` is `null`, the shell shows a **Sign in** link in place of the profile/sign-out block, and -`ctx.roles` is empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling` +`ctx.user` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page) +in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.roles` is +empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling` **Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone, while the actual shifts list stays behind `scheduling:read`. diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts index 6ec72d1..9c66709 100644 --- a/e2e/visual.spec.ts +++ b/e2e/visual.spec.ts @@ -164,7 +164,12 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red // The public overview is reachable with no session (200), not bounced to sign in. const pub = await request.get("/scheduling", { maxRedirects: 0 }); expect(pub.status()).toBe(200); - expect(await pub.text()).toContain("Scheduling"); + const body = await pub.text(); + expect(body).toContain("Scheduling"); + // Anonymous in the native shell (§10): the gated Dashboard link is hidden (it would only dead-end at + // /login), and the shell's Sign-in link carries the current page as return_to. + expect(body).not.toContain('href="/dashboard"'); + expect(body).toContain('href="/login?return_to=%2Fscheduling"'); // The gated shifts list still bounces (don't follow — this Ory-free suite has no /login handler); // assert the gate's 303 with the requested page preserved as return_to (§9). diff --git a/plugins/scheduling/shifts.test.ts b/plugins/scheduling/shifts.test.ts index 8019c76..5888ce3 100644 --- a/plugins/scheduling/shifts.test.ts +++ b/plugins/scheduling/shifts.test.ts @@ -10,7 +10,7 @@ import { SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate, } from "./shifts.ts"; -const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], user: { email: "", initials: "T", name: "Tester" } }; +const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } }; function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { const url = new URL(opts.url ?? "http://localhost/scheduling/shifts"); diff --git a/plugins/scheduling/views/overview.ejs b/plugins/scheduling/views/overview.ejs index 95e5c2e..1bbc73a 100644 --- a/plugins/scheduling/views/overview.ejs +++ b/plugins/scheduling/views/overview.ejs @@ -17,6 +17,7 @@ breadcrumbs, csrfToken: chrome.csrfToken, nav: navHtml, + signInHref: chrome.signInHref, styles: ["/public/scheduling/scheduling.css"], theme: chrome.theme, title, diff --git a/src/admin-nav.ts b/src/admin-nav.ts index d5a99f0..22e8931 100644 --- a/src/admin-nav.ts +++ b/src/admin-nav.ts @@ -20,6 +20,11 @@ export const ADMIN_CLIENTS_BASE = "/admin/clients"; export type AdminScreen = "clients" | "groups" | "roles" | "users"; +// The "Dashboard" link to the gated app home (/dashboard). One definition, reused by the in-screen +// admin sidebar and the plugin-page chrome (chrome.ts) so the two can't drift. It targets a gated +// route, so the chrome hides it from anonymous visitors (a non-signed-in click only dead-ends at /login). +export const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" }; + const ITEMS: { href: string; icon: string; id: AdminScreen; label: string }[] = [ { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" }, { href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" }, @@ -42,10 +47,7 @@ export function adminSection(current?: AdminScreen): NavNode { // In-screen sidebar for the admin screens: a link home + the admin section (active item marked). export function adminNav(roles: string[], menu: MenuConfig, current: AdminScreen): NavNode[] { - return composeNav([[ - { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" }, - adminSection(current), - ]], menu.override, roles); + 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 diff --git a/src/app.test.ts b/src/app.test.ts index 3b64b35..da8c6f9 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -60,6 +60,7 @@ test("the dashboard at /dashboard: the app-shell People list, gated to a session assert.match(html, / verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted }); - // Chrome (brand/global-nav/user/theme/csrf) is built lazily and at most once per request — - // only plugin routes (and an onRequest short-circuit) read it, so the hot path stays free and - // a matched plugin request doesn't re-compose the whole menu for the onRequest + route ctx. + // Chrome (brand/global-nav/user/theme/csrf) composes the whole menu, so it's resolved lazily and + // at most once per request: this app-level memo shares it across the contexts below, and each + // ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler, + // or the public "/" with a standalone home, never composes the menu). let chromeMemo: PageChrome | undefined; const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user })); - // base context (no route params yet); reused for onRequest. - const ctx = buildContext(req, res, { - log: reqLog, user, verifyCsrf, - ...(anyRequestHooks ? { chrome: chrome() } : {}), - }); + // base context (no route params yet); reused for onRequest + the built-in admin screens. + const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf }); // Plugin onRequest hooks run before routing and may short-circuit the request. if (anyRequestHooks) { @@ -192,7 +190,7 @@ export function createApp(options: AppOptions = {}): Server { // CSRF cookie is set so those forms have a valid double-submit token. const match = matchRoute(plugins, method, pathname); if (match) { - const routeCtx = buildContext(req, res, { chrome: chrome(), log: reqLog, params: match.params, user, verifyCsrf }); + const routeCtx = buildContext(req, res, { chrome, log: reqLog, params: match.params, user, verifyCsrf }); if (!isAuthorized(match.route, routeCtx.roles)) { // Anonymous → sign in (like the built-in screens' requireSession), remembering the page as // return_to; a signed-in user who simply lacks the role gets the 403 page. @@ -440,7 +438,7 @@ export function createApp(options: AppOptions = {}): Server { // any form it ships). Else the built-in intro page with prominent sign-in / register links. if (homePlugin) { if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); - const homeCtx = buildContext(req, res, { chrome: chrome(), log: reqLog, user, verifyCsrf }); + const homeCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf }); const result = (await homePlugin.home(homeCtx)) ?? null; if (anyResponseHooks) await runResponseHooks(plugins, homeCtx, result); await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data)); @@ -460,7 +458,7 @@ export function createApp(options: AppOptions = {}): Server { // A plugin may fully own the dashboard (§10): render its handler against its own views, native // shell via ctx.chrome — same path as a plugin route. Else the built-in mock-data People list. if (dashboardPlugin) { - const dashCtx = buildContext(req, res, { chrome: chrome(), log: reqLog, user, verifyCsrf }); + const dashCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf }); const result = (await dashboardPlugin.dashboard(dashCtx)) ?? null; if (anyResponseHooks) await runResponseHooks(plugins, dashCtx, result); await sendResult(res, result, (view, data) => renderView(dashboardPlugin.id, view, data)); diff --git a/src/chrome.test.ts b/src/chrome.test.ts index c6d98ec..803333d 100644 --- a/src/chrome.test.ts +++ b/src/chrome.test.ts @@ -13,22 +13,32 @@ const scheduling: Plugin = { icon: "i-cal", id: "scheduling", label: "Scheduling", }], }; +// A plugin with a public nav node (reachable by anyone, signed in or not). +const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] }; const labels = (nodes: NavNode[]): string[] => nodes.map((n) => n.label); -test("anonymous: brand from menu, Guest user, gated plugin + admin nav filtered out", () => { - const chrome = buildPluginChrome({ menu: DEFAULT_MENU, plugins: [scheduling] }); +test("anonymous: brand from menu, Guest user; the gated Dashboard link is hidden, a public node still shows", () => { + const chrome = buildPluginChrome({ menu: DEFAULT_MENU, plugins: [scheduling, portal] }); assert.equal(chrome.brand.name, DEFAULT_MENU.branding.name); assert.equal(chrome.user.name, "Guest"); - assert.deepEqual(labels(chrome.nav), ["Dashboard"]); // Scheduling (gated child) + Admin dropped + // Dashboard points at the gated /dashboard — showing it to an anonymous visitor only dead-ends them + // at /login, so it's dropped. Scheduling's only child is gated (dropped), admin gated (dropped); + // the explicitly public Portal node remains. + assert.deepEqual(labels(chrome.nav), ["Portal"]); }); -test("a permission holder sees the plugin nav; current path opens the active leaf", () => { +test("anonymous shell Sign-in link carries the current page as return_to", () => { + assert.equal(buildPluginChrome({ menu: DEFAULT_MENU }).signInHref, "/login"); // no path known + assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal"); +}); + +test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => { const chrome = buildPluginChrome({ currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling], user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] }, }); - assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); + assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user const section = chrome.nav.find((n) => n.label === "Scheduling")!; assert.equal(section.open, true); // ancestor of the current leaf opened assert.equal(section.children!.find((c) => c.label === "Shifts")!.current, true); diff --git a/src/chrome.ts b/src/chrome.ts index c04d178..a3fafad 100644 --- a/src/chrome.ts +++ b/src/chrome.ts @@ -4,7 +4,7 @@ // nav is the global menu — Dashboard + every plugin's fragment + the gated admin section — run // through composeNav (override + per-user filter) and current-marked for the request path. -import { adminSection } from "./admin-nav.ts"; +import { adminSection, DASHBOARD_NAV } from "./admin-nav.ts"; import type { User } from "./context.ts"; import { type MenuConfig } from "./menu-config.ts"; import { composeNav, type NavNode } from "./nav.ts"; @@ -15,12 +15,11 @@ export interface PageChrome { brand: { logo?: string; name: string; sub?: string }; csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms nav: NavNode[]; // global menu, composed + role-filtered + current-marked, ready for nav-tree.ejs + signInHref: string; // where the shell's anonymous "Sign in" link points — carries this page as return_to theme?: string; user: ShellUser; } -const HOME: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" }; - export interface ChromeOptions { csrfToken?: string; currentPath?: string; // request pathname; the matching nav leaf is marked current @@ -30,7 +29,9 @@ export interface ChromeOptions { } export function buildPluginChrome(opts: ChromeOptions): PageChrome { - const fragments: NavNode[][] = [[HOME]]; + // The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an + // anonymous visitor (a public page in the shell, §10) it would only dead-end at /login. + const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : []; for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav); fragments.push([adminSection()]); @@ -43,6 +44,8 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome { brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: b.sub } : {}) }, csrfToken: opts.csrfToken ?? "", nav, + // Anonymous "Sign in" returns to the current page (it's host-relative, our own pathname). + signInHref: opts.currentPath ? `/login?return_to=${encodeURIComponent(opts.currentPath)}` : "/login", ...(b.theme != null ? { theme: b.theme } : {}), user: shellUser(opts.user), }; diff --git a/src/context.ts b/src/context.ts index 5f63d76..0a22c6d 100644 --- a/src/context.ts +++ b/src/context.ts @@ -35,7 +35,10 @@ export interface RequestContext { } export interface BuildContextOptions { - chrome?: PageChrome; + // Lazy chrome factory: composing the global menu is only paid for if the handler actually reads + // ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing). + // The host's factory is memoised, so the menu composes at most once per request across contexts. + chrome?: () => PageChrome; log?: Log; params?: Record; user?: User | null; @@ -43,7 +46,7 @@ export interface BuildContextOptions { } // Anonymous default chrome — used until the host supplies a real one (built-in routes, tests). -const ANON_CHROME: PageChrome = { brand: { name: "Plainpages" }, csrfToken: "", nav: [], user: { email: "", initials: "G", name: "Guest" } }; +const ANON_CHROME: PageChrome = { brand: { name: "Plainpages" }, csrfToken: "", nav: [], signInHref: "/login", user: { email: "", initials: "G", name: "Guest" } }; // Silent default logger — used off the request path (built-in routes built ad hoc, tests) until the // host supplies the real request logger. One instance, no output, negligible cost. const SILENT_LOG = createLogger({ level: "none" }); @@ -55,8 +58,10 @@ export function buildContext( ): RequestContext { const url = new URL(req.url ?? "/", "http://localhost"); const user = options.user ?? null; + const buildChrome = options.chrome; + let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context return { - chrome: options.chrome ?? ANON_CHROME, + get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); }, log: options.log ?? SILENT_LOG, params: options.params ?? {}, query: url.searchParams, diff --git a/src/dashboard.ts b/src/dashboard.ts index 85d1c54..d16291b 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -1,7 +1,8 @@ // Dashboard view model (todo §1): the gated "/dashboard" app-shell "People" list. Pure — turns a // request URL into the data the building-block partials render, wiring the §1 helpers end-to-end: -// parseListQuery → filter/sort/paginate a mock dataset → composeNav. Mock data stands in for -// upstream until §4; the filter form, sortable headers and pager all round-trip the URL (zero-JS). +// parseListQuery → filter/sort/paginate a mock dataset → composeNav. This is the built-in *demo* home +// over mock data (a plugin owns the real one via a `dashboard` handler, §10); the filter form, +// sortable headers and pager all round-trip the URL (zero-JS). import { adminSection } from "./admin-nav.ts"; import type { User } from "./context.ts"; @@ -122,8 +123,8 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles: export type DashboardModel = ReturnType; // Sidebar: the demo "Directory" fragment, then each discovered plugin's own nav fragment (so a -// plugin is reachable from "/"; gated nodes stay invisible to non-admins), then the gated admin -// section. composeNav applies the central override + per-user role filter. +// 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([[ diff --git a/src/plugin.test.ts b/src/plugin.test.ts index 8286255..fac5aa8 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -7,9 +7,12 @@ import { HOST_API_VERSION, isValidPluginId, parseSemver, + RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest, } from "./plugin.ts"; +import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "./admin-nav.ts"; +import { AUTH_FLOWS } from "./flow-view.ts"; // A representative manifest exercising every field — its existence type-checks the contract. // `apiVersion` is a literal: a plugin pins the version it was built against, so importing @@ -109,3 +112,23 @@ test("findConflicts: each single slot (`home`/`dashboard`) may have one owner // One owner of each (even both on one plugin) is fine. assert.deepEqual(findConflicts([p({ id: "a", dashboard: handler, home: handler }), p({ id: "b" })]).filter((c) => c.kind === "home" || c.kind === "dashboard"), []); }); + +// Drift guard: RESERVED_PLUGIN_IDS is a hand-maintained mirror of the host's own top-level mounts — +// a folder claiming one would silently shadow a built-in route. Derive the segments from the real +// route constants so adding a new auth flow or admin screen without reserving its id fails here. +test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field) is NOT reserved", () => { + const seg = (path: string): string => path.split("/")[1] ?? ""; // first segment of "/x/y" + const builtins = new Set([ + ...Object.keys(AUTH_FLOWS).map(seg), // /login, /recovery, /registration, /settings, /verification + seg(ADMIN_USERS_BASE), seg(ADMIN_GROUPS_BASE), seg(ADMIN_ROLES_BASE), seg(ADMIN_CLIENTS_BASE), // → admin + "auth", // /auth/complete (login completion) + "logout", // POST /logout + "oauth2", // /oauth2/login · /consent · /logout (Hydra provider) + "dashboard", // the gated app home (§10) + "public", // static assets + ]); + for (const id of builtins) assert.ok(RESERVED_PLUGIN_IDS.has(id), `built-in mount "${id}" must be a reserved plugin id`); + // "/" is owned by the `home` manifest field (not a / route), so it cannot be shadowed and is + // deliberately not reserved — a plugin folder named "home" is legal. + assert.equal(RESERVED_PLUGIN_IDS.has("home"), false); +}); diff --git a/src/shell-context.test.ts b/src/shell-context.test.ts index 6d55dc4..e7d2e69 100644 --- a/src/shell-context.test.ts +++ b/src/shell-context.test.ts @@ -14,11 +14,13 @@ test("buildShellContext maps branding + breadcrumbs, omitting unset optional fie assert.equal(bare.theme, undefined); assert.equal(bare.csrfToken, ""); assert.equal(bare.user.name, "Guest"); + assert.equal(bare.signInHref, undefined); // omitted unless supplied (a public built-in screen would set it) const full = buildShellContext({ breadcrumbs: [{ href: "/", label: "Home" }, { label: "Users" }], csrfToken: "tok.sig", menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} }, + signInHref: "/login?return_to=%2Fx", title: "Users", user: { email: "a@b.c", id: "u1", roles: ["admin"] }, }); @@ -26,4 +28,5 @@ test("buildShellContext maps branding + breadcrumbs, omitting unset optional fie assert.equal(full.theme, "dark"); assert.equal(full.csrfToken, "tok.sig"); assert.equal(full.breadcrumbs?.length, 2); + assert.equal(full.signInHref, "/login?return_to=%2Fx"); }); diff --git a/src/shell-context.ts b/src/shell-context.ts index eef021d..91ec29a 100644 --- a/src/shell-context.ts +++ b/src/shell-context.ts @@ -18,6 +18,7 @@ export interface ShellModel { brand: { logo?: string; name: string; sub?: string }; breadcrumbs?: { href?: string; label: string }[]; csrfToken: string; + signInHref?: string; // anonymous "Sign in" target (mirrors PageChrome.signInHref); a gated screen omits it theme?: string; title: string; user: ShellUser; @@ -33,6 +34,7 @@ export function buildShellContext(opts: { breadcrumbs?: { href?: string; label: string }[]; csrfToken?: string; menu: MenuConfig; + signInHref?: string; title: string; user?: User | null; }): ShellModel { @@ -41,6 +43,7 @@ export function buildShellContext(opts: { brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: b.sub } : {}) }, ...(opts.breadcrumbs ? { breadcrumbs: opts.breadcrumbs } : {}), csrfToken: opts.csrfToken ?? "", + ...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}), ...(b.theme != null ? { theme: b.theme } : {}), title: opts.title, user: shellUser(opts.user), diff --git a/src/shell.test.ts b/src/shell.test.ts index 2c7054e..2fcd93b 100644 --- a/src/shell.test.ts +++ b/src/shell.test.ts @@ -44,9 +44,13 @@ test("app shell renders sidebar, topbar and the content slot", async () => { }); test("app shell offers Sign in (not Sign out) to an anonymous visitor — so a public page in the shell works (§10)", async () => { - const html = await render({ title: "Overview", brand: { name: "Acme" }, nav: "", body: "x" }); // no user → Guest - assert.match(html, /href="\/login"[^>]*>[\s\S]*?Sign in/); // a path to sign in + const html = await render({ title: "Overview", brand: { name: "Acme" }, nav: "", body: "x" }); // no user, no signInHref → default + assert.match(html, /href="\/login"[^>]*>[\s\S]*?Sign in/); // a path to sign in (default target) assert.doesNotMatch(html, /action="\/logout"/); // a guest has no session to end + + // 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" }); + assert.match(withReturn, /href="\/login\?return_to=%2Fscheduling"[^>]*>[\s\S]*?Sign in/); }); test("app shell renders a configured logo + default theme, falls back to the brand mark", async () => { diff --git a/views/index.ejs b/views/index.ejs index 2213bd8..fb4fad7 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -5,6 +5,9 @@ sortable headers and pager are real GET links — q/sort/page round-trip the URL, zero-JS. %><% 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) — + // say so up front so the inert affordances read as a starter to replace, not a half-built product. + 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" }); const filters = include("partials/filter-bar", model.filterBar); const table = include("partials/data-table", model.table); const pager = include("partials/pagination", model.pagination); @@ -14,7 +17,7 @@ -%> <%- include("partials/shell", { actions, - body: filters + table + pager, + body: note + filters + table + pager, brand: model.shell.brand, breadcrumbs: model.shell.breadcrumbs, csrfToken: model.shell.csrfToken, diff --git a/views/partials/shell.ejs b/views/partials/shell.ejs index bb2c9c8..28c10cf 100644 --- a/views/partials/shell.ejs +++ b/views/partials/shell.ejs @@ -4,7 +4,8 @@ `actions` (topbar buttons), `body` (page content); `styles` is an optional array of extra stylesheet hrefs (e.g. a plugin's own /public//x.css). Text locals: `title`, `brand` ({ name, logo?, sub? } — logo image else the default mark), `theme` (default for the - theme-switch), `user`, `breadcrumbs`, `csrfToken` (the Sign-out POST form's hidden field). + theme-switch), `user`, `breadcrumbs`, `csrfToken` (the Sign-out POST form's hidden field), + `signInHref` (anonymous "Sign in" target, carries return_to; default /login). Branding comes from config/menu.ts; `user`/`csrfToken` from §4 auth. %><% const title = locals.title || "Plainpages"; @@ -66,8 +67,9 @@ <% } else { %> - <%# anonymous (a public page in the shell, §10): no session to end — offer a way in instead %> - Sign in + <%# 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. %> + Sign in <% } %> <%- include("menu", {