Let a plugin add links to the profile menu, and list the setting conflict kind #220
@@ -162,7 +162,7 @@ Revisit only if the stated reason stops holding.
|
|||||||
"permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
|
"permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
|
||||||
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
|
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
|
||||||
word names *who someone is* (a role), and roles are groups here. **Enforced at discovery**
|
word names *who someone is* (a role), and roles are groups here. **Enforced at discovery**
|
||||||
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav
|
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route, nav and profile-menu
|
||||||
`permission` and every declared name), fail-loud like any other manifest rule — not only in the
|
`permission` and every declared name), fail-loud like any other manifest rule — not only in the
|
||||||
admin GUI, which an operator removes by not copying it in.
|
admin GUI, which an operator removes by not copying it in.
|
||||||
- **Names are authored in plugin code; only grants live in Keto.** The host collects every installed
|
- **Names are authored in plugin code; only grants live in Keto.** The host collects every installed
|
||||||
@@ -288,7 +288,7 @@ Revisit only if the stated reason stops holding.
|
|||||||
declarative attribute's alone** — open a panel from script and no engine applies it, so
|
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. `<details>` stays where
|
"enhancing" one of these triggers is what would cost it its accessibility. `<details>` stays where
|
||||||
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
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
|
profile menu (its trigger composes escaped user values and its Sign-out item is a CSRF POST form) — keep
|
||||||
the two in step.
|
the two in step.
|
||||||
- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s
|
- **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:
|
`overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame:
|
||||||
|
|||||||
@@ -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
|
`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.
|
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 without an `href`, a `label`, or exactly one gate.
|
||||||
|
|
||||||
### A plugin can version its own `/public/` files
|
### 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/<id>/…")` in a view,
|
Wrap the href of a script, image or other file your own markup links in `assetHref("/public/<id>/…")` in a view,
|
||||||
|
|||||||
@@ -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). |
|
| `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. |
|
| `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). |
|
| `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. |
|
||||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||||
| `hooks` | no | See [Hooks](#hooks). |
|
| `hooks` | no | See [Hooks](#hooks). |
|
||||||
| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). |
|
| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` 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.profileLinks` 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
|
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
|
`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
|
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. |
|
| `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 `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
|
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` 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. |
|
| `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)). |
|
| `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 [`<resource>`](#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 [`<resource>`](#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
|
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`/`permissions`/`profileMenu`/`routes`, a non-function
|
||||||
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
|
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#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
|
### Hooks
|
||||||
|
|
||||||
|
|||||||
@@ -359,3 +359,17 @@ Inherited architect, decided by:
|
|||||||
|
|
||||||
- Locality, Shape: `src/http/request-tracking.ts:9` `trackRequest`: the exactly-once finalize and close listener are one named unit, changeable without reading `createApp`'s other state
|
- Locality, Shape: `src/http/request-tracking.ts:9` `trackRequest`: the exactly-once finalize and close listener are one named unit, changeable without reading `createApp`'s other state
|
||||||
- Locality, Shape: `src/http/request-fields.ts:33` `clientAddress`: pure function of peer, header and list, with the reasons for reading from the right beside it
|
- Locality, Shape: `src/http/request-fields.ts:33` `clientAddress`: pure function of peer, header and list, with the reasons for reading from the right beside it
|
||||||
|
|
||||||
|
## 2026-11-08T18:34:54Z, PR #220 at 1b53b01, against 14a07dd
|
||||||
|
|
||||||
|
Ruling: same
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | same | same | same | same | same |
|
||||||
|
| Inherited architect | same | same | worse | same | same |
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Shape: `views/partials/shell.ejs:74` `user.menu`: a third meaning of `menu` beside the sidebar switch at line 19 and the `.menu` popover class
|
||||||
|
- Shape: `src/plugin-host/plugin.ts:9` `import type { ProfileMenuEntry }`: a second contract type defined under `ui/`, with a type-only import cycle through `chrome.ts`
|
||||||
|
|||||||
@@ -371,3 +371,20 @@ Read most (opens and searches):
|
|||||||
- `release-tooling/contract-version.test.ts` 2
|
- `release-tooling/contract-version.test.ts` 2
|
||||||
- `examples/plugins/admin/admin-clients.ts` 1
|
- `examples/plugins/admin/admin-clients.ts` 1
|
||||||
- `examples/plugins/admin/admin-groups.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
|
||||||
|
|||||||
@@ -250,8 +250,8 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
test("logout: signing out ends the session and returns to the login page", async () => {
|
test("logout: signing out ends the session and returns to the login page", async () => {
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await page.locator("button.profile").click(); // open the profile dropdown
|
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(["My shifts", "Sign out"]);
|
||||||
await expect(page.locator("#profile-menu .menu-item")).toHaveText(["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.locator('form[action="/logout"] button[type="submit"]').click();
|
||||||
await page.waitForURL(/\/login(\?|$)/);
|
await page.waitForURL(/\/login(\?|$)/);
|
||||||
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
|
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ test("the reference plugin: public Overview is open to all, My shifts takes any
|
|||||||
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
||||||
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
|
||||||
await expect(page.locator('.sidebar a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough
|
await expect(page.locator('.sidebar nav a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough
|
||||||
|
|
||||||
// No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500
|
// No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500
|
||||||
// and never a claim about what is assigned.
|
// and never a claim about what is assigned.
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export default definePlugin({
|
|||||||
{ description: "Create and edit shifts", name: WRITE },
|
{ 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: [
|
routes: [
|
||||||
{ handler: overview(), method: "GET", path: "/", public: true },
|
{ handler: overview(), method: "GET", path: "/", public: true },
|
||||||
{ handler: myShifts(upstream), method: "GET", path: "/mine", session: true },
|
{ handler: myShifts(upstream), method: "GET", path: "/mine", session: true },
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const grandfathered: Record<string, { lines?: number; complexity?: number }> = {
|
|||||||
"src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 },
|
"src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 },
|
||||||
"src/auth/kratos-public.ts createKratosPublic": { lines: 60 },
|
"src/auth/kratos-public.ts createKratosPublic": { lines: 60 },
|
||||||
"src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 },
|
"src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 },
|
||||||
"src/plugin-host/discovery.ts shapeError": { complexity: 23 },
|
|
||||||
"src/config.ts loadConfig": { lines: 52 },
|
"src/config.ts loadConfig": { lines: 52 },
|
||||||
"release-tooling/dockerhub-overview.ts main": { lines: 57 },
|
"release-tooling/dockerhub-overview.ts main": { lines: 57 },
|
||||||
"examples/plugins/admin/admin-users.ts buildUserFormModel": { complexity: 21 },
|
"examples/plugins/admin/admin-users.ts buildUserFormModel": { complexity: 21 },
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
|||||||
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
||||||
// only in the admin GUI, so it holds for a plugin installed without that GUI.
|
// 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.*<resource>:<action>/s },
|
{ 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.*<resource>:<action>/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.*<resource>:<action>/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: "a profile-menu entry with an empty href", files: { "emptyhref/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "", label: "X", session: true }] };` }, match: /emptyhref.*needs an href/s },
|
||||||
|
{ name: "a profile-menu entry without a label", files: { "nolabel/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", session: true }] };` }, match: /nolabel.*needs a label/s },
|
||||||
|
{ name: "an ungated profile-menu entry after a valid one", files: { "secondbad/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/a", label: "A", session: true }, { href: "/b", label: "B" }] };` }, match: /secondbad.*"B".*names no gate/s },
|
||||||
|
{ name: "a null profile-menu entry", files: { "nullentry/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [null] };` }, match: /nullentry.*needs an 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.*<resource>:<action>/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.*<resource>:<action>/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.*<resource>:<action>/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.*<resource>:<action>/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 },
|
{ 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 },
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function asManifest(value: unknown): PluginManifest | null {
|
|||||||
|
|
||||||
// The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely.
|
// The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely.
|
||||||
function shapeError(manifest: PluginManifest): string | null {
|
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`;
|
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
|
// `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);
|
const gate = gateError(`route "${route?.method} ${route?.path}"`, route);
|
||||||
if (gate) return gate;
|
if (gate) return gate;
|
||||||
}
|
}
|
||||||
const navGate = findNavGateError(manifest.nav);
|
return findNavGateError(manifest.nav) ?? findProfileMenuError(manifest.profileMenu) ?? findPermissionNameError(manifest.permissions);
|
||||||
if (navGate) return navGate;
|
}
|
||||||
for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) {
|
|
||||||
|
function findPermissionNameError(decls: PluginManifest["permissions"]): string | null {
|
||||||
|
for (const decl of Array.isArray(decls) ? decls : []) {
|
||||||
if (decl?.name == null || !isValidPermissionName(decl.name)) {
|
if (decl?.name == null || !isValidPermissionName(decl.name)) {
|
||||||
return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`;
|
return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`;
|
||||||
}
|
}
|
||||||
@@ -189,6 +191,17 @@ function findNavGateError(nodes: PluginManifest["nav"]): string | null {
|
|||||||
return 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`;
|
||||||
|
if (typeof entry.label !== "string" || entry.label === "") return `${what} needs a label`;
|
||||||
|
const err = gateError(what, entry);
|
||||||
|
if (err) return err;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function messageOf(err: unknown): string {
|
function messageOf(err: unknown): string {
|
||||||
return err instanceof Error ? err.message : String(err);
|
return err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ export type { BootContext, HttpMethod, Plugin, PluginHooks, PluginManifest, Perm
|
|||||||
export type { PluginSettings, SettingDecl, SettingSummary, SettingType, SettingValue } from "./settings.ts";
|
export type { PluginSettings, SettingDecl, SettingSummary, SettingType, SettingValue } from "./settings.ts";
|
||||||
export type { StorageCredentials } from "./storage.ts";
|
export type { StorageCredentials } from "./storage.ts";
|
||||||
export type { RequestContext, User } from "../http/context.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 type { NavNode } from "../ui/nav.ts";
|
||||||
export { can, check, GuardError, requireSession } from "../auth/guards.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.
|
// The three coarse gates a route, nav node or profile-menu entry may declare — `Route`, `NavNode` and `ProfileMenuEntry` all extend it.
|
||||||
export type { Gate } from "../auth/gate.ts";
|
export type { Gate } from "../auth/gate.ts";
|
||||||
// Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for
|
// Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for
|
||||||
// authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator
|
// authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator
|
||||||
|
|||||||
@@ -131,19 +131,19 @@ test("findConflicts: duplicate nav id is an error, a shared permission name only
|
|||||||
|
|
||||||
const noopHandler = () => {};
|
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([
|
const gated = findConflicts([
|
||||||
p({ id: "a", nav: [{ children: [{ id: "a:x", label: "X", permission: "navonly:read" }], id: "a:root", label: "A", session: true }], routes: [
|
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: "/x", permission: "routeonly:read" },
|
||||||
{ handler: noopHandler, method: "GET", path: "/y", permission: "both:read" },
|
{ handler: noopHandler, method: "GET", path: "/y", permission: "both:read" },
|
||||||
{ handler: noopHandler, method: "POST", path: "/y", permission: "both:read" },
|
{ handler: noopHandler, method: "POST", path: "/y", permission: "both:read" },
|
||||||
{ handler: noopHandler, method: "GET", path: "/z", permission: "declared:write" },
|
{ handler: noopHandler, method: "GET", path: "/z", permission: "declared:write" },
|
||||||
], permissions: [] }),
|
], permissions: [], profileMenu: [{ href: "/p", label: "P", permission: "profileonly:read" }, { href: "/q", label: "Q", session: true }, { href: "/r", label: "R", public: true }] }),
|
||||||
p({ id: "b", nav: [{ id: "b:y", label: "Y", permission: "both:read" }], permissions: [{ name: "declared:write" }] }),
|
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.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) => c.plugins), [["a"], ["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) => /"([^"]+:[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", () => {
|
test("findConflicts: a gate on a permission another plugin declares, or on none, stays quiet", () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import type { Gate } from "../auth/gate.ts";
|
import type { Gate } from "../auth/gate.ts";
|
||||||
import type { RequestContext } from "../http/context.ts";
|
import type { RequestContext } from "../http/context.ts";
|
||||||
|
import type { ProfileMenuEntry } from "../ui/chrome.ts";
|
||||||
import type { NavNode } from "../ui/nav.ts";
|
import type { NavNode } from "../ui/nav.ts";
|
||||||
import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
|
import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
|
||||||
import type { StorageCredentials } from "./storage.ts";
|
import type { StorageCredentials } from "./storage.ts";
|
||||||
@@ -89,6 +90,8 @@ export interface PluginManifest<S extends readonly SettingDecl[] = readonly Sett
|
|||||||
hooks?: PluginHooks<S>;
|
hooks?: PluginHooks<S>;
|
||||||
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
|
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[];
|
permissions?: PermissionDecl[];
|
||||||
|
// Links above Sign out in the profile dropdown, each gated like a nav node; only a signed-in visitor sees any.
|
||||||
|
profileMenu?: ProfileMenuEntry[];
|
||||||
routes?: Route[];
|
routes?: Route[];
|
||||||
// Operator-supplied configuration, one PLUGIN_SETTING_<ID>_<KEY> variable per key; the resolved
|
// Operator-supplied configuration, one PLUGIN_SETTING_<ID>_<KEY> variable per key; the resolved
|
||||||
// values arrive on onBoot's BootContext, typed from these declarations (settings.ts).
|
// values arrive on onBoot's BootContext, typed from these declarations (settings.ts).
|
||||||
@@ -241,6 +244,7 @@ function undeclaredGates(plugins: Plugin[]): PluginConflict[] {
|
|||||||
const gated = new Set<string>();
|
const gated = new Set<string>();
|
||||||
for (const route of plugin.routes ?? []) if (route.permission != null) gated.add(route.permission);
|
for (const route of plugin.routes ?? []) if (route.permission != null) gated.add(route.permission);
|
||||||
collectNavPermissions(plugin.nav, (name) => gated.add(name));
|
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()) {
|
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] });
|
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] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(null), { email: "", initials: "G", name: "Guest" });
|
||||||
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
|
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; icon?: 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.profileLinks!;
|
||||||
|
|
||||||
|
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", icon: "i-user", 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
|
||||||
|
});
|
||||||
|
|||||||
+27
-1
@@ -2,6 +2,7 @@
|
|||||||
// ctx.chrome. `nav` is the global menu — Dashboard plus every plugin's fragment — run through
|
// 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.
|
// 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 type { User } from "../http/context.ts";
|
||||||
import { ENGLISH } from "../i18n/english.ts";
|
import { ENGLISH } from "../i18n/english.ts";
|
||||||
import type { Translate } from "../i18n/translate.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 };
|
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 {}
|
||||||
|
|
||||||
|
// `profileLinks` rides on the user, which every view already hands to the shell, so no view needs a new local.
|
||||||
export interface ShellUser {
|
export interface ShellUser {
|
||||||
email: string;
|
email: string;
|
||||||
initials: string;
|
initials: string;
|
||||||
|
profileLinks?: ProfileLink[]; // gate-filtered, translated, locale-carried. The host always sets it; a hand-built fixture may omit it
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +79,23 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
|||||||
nav: carryLocaleInto(nav, carryLocale),
|
nav: carryLocaleInto(nav, carryLocale),
|
||||||
signInHref: carryLocale(returnTo),
|
signInHref: carryLocale(returnTo),
|
||||||
...(theme != null ? { theme } : {}),
|
...(theme != null ? { theme } : {}),
|
||||||
user: shellUser(opts.user, t),
|
user: { ...shellUser(opts.user, t), profileLinks: profileLinks(opts, t, carryLocale) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// 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 {
|
export function shellUser(user: User | null | undefined, t: Translate = ENGLISH): ShellUser {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -131,3 +131,17 @@ test("the chrome carries the visitor's language: breadcrumb links go through loc
|
|||||||
assert.match(html, /<a href="\/admin\/users\?locale=sv-SE">Users<\/a>/);
|
assert.match(html, /<a href="\/admin\/users\?locale=sv-SE">Users<\/a>/);
|
||||||
assert.match(html, /<span>Ada<\/span>/); // the current crumb has no href to carry
|
assert.match(html, /<span>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",
|
||||||
|
profileLinks: [{ href: "/account/settings", icon: "i-user", label: "My <settings>" }, { href: "/account/help", label: "Help" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const panel = html.slice(html.indexOf('id="profile-menu"'), html.indexOf("</aside>"));
|
||||||
|
assert.match(panel, /<a class="menu-item" href="\/account\/settings"><svg class="ico"><use href="#i-user" \/><\/svg>My <settings><\/a>/);
|
||||||
|
assert.match(panel, /<a class="menu-item" href="\/account\/help">Help<\/a>/);
|
||||||
|
assert.ok(panel.indexOf("/account/help") < panel.indexOf('class="menu-item-form"'), "Sign out stays last");
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,18 +6,17 @@
|
|||||||
|
|
||||||
`Bar = 1`
|
`Bar = 1`
|
||||||
|
|
||||||
`Next ID = 58`
|
`Next ID = 59`
|
||||||
|
|
||||||
## Items
|
## Items
|
||||||
|
|
||||||
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
| 58 | 0.5.0 | | | **Let an operator hide a plugin's profile-menu entry from `config/menu.ts`.** | 2 | 2 | 2 | 4 | 4 | 7 by 3 | 2.0 |
|
||||||
| 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
|
| 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
|
||||||
| 16 | 0.6.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
|
| 16 | 0.6.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
|
||||||
| 14 | 0.6.0 | | | **Drop a double-clicked duplicate submit, with no client-side JavaScript.** | 4 | 2 | 3 | 7 | 7 | 2 by 7, 1 by 4 | 1.4 |
|
| 14 | 0.6.0 | | | **Drop a double-clicked duplicate submit, with no client-side JavaScript.** | 4 | 2 | 3 | 7 | 7 | 2 by 7, 1 by 4 | 1.4 |
|
||||||
@@ -29,12 +28,6 @@
|
|||||||
|
|
||||||
## Details
|
## 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.
|
### 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
|
The hardest place in all eight scoring seats of PR #200: `src/http/app.ts` `handleRequest` is about
|
||||||
@@ -43,11 +36,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`
|
it from `grandfathered` in `src/function-limits.test.ts`. Behaviour must not change; `app-*.test.ts`
|
||||||
is the net. Ships alone (R 5).
|
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.
|
### 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,
|
The new name is `auth-admin`, chosen by the maintainer. The id is also the mount path, so the compose files,
|
||||||
@@ -66,6 +54,15 @@ and deletes in one transaction; confirm that against the Keto version `ory/` pin
|
|||||||
call to `KetoClient`, which is contract surface (`@plainpages/plugin-api`) and so needs 0.5.0, and the
|
call to `KetoClient`, which is contract surface (`@plainpages/plugin-api`) and so needs 0.5.0, and the
|
||||||
partial-failure path in the admin example can go.
|
partial-failure path in the admin example can go.
|
||||||
|
|
||||||
|
### 58. Let an operator hide a plugin's profile-menu entry from `config/menu.ts`.
|
||||||
|
|
||||||
|
A manifest's `profileMenu` entries have no `id`, and the central override (`NavOverride` in
|
||||||
|
`src/ui/nav.ts`) targets sidebar nav nodes only, so hiding or renaming one means forking the plugin,
|
||||||
|
though README → The menu system promises hide and rename for menu items. The fix gives an entry an optional
|
||||||
|
`id`, unique across plugins through a `findConflicts` kind like `nav-id`, and applies `hide` and
|
||||||
|
`rename` to it in `profileLinks` (`src/ui/chrome.ts`). The alternative is a decision recording
|
||||||
|
profile entries as plugin-only, with README saying so.
|
||||||
|
|
||||||
### 19. Run the documented Docker commands on macOS and fix whatever misbehaves.
|
### 19. Run the documented Docker commands on macOS and fix whatever misbehaves.
|
||||||
|
|
||||||
Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is
|
Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is
|
||||||
|
|||||||
@@ -59,8 +59,8 @@
|
|||||||
<div class="footer-actions">
|
<div class="footer-actions">
|
||||||
<% if (user.email) { %>
|
<% if (user.email) { %>
|
||||||
<%# Signed in: the same popover block as the menu partial, hand-rolled because this one's
|
<%# Signed in: the same popover block as the menu partial, hand-rolled because this one's
|
||||||
trigger composes escaped user values and its item is a CSRF POST form, neither of which
|
trigger composes escaped user values and its Sign-out item is a CSRF POST form, neither of
|
||||||
the partial's Item shapes cover (AGENTS.md). Keep the two in step. %>
|
which the partial's Item shapes cover (AGENTS.md). Keep the two in step. %>
|
||||||
<div class="menu" style="flex:1 1 auto">
|
<div class="menu" style="flex:1 1 auto">
|
||||||
<button class="profile" type="button" popovertarget="profile-menu">
|
<button class="profile" type="button" popovertarget="profile-menu">
|
||||||
<span class="avatar" aria-hidden="true"><%= user.initials %></span>
|
<span class="avatar" aria-hidden="true"><%= user.initials %></span>
|
||||||
@@ -71,6 +71,9 @@
|
|||||||
</button>
|
</button>
|
||||||
<div id="profile-menu" class="menu-pop left up" popover style="min-width:220px">
|
<div id="profile-menu" class="menu-pop left up" popover style="min-width:220px">
|
||||||
<div class="menu-head"><%= t("shell.signedInAs", { name: user.name }) %></div>
|
<div class="menu-head"><%= t("shell.signedInAs", { name: user.name }) %></div>
|
||||||
|
<% (user.profileLinks || []).forEach((link) => { %>
|
||||||
|
<a class="menu-item" href="<%= link.href %>"><% if (link.icon) { %><svg class="ico"><use href="#<%= link.icon %>" /></svg><% } %><%= link.label %></a>
|
||||||
|
<% }) %>
|
||||||
<%# Sign out is a state change → a POST form (not a GET link), CSRF-guarded by app.ts %>
|
<%# Sign out is a state change → a POST form (not a GET link), CSRF-guarded by app.ts %>
|
||||||
<form class="menu-item-form" method="post" action="<%= localeHref("/logout") %>">
|
<form class="menu-item-form" method="post" action="<%= localeHref("/logout") %>">
|
||||||
<input type="hidden" name="_csrf" value="<%= locals.csrfToken || '' %>" />
|
<input type="hidden" name="_csrf" value="<%= locals.csrfToken || '' %>" />
|
||||||
|
|||||||
Reference in New Issue
Block a user