Reserve the locale param, carry it on breadcrumbs, translate the permissions detail view
CI / full-gate (push) Successful in 2m38s

This commit is contained in:
2026-08-03 23:21:38 +02:00
parent 6440c543e5
commit b3df7084c4
13 changed files with 62 additions and 27 deletions
+16 -9
View File
@@ -109,15 +109,17 @@ them. Revisit only if the stated reason stops holding.
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` says that locale needs — — keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` says that locale needs —
and a mismatch stops startup, same fail-loud contract as a bad manifest. A plugin may ship fewer and a mismatch stops startup, same fail-loud contract as a bad manifest. A plugin may ship fewer
locales than the host (its strings fall back to `en-US` per key), never one the host lacks. locales than the host (its strings fall back to `en-US` per key), never one the host lacks.
- **The core building blocks carry the locale; a plugin doesn't have to.** `pagination`, - **The core building blocks carry the locale; a plugin doesn't have to.** The shell (breadcrumbs),
`filter-bar`, `data-table`, `auth-card`, `flow-body`, `menu` and the nav wrap every href they `pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every
render in `localeHref`, and the two GET forms carry it as a hidden `locale` input (a GET submit href they render in `localeHref`; the nav is wrapped upstream in `chrome.ts`; and the two GET forms
replaces the whole query string, so no href wrapper can reach it). Putting the obligation on each (filter bar, rows-per-page) carry it as a hidden `locale` input, since a GET submit replaces the
call site was tried first and missed five of eight sites inside one commit — including the admin whole query string and no href wrapper can reach it. Putting the obligation on each call site was
screens. `ctx.localeHref` remains for hrefs a plugin's own markup emits. Decided 2026-08-03 after tried first and missed five of eight sites inside one commit — including the admin screens.
an architecture review. `ctx.localeHref` remains for hrefs a plugin's own markup emits (the admin example's delete links).
- **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set, so it never Decided 2026-08-03 after an architecture review; a second pass then found breadcrumbs still raw,
shows up as a plugin filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`, so: when a link renders from the core chrome, it is the chrome's job to carry the locale.
- **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set (`list-query.ts`),
so a localized list page doesn't hand a plugin a phantom `locale` filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`,
`localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data` `localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data`
so a collision loses the key instead of breaking the shell. so a collision loses the key instead of breaking the shell.
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`.** A catalog there - **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`.** A catalog there
@@ -223,6 +225,11 @@ Same test before adding a row to a table or the file map — a clause, not a par
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`. resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
- **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking
canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin
routing, 405/404 and error mapping. The pure parts are already extracted and separately tested; what
remains is orchestration. Planned split along those seams; don't grow it further without taking one
out. Raised by the architecture review 2026-08-03, deliberately not done inside the i18n change.
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer - Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule. agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in - **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
+6 -4
View File
@@ -998,10 +998,12 @@ Three rules worth knowing:
that deliberately carries markup is rendered with `<%- %>` — and must never interpolate that deliberately carries markup is rendered with `<%- %>` — and must never interpolate
untrusted data, since nothing escapes it there. untrusted data, since nothing escapes it there.
- **Dates and numbers are `Intl`'s job**, not the catalog's: `new Intl.DateTimeFormat(ctx.locale)`. - **Dates and numbers are `Intl`'s job**, not the catalog's: `new Intl.DateTimeFormat(ctx.locale)`.
- **The core building blocks carry the locale for you.** `pagination`, `filter-bar`, `data-table`, - **The core building blocks carry the locale for you.** The shell's breadcrumbs, `pagination`,
`auth-card` and the nav wrap every href they render, and the two GET forms carry it as a hidden `filter-bar`, `data-table`, `auth-card`, `field` and the nav wrap every href they render, and the
`locale` input (a GET submit replaces the whole query string). `ctx.localeHref` is only for hrefs two GET forms (filter bar, rows-per-page) carry it as a hidden `locale` input — a GET submit
your own markup emits — and `localeParam` (a view local: the tag, or null) for your own GET forms. replaces the whole query string, so no href wrapper can reach it. `ctx.localeHref` is only for
hrefs your own markup emits, and `localeParam` (a view local: the tag, or null) for your own GET
forms. `locale` is reserved: `parseListQuery` never returns it as a filter.
- **Reuse the core words.** Generic UI verbs live in the core catalog — `common.add/cancel/delete/ - **Reuse the core words.** Generic UI verbs live in the core catalog — `common.add/cancel/delete/
edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through
to them. Keep your catalog for your domain words, so N plugins don't re-translate "Cancel" N times. to them. Keep your catalog for your domain words, so N plugins don't re-translate "Cancel" N times.
+5
View File
@@ -67,6 +67,11 @@ test.describe.serial("authenticated admin journey", () => {
await page.getByRole("button", { name: "Visa" }).click(); // the rows-per-page GET form await page.getByRole("button", { name: "Visa" }).click(); // the rows-per-page GET form
await expect(page).toHaveURL(/locale=sv-SE/); await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
// The breadcrumb is the chrome's way back up — it is rendered by the shell, not by the screen.
await page.getByRole("navigation", { name: "Sidsökväg" }).getByRole("link").first().click();
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
}); });
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => { test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
+7
View File
@@ -81,6 +81,10 @@ const messages = {
"admin.notFound.message": "That item doesn't exist.", "admin.notFound.message": "That item doesn't exist.",
"admin.notFound.title": "Not found", "admin.notFound.title": "Not found",
"admin.permissions.actions": "Permission actions",
"admin.permissions.allAssigned": "All users and groups already have this permission.",
"admin.permissions.assign": "Assign the permission",
"admin.permissions.assignAction": "Assign",
"admin.permissions.assignTo": "Assign to", "admin.permissions.assignTo": "Assign to",
"admin.permissions.assignedTo": "Assigned to", "admin.permissions.assignedTo": "Assigned to",
"admin.permissions.column.members": "Members", "admin.permissions.column.members": "Members",
@@ -90,10 +94,13 @@ const messages = {
"admin.permissions.deleteMessage": "Delete permission {{name}}? This revokes it from everyone it's assigned to.", "admin.permissions.deleteMessage": "Delete permission {{name}}? This revokes it from everyone it's assigned to.",
"admin.permissions.error.adminUndeletable": "The admin permission can't be deleted — it would remove all admin access.", "admin.permissions.error.adminUndeletable": "The admin permission can't be deleted — it would remove all admin access.",
"admin.permissions.error.selfRevoke": "You can't revoke your own admin access.", "admin.permissions.error.selfRevoke": "You can't revoke your own admin access.",
"admin.permissions.effective": "Effective access",
"admin.permissions.effectiveHint": "Everyone who holds this permission — directly or through a group (resolved by Keto).",
"admin.permissions.field.name": "Permission name", "admin.permissions.field.name": "Permission name",
"admin.permissions.field.nameHint": "Lowercase letters, digits, dashes and underscores.", "admin.permissions.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
"admin.permissions.filter": "Filter permissions", "admin.permissions.filter": "Filter permissions",
"admin.permissions.new": "New permission", "admin.permissions.new": "New permission",
"admin.permissions.noEffective": "No users hold this permission yet.",
"admin.permissions.noMembers": "Not assigned to anyone yet.", "admin.permissions.noMembers": "Not assigned to anyone yet.",
"admin.permissions.pagination": "Permissions pagination", "admin.permissions.pagination": "Permissions pagination",
"admin.permissions.revoke": "Revoke", "admin.permissions.revoke": "Revoke",
+7
View File
@@ -81,6 +81,10 @@ const messages: AdminMessages = {
"admin.notFound.message": "Objektet finns inte.", "admin.notFound.message": "Objektet finns inte.",
"admin.notFound.title": "Hittades inte", "admin.notFound.title": "Hittades inte",
"admin.permissions.actions": "Behörighetsåtgärder",
"admin.permissions.allAssigned": "Alla användare och grupper har redan den här behörigheten.",
"admin.permissions.assign": "Tilldela behörigheten",
"admin.permissions.assignAction": "Tilldela",
"admin.permissions.assignTo": "Tilldela till", "admin.permissions.assignTo": "Tilldela till",
"admin.permissions.assignedTo": "Tilldelad till", "admin.permissions.assignedTo": "Tilldelad till",
"admin.permissions.column.members": "Medlemmar", "admin.permissions.column.members": "Medlemmar",
@@ -90,10 +94,13 @@ const messages: AdminMessages = {
"admin.permissions.deleteMessage": "Ta bort behörigheten {{name}}? Den återkallas från alla den är tilldelad till.", "admin.permissions.deleteMessage": "Ta bort behörigheten {{name}}? Den återkallas från alla den är tilldelad till.",
"admin.permissions.error.adminUndeletable": "Behörigheten admin kan inte tas bort — det skulle ta bort all administratörsåtkomst.", "admin.permissions.error.adminUndeletable": "Behörigheten admin kan inte tas bort — det skulle ta bort all administratörsåtkomst.",
"admin.permissions.error.selfRevoke": "Du kan inte återkalla din egen administratörsåtkomst.", "admin.permissions.error.selfRevoke": "Du kan inte återkalla din egen administratörsåtkomst.",
"admin.permissions.effective": "Faktisk åtkomst",
"admin.permissions.effectiveHint": "Alla som har behörigheten — direkt eller via en grupp (uppslaget av Keto).",
"admin.permissions.field.name": "Behörighetens namn", "admin.permissions.field.name": "Behörighetens namn",
"admin.permissions.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.", "admin.permissions.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
"admin.permissions.filter": "Filtrera behörigheter", "admin.permissions.filter": "Filtrera behörigheter",
"admin.permissions.new": "Ny behörighet", "admin.permissions.new": "Ny behörighet",
"admin.permissions.noEffective": "Ingen användare har den här behörigheten ännu.",
"admin.permissions.noMembers": "Inte tilldelad till någon ännu.", "admin.permissions.noMembers": "Inte tilldelad till någon ännu.",
"admin.permissions.pagination": "Sidnavigering för behörigheter", "admin.permissions.pagination": "Sidnavigering för behörigheter",
"admin.permissions.revoke": "Återkalla", "admin.permissions.revoke": "Återkalla",
@@ -33,6 +33,6 @@
</section> </section>
<section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>"> <section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>">
<p class="field-hint"><%= t("admin.clients.rereg") %></p> <p class="field-hint"><%= t("admin.clients.rereg") %></p>
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a> <a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a>
</section> </section>
</div> </div>
@@ -37,6 +37,6 @@
<% } -%> <% } -%>
</section> </section>
<section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>"> <section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>">
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a> <a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
</section> </section>
</div> </div>
@@ -31,8 +31,8 @@
<% } -%> <% } -%>
</section> </section>
<section class="form-card" aria-labelledby="effective-h"> <section class="form-card" aria-labelledby="effective-h">
<h2 class="card-title" id="effective-h">Effective access</h2> <h2 class="card-title" id="effective-h"><%= t("admin.permissions.effective") %></h2>
<p class="field-hint">Everyone who holds this permission — directly or through a group (resolved by Keto).</p> <p class="field-hint"><%= t("admin.permissions.effectiveHint") %></p>
<% if (effective.length) { -%> <% if (effective.length) { -%>
<ul class="plain-list"> <ul class="plain-list">
<% effective.forEach((u) => { -%> <% effective.forEach((u) => { -%>
@@ -40,18 +40,18 @@
<% }) -%> <% }) -%>
</ul> </ul>
<% } else { -%> <% } else { -%>
<p class="cell-muted">No users hold this permission yet.</p> <p class="cell-muted"><%= t("admin.permissions.noEffective") %></p>
<% } -%> <% } -%>
</section> </section>
<section class="form-card" aria-labelledby="add-h"> <section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h">Assign the permission</h2> <h2 class="card-title" id="add-h"><%= t("admin.permissions.assign") %></h2>
<% if (add.options.length) { -%> <% if (add.options.length) { -%>
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Assign</button></form> <form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("admin.permissions.assignAction") %></button></form>
<% } else { -%> <% } else { -%>
<p class="cell-muted">All users and groups already have this permission.</p> <p class="cell-muted"><%= t("admin.permissions.allAssigned") %></p>
<% } -%> <% } -%>
</section> </section>
<section class="form-card admin-actions" aria-label="Permission actions"> <section class="form-card admin-actions" aria-label="<%= t("admin.permissions.actions") %>">
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete permission</a> <a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.permissions.delete") %></a>
</section> </section>
</div> </div>
@@ -30,7 +30,7 @@
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>"> <section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form> <form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
<form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form> <form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
<a class="btn btn-danger" href="<%= edit.deleteAction %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a> <a class="btn btn-danger" href="<%= localeHref(edit.deleteAction) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a>
</section> </section>
<% } -%> <% } -%>
</div> </div>
+5
View File
@@ -51,3 +51,8 @@ test("parseListQuery honours custom reserved names and page-size bounds", () =>
assert.equal(parseListQuery("?n=999", { maxPageSize: 50, pageSizeParam: "n" }).pageSize, 50); assert.equal(parseListQuery("?n=999", { maxPageSize: 50, pageSizeParam: "n" }).pageSize, 50);
assert.deepEqual(parseListQuery("?q=hi", { qParam: "search" }).filters, { q: ["hi"] }); assert.deepEqual(parseListQuery("?q=hi", { qParam: "search" }).filters, { q: ["hi"] });
}); });
test("`locale` is the host's, not a filter — every localized list link carries it", () => {
const query = parseListQuery("/admin/users?locale=sv-SE&status=active");
assert.deepEqual(Object.keys(query.filters), ["status"]);
});
+3 -1
View File
@@ -31,7 +31,9 @@ export function parseListQuery(url: URL | URLSearchParams | string, options: Lis
const sortParam = options.sortParam ?? "sort"; const sortParam = options.sortParam ?? "sort";
const pageParam = options.pageParam ?? "page"; const pageParam = options.pageParam ?? "page";
const pageSizeParam = options.pageSizeParam ?? "pageSize"; const pageSizeParam = options.pageSizeParam ?? "pageSize";
const reserved = new Set([pageParam, pageSizeParam, qParam, sortParam]); // `locale` is host-owned (README → Languages): every list link and both GET forms carry it, so
// without this it would arrive as a phantom filter on every localized list page.
const reserved = new Set([pageParam, pageSizeParam, qParam, sortParam, "locale"]);
const filters: Record<string, string[]> = {}; const filters: Record<string, string[]> = {};
for (const key of new Set(params.keys())) { for (const key of new Set(params.keys())) {
+1 -1
View File
@@ -21,7 +21,7 @@
-%> -%>
<div class="field<% if (error) { %> has-error<% } %>"> <div class="field<% if (error) { %> has-error<% } %>">
<% if (link || optional) { -%> <% if (link || optional) { -%>
<div class="field-top"><label for="<%= id %>"><%= locals.label %></label><% if (link) { %><a class="field-link" href="<%= link.href %>"><%= link.label %></a><% } else { %><span class="optional"><%= t("field.optional") %></span><% } %></div> <div class="field-top"><label for="<%= id %>"><%= locals.label %></label><% if (link) { %><a class="field-link" href="<%= localeHref(link.href) %>"><%= link.label %></a><% } else { %><span class="optional"><%= t("field.optional") %></span><% } %></div>
<% } else { -%> <% } else { -%>
<label for="<%= id %>"><%= locals.label %></label> <label for="<%= id %>"><%= locals.label %></label>
<% } -%> <% } -%>
+1 -1
View File
@@ -104,7 +104,7 @@
<% if (title) { %><h1 class="page-title"><%= title %></h1><% } %> <% if (title) { %><h1 class="page-title"><%= title %></h1><% } %>
<% if (breadcrumbs.length) { %> <% if (breadcrumbs.length) { %>
<nav class="crumbs" aria-label="<%= t("shell.breadcrumb") %>"> <nav class="crumbs" aria-label="<%= t("shell.breadcrumb") %>">
<% breadcrumbs.forEach((c, i) => { %><% if (i) { %><span class="sep">/</span><% } %><% if (c.href) { %><a href="<%= c.href %>"><%= c.label %></a><% } else { %><span><%= c.label %></span><% } %><% }) %> <% breadcrumbs.forEach((c, i) => { %><% if (i) { %><span class="sep">/</span><% } %><% if (c.href) { %><a href="<%= localeHref(c.href) %>"><%= c.label %></a><% } else { %><span><%= c.label %></span><% } %><% }) %>
</nav> </nav>
<% } %> <% } %>
<div class="topbar-spacer"></div> <div class="topbar-spacer"></div>