From 777c0bd86dfe0b759f59e1741dfb90afefe8927b Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 8 Nov 2026 19:17:57 +0100 Subject: [PATCH 1/8] Let a plugin add links to the profile menu, and list the setting conflict kind --- AGENTS.md | 4 ++-- CHANGELOG.md | 6 ++++++ README.md | 10 +++++---- docs/reading-cost.md | 17 +++++++++++++++ e2e-tests/full-flow.spec.ts | 5 +++-- examples/plugins/scheduling/plugin.ts | 3 +++ src/function-limits.test.ts | 1 - src/plugin-host/discovery.test.ts | 4 ++++ src/plugin-host/discovery.ts | 20 ++++++++++++++---- src/plugin-host/plugin-api.ts | 2 +- src/plugin-host/plugin.test.ts | 8 +++---- src/plugin-host/plugin.ts | 5 +++++ src/ui/chrome.test.ts | 26 +++++++++++++++++++++++ src/ui/chrome.ts | 30 ++++++++++++++++++++++++++- src/ui/shell.test.ts | 14 +++++++++++++ todo.md | 13 ------------ views/partials/shell.ejs | 7 +++++-- 17 files changed, 141 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 72f5132..f7297d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -288,8 +288,8 @@ Revisit only if the stated reason stops holding. declarative attribute's alone** — open a panel from script and no engine applies it, so "enhancing" one of these triggers is what would cost it its accessibility. `
` stays where it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the - profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep - the two in step. + profile menu (its trigger composes escaped user values and its Sign-out item is a CSRF POST form; + the plugin-declared links above it are `user.menu`) — keep the two in step. - **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s `overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame: they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` — diff --git a/CHANGELOG.md b/CHANGELOG.md index 2616f22..00e1094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,12 @@ Call `buildPaginationModel(page, hrefFor, { label?, pageSizes?, pageParam?, page `hrefFor(n)` is your list's URL on page n, and the rows-per-page form carries the same list state as the links. A list that renamed its params in `parseListQuery` passes the same names here. +### A plugin can add links to the profile menu + +Declare `profileMenu: [{ href, label, icon?, …gate }]` in the manifest. Each entry names exactly one +gate like a nav node, and shows above Sign out to a signed-in visitor who passes it. Discovery +refuses an entry with no `href` or no gate. + ### A plugin can version its own `/public/` files Wrap the href of a script, image or other file your own markup links in `assetHref("/public//…")` in a view, diff --git a/README.md b/README.md index 36655fd..fb6da2d 100644 --- a/README.md +++ b/README.md @@ -468,6 +468,7 @@ folder-derived `id` to produce the loaded `Plugin`. | `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. | | `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). | +| `profileMenu` | no | `ProfileMenuEntry[]`: `{ href, label, icon?, …gate }` links added above Sign out in the profile dropdown, shown only to a signed-in visitor. Each names [exactly one gate](#public-pages--menu-items); `icon` and `label` follow the same rules as a `nav` node. The host hands the visitor's entries to the shell as `chrome.user.menu`. | | `routes` | no | See [Routes & handlers](#routes--handlers). | | `hooks` | no | See [Hooks](#hooks). | | `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING__` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). | @@ -607,7 +608,7 @@ interface RequestContext { } ``` -**`ctx.chrome`** — `{ brand, csrfToken, nav, signInHref, theme, user }`. Hand each field to the +**`ctx.chrome`** — `{ brand, csrfToken, nav, signInHref, theme, user }` (`user.menu` holds the profile-dropdown links). Hand each field to the matching `partials/shell` local and a `view` result renders the **native app shell**, exactly as `examples/plugins/scheduling/views/overview.ejs` does; a field you omit falls back to its shell default rather than erroring. `chrome.nav` is the whole global menu — every installed plugin's @@ -732,13 +733,14 @@ The host detects collisions across all discovered plugins with `findConflicts` a | `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. | | `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/` prefix is unique), so this catches a plugin duplicating one of its own. | | `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. | +| `setting` | error | Two settings resolve to the same `PLUGIN_SETTING_` variable name; rename a key or a plugin folder ([Plugin settings](#plugin-settings)). | | `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). | -| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [``](#naming-a-permission) if unintended. Also warned: a route or nav node gates on a permission no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's `permissions`. | +| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [``](#naming-a-permission) if unintended. Also warned: a route, nav node or profile-menu entry gates on a permission no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's `permissions`. | Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also -rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function +rejects **per-manifest shape errors**: a non-array `nav`/`profileMenu`/`routes`/`permissions`, a non-function `home`/`dashboard`, a permission name that isn't [`:`](#naming-a-permission), or a -route/nav node that does not name [exactly one gate](#public-pages--menu-items). +route, nav node or profile-menu entry that does not name [exactly one gate](#public-pages--menu-items). ### Hooks diff --git a/docs/reading-cost.md b/docs/reading-cost.md index 139f612..a185fe4 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -371,3 +371,20 @@ Read most (opens and searches): - `release-tooling/contract-version.test.ts` 2 - `examples/plugins/admin/admin-clients.ts` 1 - `examples/plugins/admin/admin-groups.ts` 1 + +## Reading cost, chunk a6f3d52327a4, finished 2026-11-08T14:17:22Z + +50555 new input tokens before the first commit. + +Read most (opens and searches): + +- `todo.md` 4 + +## Reading cost, chunk 177d8e8081a6, finished 2026-11-08T18:14:49Z + +66196 new input tokens before the first commit. + +Read most (opens and searches): + +- `todo.md` 5 +- `examples/plugins/admin/plugin.ts` 1 diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index 3118329..51c26d1 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -250,8 +250,9 @@ test.describe.serial("authenticated admin journey", () => { test("logout: signing out ends the session and returns to the login page", async () => { await page.goto("/dashboard"); await page.locator("button.profile").click(); // open the profile dropdown - // Sign out is the only item in it — the menu offers nothing that goes nowhere. - await expect(page.locator("#profile-menu .menu-item")).toHaveText(["Sign out"]); + // The scheduling plugin's profile entry sits above Sign out, which stays last. + await expect(page.locator("#profile-menu .menu-item")).toHaveText(["My shifts", "Sign out"]); + await expect(page.locator('#profile-menu a.menu-item[href="/scheduling/mine"]')).toHaveCount(1); await page.locator('form[action="/logout"] button[type="submit"]').click(); await page.waitForURL(/\/login(\?|$)/); // The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav). diff --git a/examples/plugins/scheduling/plugin.ts b/examples/plugins/scheduling/plugin.ts index 48a0243..4a3d7c3 100644 --- a/examples/plugins/scheduling/plugin.ts +++ b/examples/plugins/scheduling/plugin.ts @@ -40,6 +40,9 @@ export default definePlugin({ { description: "Create and edit shifts", name: WRITE }, ], + // Also offered in the profile dropdown above Sign out, since the page is the visitor's own. + profileMenu: [{ href: MINE_PATH, icon: "i-cal", label: "scheduling.nav.mine", session: true }], + routes: [ { handler: overview(), method: "GET", path: "/", public: true }, { handler: myShifts(upstream), method: "GET", path: "/mine", session: true }, diff --git a/src/function-limits.test.ts b/src/function-limits.test.ts index 4931297..5e30019 100644 --- a/src/function-limits.test.ts +++ b/src/function-limits.test.ts @@ -20,7 +20,6 @@ const grandfathered: Record = { "src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 }, "src/auth/kratos-public.ts createKratosPublic": { lines: 60 }, "src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 }, - "src/plugin-host/discovery.ts shapeError": { complexity: 23 }, "src/config.ts loadConfig": { lines: 52 }, "release-tooling/dockerhub-overview.ts main": { lines: 57 }, "examples/plugins/admin/admin-users.ts buildUserFormModel": { complexity: 21 }, diff --git a/src/plugin-host/discovery.test.ts b/src/plugin-host/discovery.test.ts index 76d2665..3a24e44 100644 --- a/src/plugin-host/discovery.test.ts +++ b/src/plugin-host/discovery.test.ts @@ -74,6 +74,10 @@ const badCases: Array<{ name: string; files: Record; match: RegE // A permission name is : wherever the manifest mentions one. Enforced here, not // only in the admin GUI, so it holds for a plugin installed without that GUI. { name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*:/s }, + { name: "a profile-menu entry naming no gate", files: { "nogateprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", label: "X" }] };` }, match: /nogateprofile.*profile-menu entry "X".*names no gate/s }, + { name: "a profile-menu entry gating on a bare word", files: { "bareprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", label: "X", permission: "admin" }] };` }, match: /bareprofile.*admin.*:/s }, + { name: "a profile-menu entry without an href", files: { "nohrefprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ label: "X", session: true }] };` }, match: /nohrefprofile.*profile-menu entry "X".*href/s }, + { name: "profileMenu that is not an array", files: { "objprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: {} };` }, match: /objprofile.*"profileMenu" must be an array/s }, { name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*:/s }, { name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*:/s }, { name: "a plugin shipping its own copy of the barrel", files: { "shadow/node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "shadow/plugin.ts": full("shadow") }, match: /shadow.*@plainpages\/plugin-api/s }, diff --git a/src/plugin-host/discovery.ts b/src/plugin-host/discovery.ts index 9b73049..254587b 100644 --- a/src/plugin-host/discovery.ts +++ b/src/plugin-host/discovery.ts @@ -133,7 +133,7 @@ function asManifest(value: unknown): PluginManifest | null { // The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely. function shapeError(manifest: PluginManifest): string | null { - for (const field of ["nav", "permissions", "routes"] as const) { + for (const field of ["nav", "permissions", "profileMenu", "routes"] as const) { if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`; } // `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so @@ -151,9 +151,11 @@ function shapeError(manifest: PluginManifest): string | null { const gate = gateError(`route "${route?.method} ${route?.path}"`, route); if (gate) return gate; } - const navGate = findNavGateError(manifest.nav); - if (navGate) return navGate; - for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) { + return findNavGateError(manifest.nav) ?? findProfileMenuError(manifest.profileMenu) ?? findPermissionNameError(manifest.permissions); +} + +function findPermissionNameError(decls: PluginManifest["permissions"]): string | null { + for (const decl of Array.isArray(decls) ? decls : []) { if (decl?.name == null || !isValidPermissionName(decl.name)) { return `declared permission "${decl?.name}" is not :, e.g. "things:read"`; } @@ -189,6 +191,16 @@ function findNavGateError(nodes: PluginManifest["nav"]): string | null { return null; } +function findProfileMenuError(entries: PluginManifest["profileMenu"]): string | null { + for (const entry of Array.isArray(entries) ? entries : []) { + const what = `profile-menu entry "${entry?.label ?? "?"}"`; + if (typeof entry?.href !== "string" || entry.href === "") return `${what} needs an href`; + const err = gateError(what, entry); + if (err) return err; + } + return null; +} + function messageOf(err: unknown): string { return err instanceof Error ? err.message : String(err); } diff --git a/src/plugin-host/plugin-api.ts b/src/plugin-host/plugin-api.ts index e13c673..923b2d4 100644 --- a/src/plugin-host/plugin-api.ts +++ b/src/plugin-host/plugin-api.ts @@ -11,7 +11,7 @@ export type { BootContext, HttpMethod, Plugin, PluginHooks, PluginManifest, Perm export type { PluginSettings, SettingDecl, SettingSummary, SettingType, SettingValue } from "./settings.ts"; export type { StorageCredentials } from "./storage.ts"; export type { RequestContext, User } from "../http/context.ts"; -export type { PageChrome } from "../ui/chrome.ts"; +export type { PageChrome, ProfileMenuEntry } from "../ui/chrome.ts"; export type { NavNode } from "../ui/nav.ts"; export { can, check, GuardError, requireSession } from "../auth/guards.ts"; // The three coarse gates a route or nav node may declare — `Route` and `NavNode` both extend it. diff --git a/src/plugin-host/plugin.test.ts b/src/plugin-host/plugin.test.ts index f991ffe..3cecc69 100644 --- a/src/plugin-host/plugin.test.ts +++ b/src/plugin-host/plugin.test.ts @@ -131,19 +131,19 @@ test("findConflicts: duplicate nav id is an error, a shared permission name only const noopHandler = () => {}; -test("findConflicts: a route or nav node gating on a permission no plugin declares warns once per plugin and name, sorted", () => { +test("findConflicts: a route, nav node or profile entry gating on a permission no plugin declares warns once per plugin and name, sorted", () => { const gated = findConflicts([ p({ id: "a", nav: [{ children: [{ id: "a:x", label: "X", permission: "navonly:read" }], id: "a:root", label: "A", session: true }], routes: [ { handler: noopHandler, method: "GET", path: "/x", permission: "routeonly:read" }, { handler: noopHandler, method: "GET", path: "/y", permission: "both:read" }, { handler: noopHandler, method: "POST", path: "/y", permission: "both:read" }, { handler: noopHandler, method: "GET", path: "/z", permission: "declared:write" }, - ], permissions: [] }), + ], permissions: [], profileMenu: [{ href: "/p", label: "P", permission: "profileonly:read" }] }), p({ id: "b", nav: [{ id: "b:y", label: "Y", permission: "both:read" }], permissions: [{ name: "declared:write" }] }), ]); assert.ok(gated.every((c) => c.kind === "permission" && c.level === "warn")); - assert.deepEqual(gated.map((c) => c.plugins), [["a"], ["a"], ["a"], ["b"]]); - assert.deepEqual(gated.map((c) => /"([^"]+:[a-z]+)"/.exec(c.message)?.[1]), ["both:read", "navonly:read", "routeonly:read", "both:read"]); + assert.deepEqual(gated.map((c) => c.plugins), [["a"], ["a"], ["a"], ["a"], ["b"]]); + assert.deepEqual(gated.map((c) => /"([^"]+:[a-z]+)"/.exec(c.message)?.[1]), ["both:read", "navonly:read", "profileonly:read", "routeonly:read", "both:read"]); }); test("findConflicts: a gate on a permission another plugin declares, or on none, stays quiet", () => { diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts index 98a3dea..710c161 100644 --- a/src/plugin-host/plugin.ts +++ b/src/plugin-host/plugin.ts @@ -6,6 +6,7 @@ import type { Gate } from "../auth/gate.ts"; import type { RequestContext } from "../http/context.ts"; +import type { ProfileMenuEntry } from "../ui/chrome.ts"; import type { NavNode } from "../ui/nav.ts"; import { envName, type SettingDecl, type SettingsOf } from "./settings.ts"; import type { StorageCredentials } from "./storage.ts"; @@ -89,6 +90,9 @@ export interface PluginManifest; nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique permissions?: PermissionDecl[]; + // Links added above Sign out in the profile dropdown, each gated like a nav node and shown only to a + // signed-in visitor; `icon` is a Lucide sprite id, and a `label` naming a catalog key is translated. + profileMenu?: ProfileMenuEntry[]; routes?: Route[]; // Operator-supplied configuration, one PLUGIN_SETTING__ variable per key; the resolved // values arrive on onBoot's BootContext, typed from these declarations (settings.ts). @@ -241,6 +245,7 @@ function undeclaredGates(plugins: Plugin[]): PluginConflict[] { const gated = new Set(); for (const route of plugin.routes ?? []) if (route.permission != null) gated.add(route.permission); collectNavPermissions(plugin.nav, (name) => gated.add(name)); + for (const entry of plugin.profileMenu ?? []) if (entry.permission != null) gated.add(entry.permission); for (const name of [...gated].sort()) { if (!declared.has(name)) out.push({ kind: "permission", level: "warn", message: `plugin "${plugin.id}" gates on "${name}", which no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's \`permissions\``, plugins: [plugin.id] }); } diff --git a/src/ui/chrome.test.ts b/src/ui/chrome.test.ts index fc6210e..d64345d 100644 --- a/src/ui/chrome.test.ts +++ b/src/ui/chrome.test.ts @@ -87,3 +87,29 @@ test("shellUser derives the profile from the real user; anonymous → Guest", () assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" }); assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" }); }); + +const account: Plugin = { + apiVersion: "1.0.0", id: "account", + profileMenu: [ + { href: "/account/settings", icon: "i-user", label: "account.settings", session: true }, + { href: "/account/billing", label: "account.billing", permission: "billing:read" }, + { href: "/account/help", label: "Help", public: true }, + ], +}; +const entries = (opts: { permissions?: string[]; signedIn?: boolean }): { href: string; label: string }[] => + buildPluginChrome({ + localeHref: (href) => `${href}?locale=sv-SE`, + menu: DEFAULT_MENU, + plugins: [account], + translatorFor: () => (key) => (key === "account.settings" ? "Inställningar" : key), + ...(opts.signedIn === false ? {} : { user: { email: "ada@x.io", id: "u1", permissions: opts.permissions ?? [] } }), + }).user.menu!.map(({ href, label }) => ({ href, label })); + +test("profile menu entries are gated like nav nodes, translated by their plugin and carry the locale", () => { + assert.deepEqual(entries({}), [ + { href: "/account/settings?locale=sv-SE", label: "Inställningar" }, + { href: "/account/help?locale=sv-SE", label: "Help" }, + ]); + assert.deepEqual(entries({ permissions: ["billing:read"] }).map((e) => e.label), ["Inställningar", "account.billing", "Help"]); + assert.deepEqual(entries({ signedIn: false }).map((e) => e.label), []); // an anonymous visitor has no profile menu +}); diff --git a/src/ui/chrome.ts b/src/ui/chrome.ts index 82e433d..4f6188c 100644 --- a/src/ui/chrome.ts +++ b/src/ui/chrome.ts @@ -2,6 +2,7 @@ // ctx.chrome. `nav` is the global menu — Dashboard plus every plugin's fragment — run through // composeNav (override + per-user filter) and current-marked for the request path. +import { allows, type Gate } from "../auth/gate.ts"; import type { User } from "../http/context.ts"; import { ENGLISH } from "../i18n/english.ts"; import type { Translate } from "../i18n/translate.ts"; @@ -11,9 +12,21 @@ import type { Plugin } from "../plugin-host/plugin.ts"; const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard", session: true }; +// A link in the shell's profile dropdown, above Sign out. +export interface ProfileLink { + href: string; + icon?: string; // a Lucide sprite id (src/ui/icons.ts) + label: string; +} + +// What a plugin's `profileMenu` declares: a link plus the one gate that decides who sees it. +export interface ProfileMenuEntry extends ProfileLink, Gate {} + +// `menu` rides on the user, which every view already hands to the shell, so no view needs a new local. export interface ShellUser { email: string; initials: string; + menu?: ProfileLink[]; // permission-filtered, translated and locale-carried; the host always sets it, a hand-built fixture may omit it name: string; } @@ -66,10 +79,25 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome { nav: carryLocaleInto(nav, carryLocale), signInHref: carryLocale(returnTo), ...(theme != null ? { theme } : {}), - user: shellUser(opts.user, t), + user: { ...shellUser(opts.user, t), menu: profileLinks(opts, t, carryLocale) }, }; } +// A plugin's labels are keys in *its* catalog, hence its own translator. Only a signed-in visitor +// has a profile menu; the gate then decides per entry. +function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] { + const user = opts.user ?? null; + if (!user) return []; + return (opts.plugins ?? []).flatMap((plugin) => { + const pluginT = opts.translatorFor?.(plugin.id) ?? t; + return (plugin.profileMenu ?? []).filter((entry) => allows(entry, user)).map((entry) => ({ + href: carryLocale(entry.href), + ...(entry.icon != null ? { icon: entry.icon } : {}), + label: pluginT(entry.label), + })); + }); +} + // The JWT holds only id/email/permissions, so the profile name is the email's local part. export function shellUser(user: User | null | undefined, t: Translate = ENGLISH): ShellUser { if (!user) { diff --git a/src/ui/shell.test.ts b/src/ui/shell.test.ts index 62397e6..cd5006a 100644 --- a/src/ui/shell.test.ts +++ b/src/ui/shell.test.ts @@ -131,3 +131,17 @@ test("the chrome carries the visitor's language: breadcrumb links go through loc assert.match(html, /Users<\/a>/); assert.match(html, /Ada<\/span>/); // the current crumb has no href to carry }); + +test("profile menu entries render as links above Sign out, with their icon", async () => { + const html = await render({ + csrfToken: "tok.sig", + user: { + email: "ada@acme.io", initials: "AD", name: "ada", + menu: [{ href: "/account/settings", icon: "i-user", label: "My " }, { href: "/account/help", label: "Help" }], + }, + }); + const panel = html.slice(html.indexOf('id="profile-menu"'), html.indexOf("")); + assert.match(panel, /<\/svg>My <settings><\/a>/); + assert.match(panel, /Help<\/a>/); + assert.ok(panel.indexOf("/account/help") < panel.indexOf('class="menu-item-form"'), "Sign out stays last"); +}); diff --git a/todo.md b/todo.md index 3fc2a44..0750a20 100644 --- a/todo.md +++ b/todo.md @@ -12,9 +12,7 @@ | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| -| 28 | 0.5.0 | | | **Let a plugin add entries to the profile menu through the plugin API.** | 2 | 2 | 2 | 4 | 4 | 7 by 4, 1 by 2 | 4.2 | | 50 | 0.5.0 | | | **Split `handleRequest` into named stages, so each fits the 50-line limit and the stage order is written once.** | 3 | 1 | 3 | 5 | 7 | code health by 7 | 3.7 | -| 54 | 0.5.0 | | | **List the `setting` conflict kind in the README's Conflict rules table.** | 1 | 1 | 1 | 1 | 1 | 9 by 2 | 3.1 | | 2 | 0.5.0 | | | **Rename the admin example plugin to a name that says what it administers.** | 3 | 1 | 2 | 4 | 6 | 9 by 3, code health by 2 | 2.6 | | 5 | 0.5.0 | | | **List grants that no installed plugin declares, with a remove action.** | 2 | 2 | 2 | 4 | 4 | 2 by 3 | 2.1 | | 41 | 0.5.0 | | | **Apply a permission save as one Keto call, so it cannot stop part-way.** | 2 | 2 | 2 | 4 | 3 | 2 by 2 | 2.1 | @@ -29,12 +27,6 @@ ## Details -### 28. Let a plugin add entries to the profile menu through the plugin API. - -The profile dropdown stays a dropdown: Sign out is its only entry today, and it will gain more. -`views/partials/shell.ejs` hand-rolls it (AGENTS.md → UI, the dropdown rule), so the entries need a -manifest field that discovery checks, gated like a nav node. - ### 50. Split `handleRequest` into named stages, so each fits the 50-line limit and the stage order is written once. The hardest place in all eight scoring seats of PR #200: `src/http/app.ts` `handleRequest` is about @@ -43,11 +35,6 @@ canonical host, locale, session, CSRF, contexts, hooks, dispatch) is carried by it from `grandfathered` in `src/function-limits.test.ts`. Behaviour must not change; `app-*.test.ts` is the net. Ships alone (R 5). -### 54. List the `setting` conflict kind in the README's Conflict rules table. - -`findConflicts` (`plugin.ts`) emits `kind: "setting"` at `error` level and the table in README → -Conflict rules omits it. - ### 2. Rename the admin example plugin to a name that says what it administers. The new name is `auth-admin`, chosen by the maintainer. The id is also the mount path, so the compose files, diff --git a/views/partials/shell.ejs b/views/partials/shell.ejs index 0aa6b2b..a771b05 100644 --- a/views/partials/shell.ejs +++ b/views/partials/shell.ejs @@ -59,8 +59,8 @@