diff --git a/.gitignore b/.gitignore index b58ff42..aa5c1fc 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ e2e-tests/artifacts/ # config/ is a drop-in mount point for your menu/branding override — keep it empty (see examples/config/ for the template) /config/* !/config/.gitkeep + +# locales/ is a drop-in mount point for extra (or replacement) language catalogs — keep it empty +/locales/* +!/locales/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index 424dea5..0217604 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,8 @@ Intentional, reasoned choices — an architecture review should honor them, not them. Revisit only if the stated reason stops holding. - **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/` - (session-JWT hot path, guards, and the Ory REST clients), `plugin-host/` + (session-JWT hot path, guards, and the Ory REST clients), `i18n/` (locale resolution + the + catalogs, `locales/` holding the data), `plugin-host/` (discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel + `system.ts`, the `ctx.system` capability surface), and `ui/` (design-system view-models + menu/chrome); `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests @@ -99,6 +100,67 @@ them. Revisit only if the stated reason stops holding. README → Auth records the mapping so nobody has to rediscover it. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape and keeps Ory's name — don't rename that one. +- **The locale lives in the URL, never in a cookie.** `?locale=sv-SE` → `Accept-Language` → `en-US`, + and when the URL asked for one the host carries it onto the links it renders (`ctx.localeHref`). + A cookie would make a page's language invisible in its address and unshareable; the cost is that a + plugin must wrap its own hrefs. Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a + lone language from `Accept-Language` takes the first regional catalog for it. Decided 2026-08-03. +- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US` + — 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 + 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.** The shell (breadcrumbs), + `pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every + href they render in `localeHref`; the nav and the sign-in link are wrapped upstream in `chrome.ts`; + and the two GET forms + (filter bar, rows-per-page) carry it as a hidden `locale` input, since a GET submit replaces the + whole query string and no href wrapper can reach it. Putting the obligation on each call site was + tried first and missed five of eight sites inside one commit — including the admin screens. + `ctx.localeHref` remains for hrefs a plugin's own markup emits (the admin example's delete links). + **A form's `action` counts as a link** — a POST replaces the URL as completely as a GET submit, so + the sign-out, consent and auth-card forms carry it too; without that, picking a language and then + saving anything drops back to `Accept-Language`. The one round-trip that cannot carry it is the + Kratos sign-in POST, whose action is an absolute off-site URL. + Decided 2026-08-03 after an architecture review; a second pass then found breadcrumbs still raw, + 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` + so a collision loses the key instead of breaking the shell. +- **The language picker is on every page, POST-rendered ones included.** Maintainer's call + 2026-08-04, overriding an earlier decision to hide it there. The problem it was hiding is real: a + POST-rendered URL frequently answers no GET (`POST /admin/users/:id/recovery`), so a link back to + it dead-ends on a 405. The host therefore resolves the picker's target (`app.ts` → `switchBase`): + this path when it answers GET, else the same-origin Referer, else `/`. Accepted cost: switching + language on such a page leaves that POST's own result behind (a re-rendered form's input, or a + one-time recovery code). Valid while the picker is expected on literally every page — if that ever + softens, hiding it after a POST is the simpler answer. +- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`, + `dashboard`) and an `onRequest` short-circuit dispatch a plugin's handler, so they build the + context with `contextFor(pluginId)` exactly as a plugin route does — otherwise `ctx.t` is the core + translator and the plugin's own keys render as bare keys on the pages it owns. Found by review + 2026-08-03 after all three paths shipped with the host's context. +- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`** — `locales/.ts` + for the core and `locales/plugins//.ts` for an installed plugin, each adding a language or + replacing that tag's catalog wholesale. Adding a language must never require forking the image or a + vendored plugin folder. The SHIPPED `en-US` (core's, or the plugin's own) stays the parity baseline + even when the mount replaces it, so a mounted catalog is checked rather than trusted (one compared + only against itself would boot green with the whole UI rendering keys), and each half is reported + under the folder it actually lives in. +- **RTL is out of scope until there is a real use case.** `textDirection` sets `` from the + locale's script because that is free and correct, but the stylesheet keeps physical `left`/`right` + properties — a genuine RTL locale needs those moved to logical ones first. Don't convert the CSS or + file findings about it on spec. Maintainer's call 2026-08-04; valid while no deployment needs an + RTL language. A catalog there + for a new tag adds a language; one for a tag the image ships replaces that catalog wholesale, held + to the same parity check. Adding a language must not require forking the image. +- **An unknown translation key renders as itself.** That single rule is what lets a nav label, + branding, or a menu `rename` be either a key or plain text without a second field or a migration. + Don't "fix" it into a loud failure: a manifest with plain labels must keep working. +- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value, + so nothing is double-escaped; a message carrying markup uses `<%- %>`, and then its `{{vars}}` are + escaped at the call site (see `views/partials/pagination.ejs`). Don't move escaping into `t()` — + every other value in a view would then be the odd one out. - **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so `docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs can race (one job's logout can 401 another's push — recover by re-running), @@ -178,6 +240,11 @@ Same test before adding a row to a table or the file map — a clause, not a par versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g. `node:24.16.0-alpine3.24`). +- **`HOST_API_VERSION` is frozen at 1.0.0 until the first external install**, even for additive + contract changes (i18n added four `RequestContext` fields and several barrel exports without a + minor bump). Valid while nothing is installed against it: with no third-party plugin in the wild, + a version bump can only produce noise. The promotion trigger is the first external plugin — from + then on, follow the versioning table in README → Contract versioning as written. Decided 2026-08-03. - A plugin's `apiVersion` is a **hand-written literal** semver — the host version the plugin was built against — bumped by hand on rebuild, **never** the host's `HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the @@ -188,8 +255,18 @@ 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 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/`. +- **`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 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 + `src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own + `i18n/`. Operator/developer-facing text — boot errors, log messages, guard messages — stays English. + A pure view-model builder takes an optional `t` defaulting to its own English, so a unit test reads + in words; handlers pass `ctx.t`. - Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y" and not "ids=x,y". diff --git a/README.md b/README.md index d8f0142..5b8a4fc 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ From here, render real pages against the app shell and fetch upstream data — s - [The menu system](#the-menu-system) - [Building blocks](#building-blocks) - [Interactivity: zero-JS spine](#interactivity-zero-js-spine) +- [Languages (i18n)](#languages-i18n) - [Configuration](#configuration) - [canonical host](#canonical-host-one-public-url) - [what you must supply](#what-you-must-supply-the-only-manual-prep) @@ -131,6 +132,25 @@ hand-roll auth for the tenth time. It's not a no-code tool and doesn't hide its parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--access)) reads as obvious rather than surprising, you're the audience. +**Who *they* build for.** The people who end up in front of a Plainpages app are not the +audience above, and three of them shape the design more than any feature request does: + +- **The end user** — anyone using the product you assemble from Plainpages + your plugins. + They never hear the word "plugin": to them the menu, the screens and the sign-in are one app, + which is why the shell, the auth pages and every plugin share one design system, one menu and + one language. +- **The power user** — lives in the app all day. Ctrl-clicks a row to open it in a new tab, + bookmarks a filtered-and-sorted list to come back to on Monday, sends that URL to a colleague, + and edits the query string by hand when it's faster. This is why list state and the chosen + language live **in the URL** and why every navigation is a real ``: middle-click, "open + in new tab", back, and bookmark all have to work without a second thought. +- **The non-technical user** — clicks a button twice when nothing happens fast enough, never + touches the tab key, doesn't distinguish a link from a button, and won't recognise an error + code. This is why destructive actions go through a confirm page instead of an inline + `?confirm=1`, why a form's labels are clickable and its errors sit next to the field they + belong to, and why a page must never depend on keyboard-only affordances. A double-clicked + submit is a real event, not a misuse. + **Included vs. what you add.** - **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design @@ -356,14 +376,16 @@ plugins/things/ # the plugin folder — its name is the id AND the moun things.ejs # your view files; a handler picks one with { view: "things" } public/ # fixed name, optional — static assets, served at /public/things/ things.css # your asset files + i18n/ # fixed name, optional — this plugin's own catalogs (see Languages) + en-US.ts # the baseline; sv-SE.ts et al are written against its type handlers.ts # your code, any names/layout — host never looks here; plugin.ts imports it service.ts # e.g. route handlers, upstream calls, domain helpers — design as you wish ``` **Only `plugin.ts` is required.** The host looks for exactly that filename and its -default-exported manifest. `views/` and `public/` are the two fixed folder *names* it resolves -against — used only if the plugin renders views or serves assets — but the files inside are -yours to name. Everything else (handlers, upstream clients, their filenames and folder layout) +default-exported manifest. `views/`, `public/` and `i18n/` are the fixed folder *names* it resolves +against — used only if the plugin renders views, serves assets or ships translations — but the files +inside are yours to name (a catalog is named for its locale). Everything else (handlers, upstream clients, their filenames and folder layout) the host never sees; `plugin.ts` simply imports it. The `handlers.ts`/`service.ts` split above is just an example — name and arrange your modules however you like, or keep a routes-only plugin to a single `plugin.ts`. @@ -434,7 +456,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder | `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). | | `home` | no | A `RouteHandler` that owns the **public** landing `/`. 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). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. | +| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `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). | | `routes` | no | See [Routes & handlers](#routes--handlers). | | `hooks` | no | See [Hooks](#hooks). | @@ -569,6 +591,10 @@ interface RequestContext { user: User | null; // { id, email, permissions } from the verified session JWT, or null log: Log; // request-scoped logger, in this request's trace params: Record; // path params from the route match, e.g. /things/:id → { id } + t: Translate; // t(key, vars) in this request's language (see Languages); an unknown key renders as itself + locale: string; // the locale being served, e.g. "sv-SE" + locales: string[]; // every installed locale, sorted + localeHref(href): string; // carry an explicitly chosen locale onto a link this page renders query: URLSearchParams; // alias of url.searchParams req: IncomingMessage; res: ServerResponse; @@ -600,6 +626,9 @@ login/registration/front pages), so the menu looks identical signed in or out A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the sidebar, single column); everything else still renders. +**`ctx.t`** translates in the request's language, and the same block (`t`, `locale`, `locales`, +`localeHref`, `dir`) is merged into every view's data — see [Languages](#languages-i18n). + **`ctx.log`** is a structured, request-scoped logger ([`@larvit/log`](https://www.npmjs.com/package/@larvit/log)) already in this request's trace: `ctx.log.info("…", { key: "value" })` (also `warn`/`error`/`debug`, metadata values are string/number/boolean), and **`ctx.log.fetch(url, init?)`** — a drop-in `fetch` @@ -894,6 +923,132 @@ Plugins that genuinely need it — live dashboards, bulk actions, client-side va may **opt into progressive enhancement** (htmx, Alpine, or vanilla JS) on top of working server-rendered HTML. The baseline never depends on it. +## Languages (i18n) + +Every string the host renders comes from a **catalog**: one TypeScript module per locale, named +for the locale it holds. The core ships `en-US` and `sv-SE`; a deployment adds a language by +dropping another file next to them. + +``` +src/i18n/locales/en-US.ts the baseline — every other locale is checked against it +src/i18n/locales/sv-SE.ts +locales/ drop-in mount root: your own catalogs, ships empty (like plugins/ and config/) +locales/plugins//sv-SE.ts the same, for a plugin's words — so adding a language never forks a plugin +plugins//i18n/en-US.ts a plugin's own words, looked up before the host's +plugins//i18n/sv-SE.ts +``` + +`locales/` is the operator's, mounted like `plugins/` and `config/` — a file there for a new tag +**adds** a language, one for a tag the image already ships **replaces** that catalog wholesale (and +is held to the same parity check, so a partial replacement fails the boot instead of leaving half +the app in English). `locales/plugins//.ts` does the same for an installed plugin's words, +checked against *that plugin's* `en-US` — so translating a vendored plugin, or fixing its wording, +never means forking its folder: + +```yaml +# compose.override.yml +services: + web: + volumes: + - ./locales:/app/locales:ro +``` + +**Which language a request gets:** `?locale=sv-SE` wins, else `Accept-Language`, else `en-US`. +Matching is exact on a full tag — `?locale=sv-FI` with only `sv-SE` installed matches nothing and +falls through to `Accept-Language` (and from there to `en-US`), rather than being served a +neighbouring region — but a lone language (`sv`, as browsers send) resolves to the first regional +catalog for it. There is **no locale cookie**: the URL is the only place a choice +is stored, so a link is shareable and a page is what its address says it is. When the URL asked +for a language, the host carries `?locale=` onto every link *it* renders (menu, sign-in, its own +redirects) and `ctx.localeHref(href)` does the same for a plugin's links. The picker in the +sidebar footer (and on the auth pages) lists every installed locale, each a plain link to the +same page in that language; it renders whenever more than one locale is installed — **on every +page**. After a POST the current URL may answer no GET at all (`POST /admin/users/:id/recovery` +renders a page and has no GET sibling), so the host points the picker at the nearest page that does: +this path when it answers GET, else the page the form was submitted from, else `/`. Switching +language there therefore leaves the POST's own result behind — a re-rendered form's input, or a +one-time code — which is the accepted cost of having the picker everywhere. + +**Writing a catalog.** `en-US.ts` exports the object and its type; every other locale is written +against that type, so a missing or misspelled key is a type error before the app ever boots. For a +language of your own: copy `src/i18n/locales/en-US.ts` into `locales/.ts`, type it +`CoreMessages` (from `#plugin-api`), and translate. The `as PluralMessage` cast below is required — +without it the inferred type pins the plural forms to English's two, and a locale that selects more +(Polish, Arabic) becomes unwritable: + +```ts +// plugins/shop/i18n/en-US.ts +import type { PluralMessage } from "#plugin-api"; + +const messages = { + "shop.title": "Shop", + "shop.greeting": "Hello, {{name}}!", + "shop.orders": { one: "{{count}} order", other: "{{count}} orders" } as PluralMessage, +}; +export type ShopMessages = typeof messages; +export default messages; + +// plugins/shop/i18n/sv-SE.ts +import type { ShopMessages } from "./en-US.ts"; +const messages: ShopMessages = { "shop.title": "Butik", /* … */ }; +export default messages; +``` + +At boot every catalog is checked against its set's `en-US`; a missing key, an unknown key, or a +plural message that doesn't cover the categories its locale actually selects (`Intl.PluralRules`) +**stops startup** with the full list — a half-translated deploy never reaches a visitor. A plugin +may translate *fewer* locales than the host (its strings then render in `en-US` on that page), but +never one the host doesn't have. + +**Using it.** `ctx.t(key, vars)` in a handler; in a view `t(...)` is already there, along with +`locale`, `locales`, `localeHref()` and `dir` — the host merges them into every render, at any +include depth: + +```ts +// handler +return { data: { title: ctx.t("shop.title"), lead: ctx.t("shop.greeting", { name }) }, view: "shop" }; + +// a pure view model built outside a request (its unit test) defaults to the plugin's own English: +import { englishTranslator, type Translate } from "#plugin-api"; +import enUS from "./i18n/en-US.ts"; +const EN: Translate = englishTranslator(enUS); // your catalog, then the host's +``` +```html + +

<%= t("shop.title") %>

+

<%= t("shop.orders", { count: orders.length }) %>

+
"><%= t("shop.new") %> +``` + +Three rules worth knowing: + +- **An unknown key renders as itself.** That is what lets a nav label be either a catalog key or + plain text — `nav: [{ label: "shop.title" }]` is translated, `label: "Shop"` is not, and neither + breaks. The same holds for `config/menu.ts` branding and its `rename` overrides. +- **`t()` returns raw text; the view escapes it.** Use `<%= %>` as for any other value. A message + that deliberately carries markup is rendered with `<%- %>` — and its `{{vars}}` must then be + escaped at the call site, since nothing escapes them there (`pagination.ejs` is the worked example). +- **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** — every href they render (menu, breadcrumbs, + pagination, sort headers, row actions, the auth card's links) goes through `localeHref`, and their + GET forms carry it as a hidden field, since a GET submit replaces the whole query string. + `ctx.localeHref` is for hrefs and form actions your own markup emits (a POST replaces the URL just + as a GET submit does), and `localeParam` (a view local: the tag, or null) for your own GET forms. + `locale` is reserved: `parseListQuery` never returns it as a filter. Responses carry + `Vary: Accept-Language`, so a cache in front of the app keys on the language too. +- **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 + to them. Keep your catalog for your domain words, so N plugins don't re-translate "Cancel" N times. +- **These view locals are reserved:** `t`, `locale`, `locales`, `localeHref`, `localeParam`, + `localeSwitch`, `dir`. They are merged after your `data`, so a key of yours with one of those names + is ignored rather than breaking the shell. + +**Kratos writes the auth flow's own text** (field labels, validation errors) and tags each string +with a stable numeric id; a `kratos.` key replaces it, and anything unmapped renders Kratos' +English as-is. Field labels are keyed on the input name instead (`auth.field.password`), because +Kratos' trait-label id is generic — the same id says "Email" on the login form and "First name" on +a registration form. Operator- and developer-facing text (boot errors, logs) stays English. + ## Configuration Read from the environment once at boot (`src/config.ts`) and validated there — a bad URL, @@ -1267,11 +1422,13 @@ docker compose run --rm --no-deps web npm test # node --test (units) E2E runs in the official Playwright image (browsers preinstalled) against the live `web` service — no Node/browsers on the host. There are five suites: -**Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots -the live pages and asserts the rendered design system — the app shell, theme switch, mobile -off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and +**Visual + design system** (`visual.spec.ts`, `language.spec.ts`) — Ory-free, so it stays fast. It +screenshots the live pages and asserts the rendered design system — the app shell, theme switch, +mobile off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and plugin permission-gating — the last exercised by bind-mounting the reference example -(`examples/plugins/scheduling/`) onto `/app/plugins/scheduling`. +(`examples/plugins/scheduling/`) onto `/app/plugins/scheduling`. `language.spec.ts` drives +[language switching](#languages-i18n) in the browser: the picker, the choice surviving a click into +the plugin's own pages, an `Accept-Language`-only visitor, and the fallback for an uninstalled locale. ```bash docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --build --rm e2e # run the suite @@ -1652,6 +1809,16 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *. hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD fetch-timeout.ts withTimeout(): bound every outbound Ory call — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients + i18n/ Translation: which language a request gets, and the words for it + catalog.ts Catalog/Message types + checkCatalog(): the boot-time parity rules a locale is held to (keys, kinds, plural categories) + locale.ts resolveLocale()/matchLocale()/parseAcceptLanguage() (?locale → Accept-Language → en-US) + localeHref(), textDirection(), localeLabel() + translate.ts createTranslator(): key + vars → text — the catalog chain, {{var}} interpolation, Intl.PluralRules selection; an unknown key renders as itself + load.ts loadI18n(): import src/i18n/locales/*.ts + plugins//i18n/*.ts and check every catalog against its en-US baseline — one boot-stopping error listing every problem + runtime.ts createI18n(): the loaded catalogs per request — resolve the locale, hand out a memoised translator per locale+plugin + english.ts The shipped en-US catalog as a ready translator + I18n, for paths the loaded catalogs aren't wired into (tests, ad-hoc contexts) + view-locals.ts i18nLocals(): the t/locale/dir/localeSwitch block merged into every render (EJS passes it down into includes) + locales/ The core catalogs — en-US.ts (the baseline + its type) and sv-SE.ts + plugin-host/ Plugin discovery, routing, hooks, view resolution + the stable author barrel plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath() plugin-api.ts Stable plugin author barrel — the one module a plugin imports, as `#plugin-api` (definePlugin, ctx/result types, guards, body/CSRF/list-query/paginate helpers, and the ctx.system Ory client types) @@ -1671,9 +1838,10 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *. list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize } paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs -views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Permissions/Clients + confirm bodies) +views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, language picker, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Permissions/Clients + confirm bodies) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) config/ Drop-in mount point for the central menu override + branding (config/menu.ts). Ships empty (.gitkeep, git-ignored otherwise) — mount your own or copy the template from examples/config/; defaults apply when absent +locales/ Drop-in mount point for extra (or replacement) language catalogs — a .ts here adds a language for the core, or replaces the shipped catalog for that tag wholesale; plugins//.ts does the same for an installed plugin. Ships empty (.gitkeep, git-ignored otherwise); see Languages ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — permission/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service) plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the example plugins onto /app/plugins/scheduling and /app/plugins/admin examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Permissions/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service) diff --git a/e2e-tests/compose.visual.yml b/e2e-tests/compose.visual.yml index 87ebde1..c2bca97 100644 --- a/e2e-tests/compose.visual.yml +++ b/e2e-tests/compose.visual.yml @@ -1,5 +1,6 @@ # Playwright E2E. Brings up the app + a Playwright runner and exercises the live pages (design -# system, theme switch, mobile layout, CSRF, landing, 404, plugin gating) — Ory-free, so it's fast. +# system, theme switch, mobile layout, CSRF, landing, 404, plugin gating, language switching) — +# Ory-free, so it's fast. # docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --build --rm e2e # docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v # tear down after # --build rebuilds the runner (the image bakes in e2e-tests/) so spec edits are picked up. @@ -29,8 +30,9 @@ services: build: context: . dockerfile: e2e-tests/Dockerfile - # Just the Ory-free visual suite; the full-stack auth spec runs via e2e-tests/compose.auth.yml. - command: ["npx", "playwright", "test", "visual.spec.ts"] + # The Ory-free suites (design system + language switching); the full-stack auth spec runs via + # e2e-tests/compose.auth.yml. + command: ["npx", "playwright", "test", "visual.spec.ts", "language.spec.ts"] depends_on: web: condition: service_healthy diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index 229f36a..aa7faa5 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -24,6 +24,19 @@ async function loginPassword(page: Page): Promise { await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL); // waits through the redirect chain } +// The themed Kratos page in another language: our own chrome, Kratos' own strings mapped by id, and +// the card's own links keeping the choice (they are rendered by the flow body, not by the menu). +test("the login page speaks the visitor's language, links included", async ({ browser }) => { + const page = await (await browser.newContext()).newPage(); + await page.goto("/login?locale=sv-SE"); + await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); + await expect(page.getByRole("heading", { name: "Logga in" })).toBeVisible(); + await expect(page.getByLabel("Lösenord", { exact: true })).toBeVisible(); // Kratos' own field, labelled via auth.field.password + await expect(page.getByRole("link", { name: "Glömt lösenordet?" })).toHaveAttribute("href", /locale=sv-SE/); + await expect(page.getByRole("link", { name: "Skapa ett" })).toHaveAttribute("href", /locale=sv-SE/); + await page.context().close(); +}); + test.describe.serial("authenticated admin journey", () => { let browser: Browser; let page: Page; @@ -36,6 +49,57 @@ test.describe.serial("authenticated admin journey", () => { }); test.afterAll(async () => { await page.context().close(); }); + // The list screens rebuild their query from the list state (sort/page/filter), so they are where + // a chosen language used to get dropped — the core building blocks carry it now. + test("a sorted, paged admin list keeps the visitor's language", async () => { + await page.goto("/admin/users?locale=sv-SE"); + await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); + await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible(); + + await page.getByRole("link", { name: /E-postadress/ }).click(); // a sort header + await expect(page).toHaveURL(/locale=sv-SE/); + await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); + + await page.getByRole("button", { name: "Använd filter" }).click(); // the filter bar's GET form + await expect(page).toHaveURL(/locale=sv-SE/); + await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); + + await page.getByRole("button", { name: "Visa" }).click(); // the rows-per-page GET form + await expect(page).toHaveURL(/locale=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"); + }); + + // A POST that re-renders a page: the write must keep the language, and the picker — which is on + // every page — must point somewhere that answers GET rather than at the POST-only URL. + test("a write keeps the visitor's language, and the picker still works on the POST-rendered page", async () => { + await page.goto("/admin/users?locale=sv-SE"); + await page.getByRole("link", { name: "Ny användare" }).click(); + await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`); + await page.getByRole("button", { name: "Skapa användare" }).click(); + await expect(page).toHaveURL(/locale=sv-SE/); // the POST → redirect → GET keeps it + await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); + + // Open the new user's edit page the way the CRUD test does — the row's Edit link carries the id. + const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` }); + const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href"); + await page.goto(`${editHref}`); + await expect(page.locator('summary[aria-label="Språk"]')).toHaveCount(1); + await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route + await expect(page.getByText("Återställningskod skapad")).toBeVisible(); + + // The picker is here too, and following it lands on a real page in the other language. + await page.locator('summary[aria-label="Språk"]').click(); + await page.getByRole("link", { name: /English/i }).click(); + expect(page.url()).toContain("locale=en-US"); + await expect(page.locator("html")).toHaveAttribute("lang", "en-US"); + await expect(page.getByRole("heading", { name: "Edit user" })).toBeVisible(); // not a 405 + }); + test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => { // The signed-in admin holds admin + scheduling:read/write, so both gated sections are present // in the menu (collapsed by default → assert they're in the DOM, not necessarily visible). diff --git a/e2e-tests/language.spec.ts b/e2e-tests/language.spec.ts new file mode 100644 index 0000000..4efb682 --- /dev/null +++ b/e2e-tests/language.spec.ts @@ -0,0 +1,84 @@ +import { readFileSync } from "node:fs"; +import { createPrivateKey, sign } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import { expect, test } from "@playwright/test"; + +// Language switching in a real browser, Ory-free (the visual stack). Proves the whole path a +// visitor takes: pick a language, read the page in it, and stay in it while clicking around — +// including into a plugin, whose words come from its own catalog (plugins/scheduling/i18n/). + +const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000"; +const SESSION_COOKIE = "plainpages_jwt"; +const SHOTS = "artifacts/screenshots"; + +// Same trick as visual.spec.ts: sign a session JWT with the committed dev tokenizer key so the +// gated pages render without standing up Ory. +function devSession(permissions: string[] = []): string { + const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0]; + const key = createPrivateKey({ format: "jwk", key: jwk }); + const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url"); + const now = Math.floor(Date.now() / 1000); + const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "lang-demo" })}`; + return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`; +} + +test("the switcher changes language, and the choice survives clicking through the app", async ({ page, context }) => { + await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession(["scheduling:read"]) }]); + + await page.goto("/dashboard"); + await expect(page.locator("html")).toHaveAttribute("lang", "en-US"); + await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible(); + + // The picker sits in the sidebar footer beside the theme switch; each entry is a plain link to + // this same page in that language (zero-JS). + await page.locator('summary[aria-label="Language"]').click(); + await page.getByRole("link", { name: /svenska/i }).click(); + + await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); + await expect(page).toHaveURL(/locale=sv-SE/); + await expect(page.getByRole("heading", { name: "Startpanel" })).toBeVisible(); // the starter dashboard, in Swedish + await expect(page.getByRole("link", { name: "Översikt", exact: true })).toBeVisible(); // the menu too + await mkdir(SHOTS, { recursive: true }); + await page.screenshot({ fullPage: true, path: `${SHOTS}/live-05-swedish.png` }); + + // Clicking a menu item keeps Swedish — the host carries the choice onto the links it renders, + // and the plugin's own page is translated from its own catalog. The section's own label comes + // from the plugin's catalog too, so opening it proves the nav fragment was translated. + await page.locator('summary[aria-label="Visa eller dölj Schemaläggning"]').click(); + await page.getByRole("link", { name: "Pass", exact: true }).click(); + await expect(page).toHaveURL(/\/scheduling\/shifts\?locale=sv-SE/); + await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); + await expect(page.getByRole("heading", { name: "Pass" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Sök" })).toBeVisible(); // the core filter bar, in Swedish + + // The filter bar is a GET form: submitting it replaces the whole query string, so the choice + // survives only because the form carries it as a hidden field. + await page.getByRole("button", { name: "Sök" }).click(); + await expect(page).toHaveURL(/locale=sv-SE/); + await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); + + // …and back to English the same way. + await page.locator('summary[aria-label="Språk"]').click(); + await page.getByRole("link", { name: /English/i }).click(); + await expect(page.locator("html")).toHaveAttribute("lang", "en-US"); + await expect(page.getByRole("heading", { name: "Shifts" })).toBeVisible(); +}); + +test("a browser that asks for Swedish gets it without touching the URL", async ({ browser }) => { + const context = await browser.newContext({ locale: "sv" }); // a browser set to Swedish, no region + const page = await context.newPage(); + await page.goto(`${BASE_URL}/`); + + await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); + const signIn = page.locator("#main-content").getByRole("link", { name: "Logga in" }); + await expect(signIn).toBeVisible(); + // Nothing was chosen in the URL, so the links stay plain — the browser asks again on the next hit. + await expect(signIn).toHaveAttribute("href", "/login"); + await context.close(); +}); + +test("an uninstalled language falls back to English rather than failing", async ({ page }) => { + const response = await page.goto("/?locale=sv-FI"); // sv-SE is installed; sv-FI is not + expect(response?.status()).toBe(200); + await expect(page.locator("html")).toHaveAttribute("lang", "en-US"); +}); diff --git a/examples/config/menu.ts b/examples/config/menu.ts index d625e02..b41bbe9 100644 --- a/examples/config/menu.ts +++ b/examples/config/menu.ts @@ -12,14 +12,14 @@ import { defineMenu } from "#menu-config"; export default defineMenu({ branding: { name: "Plainpages", // app name shown in the sidebar - sub: "Console", // optional subtitle under the name + sub: "Console", // optional subtitle under the name — a catalog key here would be translated // logo: "/public/logo.svg", // optional logo asset (rendered in the sidebar brand) // theme: "auto", // default color theme: auto | light | dark }, // Operator override (rename → group → order → hide), keyed by node id. override: { - // rename: { people: "Staff" }, // node id → new label + // rename: { people: "Staff" }, // node id → new label (or a catalog key) // groups: [{ id: "admin", label: "Admin", children: ["users", "permissions"] }], // order: ["people", "reports"], // top-level order by id // hide: ["teams"], // remove nodes (any depth) diff --git a/examples/plugins/admin/README.md b/examples/plugins/admin/README.md index 7ec84b9..0ce7d75 100644 --- a/examples/plugins/admin/README.md +++ b/examples/plugins/admin/README.md @@ -13,6 +13,11 @@ docker compose restart web The seeded `admin@plainpages.local` already holds the `admin` permission, so the section appears in the menu and the screens work immediately. +Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`) — the nav +labels included, which are catalog keys in `admin-shared.ts`. Each pure view-model builder takes an +optional `t`; the handlers pass `ctx.t`, and the default is the plugin's own English so a unit test +reads in words rather than keys. (README → [Languages](../../../README.md#languages-i18n).) + ## What it demonstrates — a *system* plugin Most plugins fetch their data from an upstream service of their own (see the [scheduling diff --git a/examples/plugins/admin/admin-clients.ts b/examples/plugins/admin/admin-clients.ts index 1069e14..e200692 100644 --- a/examples/plugins/admin/admin-clients.ts +++ b/examples/plugins/admin/admin-clients.ts @@ -5,8 +5,8 @@ // PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin // per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded. -import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api"; -import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; +import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api"; +import { ADMIN_CLIENTS_BASE, ADMIN_EN, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import type { FieldConfig } from "./admin-users.ts"; const DEFAULT_PAGE_SIZE = 25; @@ -64,14 +64,14 @@ export function clientPayload(input: ClientInput): Record { }; } -export function validateClientInput(input: ClientInput): string | null { - if (!input.name) return "Enter a name for the client."; - if (!input.redirectUris.length) return "Add at least one redirect URI."; +export function validateClientInput(input: ClientInput, t: Translate = ADMIN_EN): string | null { + if (!input.name) return t("admin.clients.validation.name"); + if (!input.redirectUris.length) return t("admin.clients.validation.redirectUris"); for (const uri of input.redirectUris) { try { new URL(uri); // must be an absolute URL — any scheme (public/native clients use custom ones) } catch { - return `"${uri}" is not a valid redirect URI — use an absolute URL like https://app.example.com/callback.`; + return t("admin.clients.validation.redirectUri", { uri }); } } return null; @@ -102,8 +102,10 @@ function listHref(state: ListState, overrides: Partial = {}): string export function buildClientsListModel(opts: { clients: OAuth2Client[]; csrfToken?: string; + t?: Translate; url: URL | URLSearchParams | string; }) { + const t = opts.t ?? ADMIN_EN; const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE }); const needle = query.q.toLowerCase(); @@ -116,56 +118,56 @@ export function buildClientsListModel(opts: { const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q }; return { - breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }], - filterBar: listFilterBar(state), - pagination: listPagination(state, page), - table: listTable(rows), - title: "OAuth2 clients", + breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }], + filterBar: listFilterBar(state, t), + pagination: listPagination(state, page, t), + table: listTable(rows, t), + title: t("admin.clients.title"), }; } -function listTable(rows: ClientView[]) { +function listTable(rows: ClientView[], t: Translate) { return { - caption: "OAuth2 clients", - columns: [{ label: "Name" }, { label: "Client ID" }, { label: "Type" }], + caption: t("admin.clients.title"), + columns: [{ label: t("admin.clients.column.name") }, { label: t("admin.clients.column.id") }, { label: t("admin.clients.column.type") }], rows: rows.map((c) => ({ cells: [ { rowHeader: { href: detailHref(c.id), text: c.name } }, { className: "cell-muted", text: c.id }, - { badge: { label: c.public ? "Public" : "Confidential", tone: c.public ? "warn" : "info" } }, + { badge: { label: c.public ? t("admin.clients.public") : t("admin.clients.confidential"), tone: c.public ? "warn" : "info" } }, ], name: c.name, })), }; } -function listFilterBar(state: ListState) { +function listFilterBar(state: ListState, t: Translate) { const pills: { label: string; remove: string; value: string }[] = []; - if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q }); + if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q }); return { - applyLabel: "Apply", + applyLabel: t("filter.apply"), clearHref: ADMIN_CLIENTS_BASE, - label: "Filter clients", + label: t("admin.clients.filter"), pills, rows: [[ - { label: "Search clients", name: "q", placeholder: "Search name or client ID…", type: "search", value: state.q }, + { label: t("admin.clients.searchLabel"), name: "q", placeholder: t("admin.clients.searchPlaceholder"), type: "search", value: state.q }, { type: "spacer" }, ]], }; } -function listPagination(state: ListState, page: ReturnType) { +function listPagination(state: ListState, page: ReturnType, t: Translate) { const hidden: { name: string; value: string }[] = []; if (state.q) hidden.push({ name: "q", value: state.q }); return { - label: "Clients pagination", + label: t("admin.clients.pagination"), next: { href: page.next ? listHref(state, { page: page.next }) : undefined }, pages: page.pages.map((p) => p.ellipsis ? { ellipsis: true } : p.current ? { current: true, label: String(p.page) } : { href: listHref(state, { page: p.page as number }), label: String(p.page) }), prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined }, - rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize }, + rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize }, summary: { from: page.from, to: page.to, total: page.total }, }; } @@ -175,18 +177,20 @@ function listPagination(state: ListState, page: ReturnType) { export function buildClientFormModel(opts: { csrfToken?: string; error?: string; + t?: Translate; values?: Partial; }) { + const t = opts.t ?? ADMIN_EN; const v = opts.values; const nameField: FieldConfig = { - autocomplete: "off", icon: "i-box", id: "name", label: "Name", name: "name", required: true, value: v?.name ?? "", + autocomplete: "off", icon: "i-box", id: "name", label: t("admin.clients.field.name"), name: "name", required: true, value: v?.name ?? "", }; const scopeField: FieldConfig = { - hint: "Space-separated scopes the client may request.", id: "scope", label: "Scopes", name: "scope", + hint: t("admin.clients.field.scopesHint"), id: "scope", label: t("admin.clients.field.scopes"), name: "scope", value: v?.scope ?? DEFAULT_SCOPE, }; return { - breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }], + breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: t("admin.clients.register") }], error: opts.error, form: { action: ADMIN_CLIENTS_BASE, @@ -197,9 +201,9 @@ export function buildClientFormModel(opts: { public: v?.public ?? false, redirectUris: (v?.redirectUris ?? []).join("\n"), scopeField, - submitLabel: "Register client", + submitLabel: t("admin.clients.registerClient"), }, - title: "Register client", + title: t("admin.clients.registerTitle"), }; } @@ -208,16 +212,18 @@ export function buildClientDetailModel(opts: { created?: boolean; // just registered → success banner + the one-time secret (if any) csrfToken?: string; secret?: string; // one-time client_secret (confidential clients), shown once right after create + t?: Translate; }) { + const t = opts.t ?? ADMIN_EN; const base = detailHref(opts.client.id); return { - breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }], + breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: opts.client.name }], client: opts.client, created: opts.created ?? false, csrfToken: opts.csrfToken ?? "", delete: { action: `${base}/delete` }, secret: opts.secret, - title: opts.created ? "Client registered" : opts.client.name, + title: opts.created ? t("admin.clients.created") : opts.client.name, }; } @@ -241,7 +247,7 @@ function withClients(inner: (deps: ClientsDeps) => Promise): RouteH return async (ctx) => { const user = requireAdmin(ctx); const hydra = ctx.system?.hydra; - if (!hydra) return unavailable(ctx, "Hydra OAuth2 admin"); + if (!hydra) return unavailable(ctx, ctx.t("admin.capability.hydra")); return inner({ ctx, hydra, user }); }; } @@ -257,27 +263,27 @@ function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) } const clientFormResult = (ctx: RequestContext, extra: { error?: string; values?: Partial }): RouteResult => - ({ data: { chrome: ctx.chrome, model: buildClientFormModel({ csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "client-form" }); + ({ data: { chrome: ctx.chrome, model: buildClientFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-form" }); const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult => - ({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ client: toClientView(client), csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "client-detail" }); + ({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-detail" }); // GET /admin/clients — the list. export const clientsList = withClients(async ({ ctx, hydra }) => { const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE }); - return { data: { chrome: ctx.chrome, model: buildClientsListModel({ clients, csrfToken: ctx.chrome.csrfToken, url: ctx.url }) }, view: "clients" }; + return { data: { chrome: ctx.chrome, model: buildClientsListModel({ clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, url: ctx.url }) }, view: "clients" }; }); // POST /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never // returns it again). A Hydra 4xx (bad redirect/scope) re-renders the form (400); a 5xx rethrows → 500. export const clientsCreate = withClients(async ({ ctx, hydra, user }) => { const input = readClientInput((await guardedForm(ctx))!); - const error = validateClientInput(input); + const error = validateClientInput(input, ctx.t); if (error) return { ...clientFormResult(ctx, { error, values: input }), status: 400 }; let created: OAuth2Client; try { created = await hydra.createClient(clientPayload(input)); } catch (err) { - if (err instanceof HydraError && err.status < 500) return { ...clientFormResult(ctx, { error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input }), status: 400 }; + if (err instanceof HydraError && err.status < 500) return { ...clientFormResult(ctx, { error: ctx.t("admin.clients.error.rejected"), values: input }), status: 400 }; throw err; } ctx.log.info("admin: oauth2 client registered", { actor: user.id, client: created.client_id ?? "" }); @@ -294,10 +300,11 @@ export const clientsDetail = withClient((deps, client) => Promise.resolve(client export const clientsDeleteConfirm = withClient((deps, client, id) => { const base = detailHref(id); const name = toClientView(client).name; + const tt = deps.ctx.t; return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ - breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }], - cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client", - message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, title: "Delete client", + breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: tt("admin.clients.title") }, { href: base, label: name }, { label: tt("common.delete") }], + cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.clients.delete"), + message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"), }) }, view: "confirm" }); }); diff --git a/examples/plugins/admin/admin-groups.ts b/examples/plugins/admin/admin-groups.ts index 2f5e00b..12ff1e4 100644 --- a/examples/plugins/admin/admin-groups.ts +++ b/examples/plugins/admin/admin-groups.ts @@ -6,8 +6,8 @@ // per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded, // each returning a RouteResult. -import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type User } from "#plugin-api"; -import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; +import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "#plugin-api"; +import { ADMIN_EN, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import type { FieldConfig } from "./admin-users.ts"; const GROUP_NS = "Group"; @@ -90,8 +90,8 @@ const SORT: Record number | string> = { name: (g) => g.name, }; const COLUMNS = [ - { key: "name", label: "Group" }, - { key: "members", label: "Members" }, + { key: "name", label: "admin.groups.column.name" }, + { key: "members", label: "admin.groups.column.members" }, ]; function detailHref(name: string): string { @@ -112,8 +112,10 @@ function listHref(state: ListState, overrides: Partial = {}): string export function buildGroupsListModel(opts: { csrfToken?: string; groups: GroupView[]; + t?: Translate; url: URL | URLSearchParams | string; }) { + const t = opts.t ?? ADMIN_EN; const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE }); const sort = query.sort && SORT[query.sort.field] ? query.sort : null; const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null; @@ -136,21 +138,21 @@ export function buildGroupsListModel(opts: { const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken }; return { - breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Admin" }, { label: "Groups" }], - filterBar: listFilterBar(state), - pagination: listPagination(state, page), - table: listTable(rows, state, sort), - title: "Groups", + breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }], + filterBar: listFilterBar(state, t), + pagination: listPagination(state, page, t), + table: listTable(rows, state, sort, t), + title: t("admin.groups.title"), }; } -function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) { +function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) { return { - caption: "Groups", + caption: t("admin.groups.title"), columns: COLUMNS.map((c) => { const dir = sort && sort.field === c.key ? sort.dir : undefined; const next = dir === "asc" ? `-${c.key}` : c.key; - return { href: listHref(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true }; + return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true }; }), rows: rows.map((g) => ({ cells: [{ rowHeader: { href: detailHref(g.name), text: g.name } }, String(g.memberCount)], @@ -159,34 +161,34 @@ function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "de }; } -function listFilterBar(state: ListState) { +function listFilterBar(state: ListState, t: Translate) { const pills: { label: string; remove: string; value: string }[] = []; - if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q }); + if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q }); return { - applyLabel: "Apply", + applyLabel: t("filter.apply"), clearHref: ADMIN_GROUPS_BASE, - label: "Filter groups", + label: t("admin.groups.filter"), pills, rows: [[ - { label: "Search groups", name: "q", placeholder: "Search group name…", type: "search", value: state.q }, + { label: t("admin.groups.searchLabel"), name: "q", placeholder: t("admin.groups.searchPlaceholder"), type: "search", value: state.q }, { type: "spacer" }, ]], }; } -function listPagination(state: ListState, page: ReturnType) { +function listPagination(state: ListState, page: ReturnType, t: Translate) { const hidden: { name: string; value: string }[] = []; if (state.q) hidden.push({ name: "q", value: state.q }); if (state.sort) hidden.push({ name: "sort", value: state.sort }); return { - label: "Groups pagination", + label: t("admin.groups.pagination"), next: { href: page.next ? listHref(state, { page: page.next }) : undefined }, pages: page.pages.map((p) => p.ellipsis ? { ellipsis: true } : p.current ? { current: true, label: String(p.page) } : { href: listHref(state, { page: p.page as number }), label: String(p.page) }), prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined }, - rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize }, + rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize }, summary: { from: page.from, to: page.to, total: page.total }, }; } @@ -197,14 +199,16 @@ export function buildGroupFormModel(opts: { csrfToken?: string; error?: string; memberOptions: MemberOption[]; + t?: Translate; values?: { member?: string; name?: string }; }) { + const t = opts.t ?? ADMIN_EN; const nameField: FieldConfig = { - autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-layers", - id: "name", label: "Group name", name: "name", required: true, value: opts.values?.name ?? "", + autocomplete: "off", hint: t("admin.groups.field.nameHint"), icon: "i-layers", + id: "name", label: t("admin.groups.field.name"), name: "name", required: true, value: opts.values?.name ?? "", }; return { - breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: "New" }], + breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: t("common.new") }], error: opts.error, form: { action: ADMIN_GROUPS_BASE, @@ -213,9 +217,9 @@ export function buildGroupFormModel(opts: { memberOptions: opts.memberOptions, nameField, selectedMember: opts.values?.member ?? "", - submitLabel: "Create group", + submitLabel: t("admin.groups.create"), }, - title: "New group", + title: t("admin.groups.new"), }; } @@ -225,7 +229,9 @@ export function buildGroupDetailModel(opts: { error?: string; group: { name: string }; members: MemberView[]; + t?: Translate; }) { + const t = opts.t ?? ADMIN_EN; const name = opts.group.name; const base = detailHref(name); const taken = new Set(opts.members.map((m) => m.subject)); @@ -233,7 +239,7 @@ export function buildGroupDetailModel(opts: { const options = opts.candidates.filter((c) => c.value !== self && !taken.has(c.value)); return { add: { action: `${base}/members`, options }, - breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: name }], + breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: name }], csrfToken: opts.csrfToken ?? "", delete: { action: `${base}/delete` }, error: opts.error, @@ -288,7 +294,7 @@ function withGroups(inner: (deps: GroupsDeps) => Promise): RouteHan const user = requireAdmin(ctx); const keto = ctx.system?.keto; const kratosAdmin = ctx.system?.kratosAdmin; - if (!keto || !kratosAdmin) return unavailable(ctx, "Keto and Kratos identity admin"); + if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto")); return inner({ ctx, keto, kratosAdmin, user }); }; } @@ -304,13 +310,13 @@ function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise => { const { options } = await memberCandidates(deps.keto, deps.kratosAdmin); - return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "group-form" }; + return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "group-form" }; }; // GET /admin/groups — the list. export const groupsList = withGroups(async ({ ctx, keto }) => { const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS })); - return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ csrfToken: ctx.chrome.csrfToken, groups, url: ctx.url }) }, view: "groups" }; + return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" }; }); // POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple). @@ -321,8 +327,8 @@ export const groupsCreate = withGroups(async (deps) => { const member = (form.get("member") ?? "").trim(); const tuple = memberTuple(name, member); const reject = async (error: string): Promise => ({ ...(await groupFormResult(deps, { error, values: { member, name } })), status: 400 }); - if (!isValidGroupName(name)) return reject("Group names use lowercase letters, digits, dashes and underscores."); - if (!tuple) return reject("Pick a member to add as the group's first member."); + if (!isValidGroupName(name)) return reject(ctx.t("admin.groups.validation.name")); + if (!tuple) return reject(ctx.t("admin.groups.validation.member")); if (await groupExists(keto, name)) return reject("A group with that name already exists."); await keto.writeTuple(tuple); ctx.log.info("admin: group created", { actor: user.id, group: name }); @@ -336,7 +342,7 @@ export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {})); export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => { const { emailById, options } = await memberCandidates(keto, kratosAdmin); const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById)); - return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members }) }, view: "group-detail" }; + return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, t: ctx.t }) }, view: "group-detail" }; }); // POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest). @@ -350,10 +356,11 @@ export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => { // GET /admin/groups/:name/delete — the deliberate confirm step. export const groupsDeleteConfirm = withGroupName((deps, name) => { const base = detailHref(name); + const tt = deps.ctx.t; return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ - breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }], - cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete group", - message: `Delete group ${name}? This removes the group and all its memberships.`, title: "Delete group", + breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("common.delete") }], + cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"), + message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"), }) }, view: "confirm" }); }); diff --git a/examples/plugins/admin/admin-permissions.ts b/examples/plugins/admin/admin-permissions.ts index a6bd4c5..7a52411 100644 --- a/examples/plugins/admin/admin-permissions.ts +++ b/examples/plugins/admin/admin-permissions.ts @@ -8,8 +8,8 @@ // Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on // ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded. -import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api"; -import { ADMIN_PERMISSION, ADMIN_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; +import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api"; +import { ADMIN_EN, ADMIN_PERMISSION, ADMIN_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import { type GroupView, groupsFromTuples, @@ -75,8 +75,8 @@ const SORT: Record number | string> = { name: (r) => r.name, }; const COLUMNS = [ - { key: "name", label: "Permission" }, - { key: "members", label: "Members" }, + { key: "name", label: "admin.permissions.column.name" }, + { key: "members", label: "admin.permissions.column.members" }, ]; function detailHref(name: string): string { @@ -97,8 +97,10 @@ function listHref(state: ListState, overrides: Partial = {}): string export function buildPermissionsListModel(opts: { csrfToken?: string; permissions: PermissionView[]; + t?: Translate; url: URL | URLSearchParams | string; }) { + const t = opts.t ?? ADMIN_EN; const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE }); const sort = query.sort && SORT[query.sort.field] ? query.sort : null; const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null; @@ -121,21 +123,21 @@ export function buildPermissionsListModel(opts: { const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken }; return { - breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Admin" }, { label: "Permissions" }], - filterBar: listFilterBar(state), - pagination: listPagination(state, page), - table: listTable(rows, state, sort), - title: "Permissions", + breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.nav.section") }, { label: t("admin.permissions.title") }], + filterBar: listFilterBar(state, t), + pagination: listPagination(state, page, t), + table: listTable(rows, state, sort, t), + title: t("admin.permissions.title"), }; } -function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) { +function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) { return { - caption: "Permissions", + caption: t("admin.permissions.title"), columns: COLUMNS.map((c) => { const dir = sort && sort.field === c.key ? sort.dir : undefined; const next = dir === "asc" ? `-${c.key}` : c.key; - return { href: listHref(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true }; + return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true }; }), rows: rows.map((r) => ({ cells: [{ rowHeader: { href: detailHref(r.name), text: r.name } }, String(r.memberCount)], @@ -144,34 +146,34 @@ function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc" }; } -function listFilterBar(state: ListState) { +function listFilterBar(state: ListState, t: Translate) { const pills: { label: string; remove: string; value: string }[] = []; - if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q }); + if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q }); return { - applyLabel: "Apply", + applyLabel: t("filter.apply"), clearHref: ADMIN_PERMISSIONS_BASE, - label: "Filter permissions", + label: t("admin.permissions.filter"), pills, rows: [[ - { label: "Search permissions", name: "q", placeholder: "Search permission name…", type: "search", value: state.q }, + { label: t("admin.permissions.searchLabel"), name: "q", placeholder: t("admin.permissions.searchPlaceholder"), type: "search", value: state.q }, { type: "spacer" }, ]], }; } -function listPagination(state: ListState, page: ReturnType) { +function listPagination(state: ListState, page: ReturnType, t: Translate) { const hidden: { name: string; value: string }[] = []; if (state.q) hidden.push({ name: "q", value: state.q }); if (state.sort) hidden.push({ name: "sort", value: state.sort }); return { - label: "Roles pagination", + label: t("admin.permissions.pagination"), next: { href: page.next ? listHref(state, { page: page.next }) : undefined }, pages: page.pages.map((p) => p.ellipsis ? { ellipsis: true } : p.current ? { current: true, label: String(p.page) } : { href: listHref(state, { page: p.page as number }), label: String(p.page) }), prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined }, - rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize }, + rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize }, summary: { from: page.from, to: page.to, total: page.total }, }; } @@ -182,14 +184,16 @@ export function buildPermissionFormModel(opts: { csrfToken?: string; error?: string; memberOptions: MemberOption[]; + t?: Translate; values?: { member?: string; name?: string }; }) { + const t = opts.t ?? ADMIN_EN; const nameField: FieldConfig = { - autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield", - id: "name", label: "Permission name", name: "name", required: true, value: opts.values?.name ?? "", + autocomplete: "off", hint: t("admin.permissions.field.nameHint"), icon: "i-shield", + id: "name", label: t("admin.permissions.field.name"), name: "name", required: true, value: opts.values?.name ?? "", }; return { - breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: "New" }], + breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: t("common.new") }], error: opts.error, form: { action: ADMIN_PERMISSIONS_BASE, @@ -198,9 +202,9 @@ export function buildPermissionFormModel(opts: { memberOptions: opts.memberOptions, nameField, selectedMember: opts.values?.member ?? "", - submitLabel: "Create permission", + submitLabel: t("admin.permissions.create"), }, - title: "New permission", + title: t("admin.permissions.new"), }; } @@ -211,14 +215,16 @@ export function buildPermissionDetailModel(opts: { error?: string; members: MemberView[]; permission: { name: string }; + t?: Translate; }) { + const t = opts.t ?? ADMIN_EN; const name = opts.permission.name; const base = detailHref(name); const taken = new Set(opts.members.map((m) => m.subject)); const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the permission itself return { add: { action: `${base}/members`, options }, - breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: name }], + breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: name }], csrfToken: opts.csrfToken ?? "", delete: { action: `${base}/delete` }, effective: opts.effective, @@ -263,7 +269,7 @@ function withRoles(inner: (deps: RolesDeps) => Promise): RouteHandl const user = requireAdmin(ctx); const keto = ctx.system?.keto; const kratosAdmin = ctx.system?.kratosAdmin; - if (!keto || !kratosAdmin) return unavailable(ctx, "Keto and Kratos identity admin"); + if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto")); return inner({ ctx, keto, kratosAdmin, revoke: ctx.system?.revoke, user }); }; } @@ -279,7 +285,7 @@ function withRoleName(inner: (deps: RolesDeps, name: string) => Promise => { const { options } = await memberCandidates(deps.keto, deps.kratosAdmin); - return { data: { chrome: deps.ctx.chrome, model: buildPermissionFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "permission-form" }; + return { data: { chrome: deps.ctx.chrome, model: buildPermissionFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "permission-form" }; }; // The permission detail (members + effective access). With `error` set it's a 400 (a rejected action). @@ -288,14 +294,14 @@ const permissionDetailResult = async (deps: RolesDeps, name: string, error?: str const tuples = await pagedTuples(deps.keto, { namespace: PERMISSION_NS, object: name, relation: GRANTED }); const members = tuples.map((t) => memberView(t, emailById)); const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById); - const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildPermissionDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, permission: { name }, ...(error ? { error } : {}) }) }, view: "permission-detail" }; + const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildPermissionDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, permission: { name }, t: deps.ctx.t, ...(error ? { error } : {}) }) }, view: "permission-detail" }; return error ? { ...result, status: 400 } : result; }; // GET /admin/permissions — the list. export const rolesList = withRoles(async ({ ctx, keto }) => { const permissions = permissionsFromTuples(await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED })); - return { data: { chrome: ctx.chrome, model: buildPermissionsListModel({ csrfToken: ctx.chrome.csrfToken, permissions, url: ctx.url }) }, view: "permissions" }; + return { data: { chrome: ctx.chrome, model: buildPermissionsListModel({ csrfToken: ctx.chrome.csrfToken, permissions, t: ctx.t, url: ctx.url }) }, view: "permissions" }; }); // POST /admin/permissions — create + assign the first member (a *user* grant revokes their live tokens). @@ -306,8 +312,8 @@ export const rolesCreate = withRoles(async (deps) => { const member = (form.get("member") ?? "").trim(); const tuple = permissionGrantTuple(name, member); const reject = async (error: string): Promise => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 }); - if (!isValidRoleName(name)) return reject("Permission names use lowercase letters, digits, dashes and underscores."); - if (!tuple) return reject("Pick a user or group to assign the permission to."); + if (!isValidRoleName(name)) return reject(ctx.t("admin.permissions.validation.name")); + if (!tuple) return reject(ctx.t("admin.permissions.validation.member")); if (await roleExists(keto, name)) return reject("A permission with that name already exists."); await keto.writeTuple(tuple); revokeUserMember(revoke, member); @@ -333,12 +339,13 @@ export const rolesAddMember = withRoleName(async (deps, name) => { // GET /admin/permissions/:name/delete — confirm, except the admin permission can't be deleted. export const rolesDeleteConfirm = withRoleName((deps, name) => { - if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access."); + if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.adminUndeletable")); const base = detailHref(name); + const tt = deps.ctx.t; return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ - breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { href: base, label: name }, { label: "Delete" }], - cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete permission", - message: `Delete permission ${name}? This revokes it from everyone it's assigned to.`, title: "Delete permission", + breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: tt("admin.permissions.title") }, { href: base, label: name }, { label: tt("common.delete") }], + cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.permissions.delete"), + message: tt("admin.permissions.deleteMessage", { name }), title: tt("admin.permissions.delete"), }) }, view: "confirm" }); }); @@ -347,7 +354,7 @@ export const rolesDeleteConfirm = withRoleName((deps, name) => { export const rolesDelete = withRoleName(async (deps, name) => { const { ctx, keto, user } = deps; await guardedForm(ctx); // CSRF-verify the POST - if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access."); + if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.adminUndeletable")); await keto.deleteTuple({ namespace: PERMISSION_NS, object: name, relation: GRANTED }); ctx.log.info("admin: permission deleted", { actor: user.id, permission: name }); return { redirect: ADMIN_PERMISSIONS_BASE }; @@ -360,7 +367,7 @@ export const rolesRemoveMember = withRoleName(async (deps, name) => { const { ctx, keto, revoke, user } = deps; const form = (await guardedForm(ctx))!; const member = (form.get("member") ?? "").trim(); - if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return permissionDetailResult(deps, name, "You can't revoke your own admin access."); + if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.selfRevoke")); const tuple = permissionGrantTuple(name, member); if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission unassigned", { actor: user.id, member, permission: name }); } return { redirect: detailHref(name) }; diff --git a/examples/plugins/admin/admin-shared.test.ts b/examples/plugins/admin/admin-shared.test.ts index 2f45add..df27743 100644 --- a/examples/plugins/admin/admin-shared.test.ts +++ b/examples/plugins/admin/admin-shared.test.ts @@ -7,7 +7,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { Readable } from "node:stream"; import { test } from "node:test"; import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api"; -import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts"; +import { ADMIN_EN, ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts"; const admin: User = { email: "ada@x.io", id: "u1", permissions: ["admin"] }; const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] }; @@ -18,8 +18,9 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; req.method = opts.method ?? "GET"; return { - chrome: CHROME, user: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse, - permissions: opts.user?.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true), + chrome: CHROME, user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {}, + query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url, + verifyCsrf: opts.verifyCsrf ?? (() => true), }; } @@ -30,7 +31,10 @@ test("ADMIN_NAV: a gated Admin header over the four screens; no per-request curr assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/permissions", "/admin/clients"]); - assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Permissions", "OAuth2 clients"]); + // Labels are catalog keys; the host translates them with this plugin's catalog when it composes + // the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys. + assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.permissions", "admin.nav.clients"]); + assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "Permissions", "OAuth2 clients"]); assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree }); diff --git a/examples/plugins/admin/admin-shared.ts b/examples/plugins/admin/admin-shared.ts index 197a60d..a4d112d 100644 --- a/examples/plugins/admin/admin-shared.ts +++ b/examples/plugins/admin/admin-shared.ts @@ -3,7 +3,13 @@ // (themed not-found / capability-unavailable). Ported from the former built-in admin screens; // everything imports the host only through the #plugin-api barrel. -import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api"; +import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api"; +import enUS from "./i18n/en-US.ts"; + +// This plugin's English (its catalog, then the host's — the screens reuse core words like Cancel and +// Search), for a view model built outside a request: its unit tests. At runtime the handlers pass +// ctx.t, which reads this catalog in the visitor's locale first, then the host's. +export const ADMIN_EN: Translate = englishTranslator(enUS); export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section export const ADMIN_USERS_BASE = "/admin/users"; @@ -18,14 +24,14 @@ export type AdminScreen = "clients" | "groups" | "permissions" | "users"; // non-admin), and current-marks the active item — so there is no `current`/`open` state here. export const ADMIN_NAV: NavNode = { children: [ - { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" }, - { href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" }, - { href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "Permissions" }, - { href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" }, + { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users" }, + { href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups" }, + { href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "admin.nav.permissions" }, + { href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients" }, ], icon: "i-shield", id: "admin", - label: "Admin", + label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels permission: ADMIN_PERMISSION, }; @@ -49,13 +55,13 @@ export async function guardedForm(ctx: RequestContext): Promise s.charAt(0).toUpperCase() + s.slice(1); - function nameParts(identity: Identity): { first: string; last: string } { const nm = ((identity.traits?.name ?? {}) as { first?: unknown; last?: unknown }); return { @@ -88,9 +86,9 @@ const SORT: Record string> = { status: (u) => u.state, }; const COLUMNS = [ - { key: "name", label: "Name" }, - { key: "email", label: "Email" }, - { key: "status", label: "Status" }, + { key: "name", label: "admin.users.column.name" }, + { key: "email", label: "admin.users.column.email" }, + { key: "status", label: "admin.users.column.status" }, ]; // Canonical list URL from the current state + per-link overrides; omits defaults so links stay tidy. @@ -109,8 +107,10 @@ function listHref(state: ListState, overrides: Partial = {}): string export function buildUsersListModel(opts: { csrfToken?: string; identities: Identity[]; + t?: Translate; url: URL | URLSearchParams | string; }) { + const t = opts.t ?? ADMIN_EN; const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE }); const status = query.filters.status?.[0] ?? "all"; const sort = query.sort && SORT[query.sort.field] ? query.sort : null; @@ -133,70 +133,70 @@ export function buildUsersListModel(opts: { const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken, status }; return { - breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Admin" }, { label: "Users" }], - filterBar: listFilterBar(state, all.length), - pagination: listPagination(state, page), - table: listTable(rows, state, sort), - title: "Users", + breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }], + filterBar: listFilterBar(state, all.length, t), + pagination: listPagination(state, page, t), + table: listTable(rows, state, sort, t), + title: t("admin.users.title"), }; } -function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) { +function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) { return { actions: true, - caption: "Users", + caption: t("admin.users.title"), columns: COLUMNS.map((c) => { const dir = sort && sort.field === c.key ? sort.dir : undefined; const next = dir === "asc" ? `-${c.key}` : c.key; // asc→desc, else→asc - return { href: listHref(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true }; + return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true }; }), rows: rows.map((u) => ({ - actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: "Edit" }], + actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: t("common.edit") }], cells: [ { user: { initials: u.initials, name: u.name } }, u.email, - { badge: { label: cap(u.state), tone: STATE_TONE[u.state] ?? "info" } }, + { badge: { label: t(`admin.users.status.${u.state}`), tone: STATE_TONE[u.state] ?? "info" } }, ], name: u.name, })), }; } -function listFilterBar(state: ListState, total: number) { +function listFilterBar(state: ListState, total: number, t: Translate) { const pills: { label: string; remove: string; value: string }[] = []; - if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q }); - if (state.status !== "all") pills.push({ label: "Status", remove: listHref(state, { page: 1, status: "all" }), value: cap(state.status) }); + if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q }); + if (state.status !== "all") pills.push({ label: t("admin.users.status.label"), remove: listHref(state, { page: 1, status: "all" }), value: t(`admin.users.status.${state.status}`) }); return { - applyLabel: "Apply filters", + applyLabel: t("filter.apply"), // an untranslated core key still resolves: the host catalog is the fallback clearHref: ADMIN_USERS_BASE, - label: "Filter users", + label: t("admin.users.filter"), pills, rows: [[ - { label: "Search users", name: "q", placeholder: "Search name or email…", type: "search", value: state.q }, - { legend: "Status", name: "status", options: [ - { count: total, label: "All", value: "all" }, - { label: "Active", value: "active" }, - { label: "Inactive", value: "inactive" }, + { label: t("admin.users.searchLabel"), name: "q", placeholder: t("admin.users.searchPlaceholder"), type: "search", value: state.q }, + { legend: t("admin.users.status.label"), name: "status", options: [ + { count: total, label: t("admin.users.status.all"), value: "all" }, + { label: t("admin.users.status.active"), value: "active" }, + { label: t("admin.users.status.inactive"), value: "inactive" }, ], type: "segmented", value: state.status }, { type: "spacer" }, ]], }; } -function listPagination(state: ListState, page: ReturnType) { +function listPagination(state: ListState, page: ReturnType, t: Translate) { const hidden: { name: string; value: string }[] = []; if (state.q) hidden.push({ name: "q", value: state.q }); if (state.status !== "all") hidden.push({ name: "status", value: state.status }); if (state.sort) hidden.push({ name: "sort", value: state.sort }); return { - label: "Users pagination", + label: t("admin.users.pagination"), next: { href: page.next ? listHref(state, { page: page.next }) : undefined }, pages: page.pages.map((p) => p.ellipsis ? { ellipsis: true } : p.current ? { current: true, label: String(p.page) } : { href: listHref(state, { page: p.page as number }), label: String(p.page) }), prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined }, - rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize }, + rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize }, summary: { from: page.from, to: page.to, total: page.total }, }; } @@ -220,8 +220,10 @@ export function buildUserFormModel(opts: { error?: string; identity?: Identity | null; recovery?: RecoveryCode; + t?: Translate; values?: Partial; }) { + const t = opts.t ?? ADMIN_EN; const editing = opts.identity != null; const view = editing ? toUserView(opts.identity!) : null; const np = editing ? nameParts(opts.identity!) : { first: opts.values?.first ?? "", last: opts.values?.last ?? "" }; @@ -229,27 +231,27 @@ export function buildUserFormModel(opts: { const idPath = editing ? `${ADMIN_USERS_BASE}/${encodeURIComponent(view!.id)}` : ADMIN_USERS_BASE; const fields: FieldConfig[] = [ - { autocomplete: "email", icon: "i-mail", id: "email", label: "Email", name: "email", required: !editing, type: "email", value: email, - ...(editing ? { hint: "The login identifier — can't be changed here.", readonly: true } : {}) }, - { id: "first", label: "First name", name: "first", optional: true, value: np.first }, - { id: "last", label: "Last name", name: "last", optional: true, value: np.last }, + { autocomplete: "email", icon: "i-mail", id: "email", label: t("admin.users.field.email"), name: "email", required: !editing, type: "email", value: email, + ...(editing ? { hint: t("admin.users.field.emailHint"), readonly: true } : {}) }, + { id: "first", label: t("admin.users.field.first"), name: "first", optional: true, value: np.first }, + { id: "last", label: t("admin.users.field.last"), name: "last", optional: true, value: np.last }, ]; - if (!editing) fields.push({ autocomplete: "new-password", hint: "Optional — leave blank to have the user set one via a recovery code.", icon: "i-lock", id: "password", label: "Password", name: "password", optional: true, type: "password" }); + if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" }); return { - breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: editing ? "Edit" : "New" }], + breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("common.edit") : t("common.new") }], edit: editing ? { deleteAction: `${idPath}/delete`, id: view!.id, - nextLabel: view!.state === "inactive" ? "Reactivate" : "Deactivate", + nextLabel: view!.state === "inactive" ? t("admin.users.reactivate") : t("admin.users.deactivate"), recoveryAction: `${idPath}/recovery`, state: view!.state, stateAction: `${idPath}/state`, } : undefined, error: opts.error, - form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? "Save changes" : "Create user" }, + form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") }, recovery: opts.recovery, - title: editing ? "Edit user" : "New user", + title: editing ? t("admin.users.edit") : t("admin.users.new"), }; } @@ -274,7 +276,7 @@ function withUser(inner: (deps: UsersDeps) => Promise): RouteHandle return async (ctx) => { const user = requireAdmin(ctx); const kratosAdmin = ctx.system?.kratosAdmin; - if (!kratosAdmin) return unavailable(ctx, "Kratos identity admin"); + if (!kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.kratos")); return inner({ ctx, kratosAdmin, revoke: ctx.system?.revoke, user }); }; } @@ -291,12 +293,12 @@ function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => } const formResult = (ctx: RequestContext, extra: Parameters[0]): RouteResult => - ({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "user-form" }); + ({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "user-form" }); // GET /admin/users — the filtered/sorted/paged list. export const usersList = withUser(async ({ ctx, kratosAdmin }) => { const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE }); - return { data: { chrome: ctx.chrome, model: buildUsersListModel({ csrfToken: ctx.chrome.csrfToken, identities, url: ctx.url }) }, view: "users" }; + return { data: { chrome: ctx.chrome, model: buildUsersListModel({ csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" }; }); // POST /admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input. @@ -305,7 +307,7 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => { try { await kratosAdmin.createIdentity(createIdentityPayload(input)); } catch (err) { - if (err instanceof KratosError) return { ...formResult(ctx, { error: createError(err), values: input }), status: 400 }; + if (err instanceof KratosError) return { ...formResult(ctx, { error: createError(err, ctx.t), values: input }), status: 400 }; throw err; } ctx.log.info("admin: user created", { actor: user.id, email: input.email }); @@ -324,7 +326,7 @@ export const usersUpdate = withTarget(async ({ ctx, kratosAdmin }, identity, id) try { await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input)); } catch (err) { - if (err instanceof KratosError) return { ...formResult(ctx, { error: "Could not save changes — check the fields and try again.", identity }), status: 400 }; + if (err instanceof KratosError) return { ...formResult(ctx, { error: ctx.t("admin.users.error.save"), identity }), status: 400 }; throw err; } return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` }; @@ -334,7 +336,7 @@ export const usersUpdate = withTarget(async ({ ctx, kratosAdmin }, identity, id) // tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account. export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => { await guardedForm(ctx); // CSRF-verify the POST (no fields read) - if (id === user.id) return { ...formResult(ctx, { error: "You can't deactivate your own account.", identity }), status: 400 }; + if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDeactivate"), identity }), status: 400 }; const nextState = identity.state === "inactive" ? "active" : "inactive"; await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState)); if (nextState === "inactive") revoke?.(id); @@ -344,20 +346,21 @@ export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, // GET /admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete. export const usersDeleteConfirm = withTarget((deps, identity, id) => { - if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: "You can't delete your own account.", identity }), status: 400 }); + if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 }); const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`; const view = toUserView(identity); + const tt = deps.ctx.t; return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ - breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }], - cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: "Delete user", - message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, title: "Delete user", + breadcrumbs: [{ href: ADMIN_USERS_BASE, label: tt("admin.users.title") }, { href: back, label: view.name }, { label: tt("common.delete") }], + cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: tt("admin.users.delete"), + message: tt("admin.users.deleteMessage", { email: view.email }), title: tt("admin.users.delete"), }) }, view: "confirm" }); }); // POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete. export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => { await guardedForm(ctx); // CSRF-verify the POST - if (id === user.id) return { ...formResult(ctx, { error: "You can't delete your own account.", identity }), status: 400 }; + if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 }; await kratosAdmin.deleteIdentity(id); revoke?.(id); ctx.log.info("admin: user deleted", { actor: user.id, target: id }); @@ -371,8 +374,8 @@ export const usersRecovery = withTarget(async ({ ctx, kratosAdmin }, identity, i return formResult(ctx, { identity, recovery }); }); -function createError(err: KratosError): string { +function createError(err: KratosError, t: Translate): string { return err.status === 409 - ? "A user with that email already exists." - : "Could not create the user — check the email and try again."; + ? t("admin.users.error.duplicate") + : t("admin.users.error.create"); } diff --git a/examples/plugins/admin/i18n/en-US.ts b/examples/plugins/admin/i18n/en-US.ts new file mode 100644 index 0000000..ab65993 --- /dev/null +++ b/examples/plugins/admin/i18n/en-US.ts @@ -0,0 +1,157 @@ +// The admin plugin's own catalog — the baseline its other locales are written against. Its keys +// are looked up before the host's, so this plugin owns its words without prefixing them. + +const messages = { + "admin.capability.hydra": "Hydra OAuth2 admin", + "admin.capability.keto": "Keto and Kratos identity admin", + "admin.capability.kratos": "Kratos identity admin", + + "admin.clients.column.id": "Client ID", + "admin.clients.column.name": "Name", + "admin.clients.column.type": "Type", + "admin.clients.confidential": "Confidential", + "admin.clients.consent.firstParty": "First-party (auto-granted)", + "admin.clients.consent.label": "Consent", + "admin.clients.consent.screen": "Shows the consent screen", + "admin.clients.created": "Client registered", + "admin.clients.createdNotice": "Client registered.", + "admin.clients.delete": "Delete client", + "admin.clients.deleteMessage": "Delete client {{name}}? Apps using it can no longer sign in through Plainpages.", + "admin.clients.error.rejected": "Hydra rejected the client — check the redirect URIs and scopes.", + "admin.clients.field.name": "Name", + "admin.clients.field.redirectUris": "Redirect URIs", + "admin.clients.field.redirectUrisHint": "One per line — where the app is sent back after sign-in.", + "admin.clients.field.scopes": "Scopes", + "admin.clients.field.scopesHint": "Space-separated scopes the client may request.", + "admin.clients.field.typeHint": + "Browser and mobile apps can't keep a secret — choose Public. Server-side apps that can store one — leave it Confidential.", + "admin.clients.filter": "Filter clients", + "admin.clients.pagination": "Clients pagination", + "admin.clients.public": "Public", + "admin.clients.publicPkce": "Public (PKCE)", + "admin.clients.register": "Register", + "admin.clients.registerClient": "Register client", + "admin.clients.registerTitle": "Register client", + "admin.clients.rereg": "To change a client, delete and re-register — this issues a new client ID and secret. The secret is shown only once, at registration.", + "admin.clients.searchLabel": "Search clients", + "admin.clients.searchPlaceholder": "Search name or client ID…", + "admin.clients.secret": "Client secret", + "admin.clients.secretHint": "Copy these now — the secret can't be shown again. Store them where the app reads its credentials.", + "admin.clients.title": "OAuth2 clients", + "admin.clients.validation.name": "Enter a name for the client.", + "admin.clients.validation.redirectUri": "\"{{uri}}\" is not a valid redirect URI — use an absolute URL like https://app.example.com/callback.", + "admin.clients.validation.redirectUris": "Add at least one redirect URI.", + + "admin.common.chooseMember": "Choose a user or group…", + "admin.common.group": "Group", + "admin.common.member": "Member", + "admin.common.type": "Type", + "admin.common.user": "User", + + "admin.groups.actions": "Group actions", + "admin.groups.addMember": "Add a member", + "admin.groups.allMembers": "All users and groups are already members.", + "admin.groups.column.members": "Members", + "admin.groups.column.name": "Group", + "admin.groups.create": "Create group", + "admin.groups.delete": "Delete group", + "admin.groups.deleteMessage": "Delete group {{name}}? This removes the group and all its memberships.", + "admin.groups.field.name": "Group name", + "admin.groups.field.nameHint": "Lowercase letters, digits, dashes and underscores.", + "admin.groups.filter": "Filter groups", + "admin.groups.firstMember": "First member", + "admin.groups.firstMemberHint": "A group exists once it has a member; add more after creating it.", + "admin.groups.members": "Members", + "admin.groups.membersOf": "Members of {{name}}", + "admin.groups.new": "New group", + "admin.groups.noMembers": "No members yet.", + "admin.groups.pagination": "Groups pagination", + "admin.groups.searchLabel": "Search groups", + "admin.groups.searchPlaceholder": "Search group name…", + "admin.groups.title": "Groups", + "admin.groups.validation.member": "Pick a member to add as the group's first member.", + "admin.groups.validation.name": "Group names use lowercase letters, digits, dashes and underscores.", + + "admin.nav.clients": "OAuth2 clients", + "admin.nav.groups": "Groups", + "admin.nav.permissions": "Permissions", + "admin.nav.section": "Admin", + "admin.nav.users": "Users", + + "admin.notFound.message": "That item doesn't exist.", + "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.assignedTo": "Assigned to", + "admin.permissions.column.members": "Members", + "admin.permissions.column.name": "Permission", + "admin.permissions.create": "Create permission", + "admin.permissions.delete": "Delete permission", + "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.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.nameHint": "Lowercase letters, digits, dashes and underscores.", + "admin.permissions.filter": "Filter permissions", + "admin.permissions.new": "New permission", + "admin.permissions.noEffective": "No users hold this permission yet.", + "admin.permissions.noMembers": "Not assigned to anyone yet.", + "admin.permissions.pagination": "Permissions pagination", + "admin.permissions.revoke": "Revoke", + "admin.permissions.searchLabel": "Search permissions", + "admin.permissions.searchPlaceholder": "Search permission name…", + "admin.permissions.title": "Permissions", + "admin.permissions.validation.member": "Pick a user or group to assign the permission to.", + "admin.permissions.validation.name": "Permission names use lowercase letters, digits, dashes and underscores.", + + "admin.unavailable.message": "{{what}} is not configured on this deployment.", + "admin.unavailable.title": "Admin unavailable", + + "admin.users.actions": "Account actions", + "admin.users.column.email": "Email", + "admin.users.column.name": "Name", + "admin.users.column.status": "Status", + "admin.users.confirm": "Confirm action", + "admin.users.create": "Create user", + "admin.users.deactivate": "Deactivate", + "admin.users.delete": "Delete user", + "admin.users.deleteMessage": "Delete {{email}}? This permanently removes the account and can't be undone.", + "admin.users.edit": "Edit user", + "admin.users.error.create": "Could not create the user — check the email and try again.", + "admin.users.error.duplicate": "A user with that email already exists.", + "admin.users.error.save": "Could not save changes — check the fields and try again.", + "admin.users.error.selfDeactivate": "You can't deactivate your own account.", + "admin.users.error.selfDelete": "You can't delete your own account.", + "admin.users.field.email": "Email", + "admin.users.field.emailHint": "The login identifier — can't be changed here.", + "admin.users.field.first": "First name", + "admin.users.field.last": "Last name", + "admin.users.field.password": "Password", + "admin.users.field.passwordHint": "Optional — leave blank to have the user set one via a recovery code.", + "admin.users.filter": "Filter users", + "admin.users.new": "New user", + "admin.users.pagination": "Users pagination", + "admin.users.reactivate": "Reactivate", + "admin.users.recovery.body": "Give it to the user — they enter it to set a new password (generate a fresh one if it has expired):", + "admin.users.recovery.link": "the password-reset screen", + "admin.users.recovery.generate": "Generate recovery code", + "admin.users.recovery.title": "Recovery code generated", + "admin.users.save": "Save changes", + "admin.users.searchLabel": "Search users", + "admin.users.searchPlaceholder": "Search name or email…", + "admin.users.status.active": "Active", + "admin.users.status.all": "All", + "admin.users.status.inactive": "Inactive", + "admin.users.status.label": "Status", + "admin.users.title": "Users", +}; + +export type AdminMessages = typeof messages; + +export default messages; diff --git a/examples/plugins/admin/i18n/sv-SE.ts b/examples/plugins/admin/i18n/sv-SE.ts new file mode 100644 index 0000000..639b160 --- /dev/null +++ b/examples/plugins/admin/i18n/sv-SE.ts @@ -0,0 +1,155 @@ +import type { AdminMessages } from "./en-US.ts"; + +const messages: AdminMessages = { + "admin.capability.hydra": "Hydra OAuth2-administration", + "admin.capability.keto": "Keto- och Kratos-identitetsadministration", + "admin.capability.kratos": "Kratos identitetsadministration", + + "admin.clients.column.id": "Klient-ID", + "admin.clients.column.name": "Namn", + "admin.clients.column.type": "Typ", + "admin.clients.confidential": "Konfidentiell", + "admin.clients.consent.firstParty": "Förstapart (godkänns automatiskt)", + "admin.clients.consent.label": "Godkännande", + "admin.clients.consent.screen": "Visar godkännandesidan", + "admin.clients.created": "Klienten är registrerad", + "admin.clients.createdNotice": "Klienten är registrerad.", + "admin.clients.delete": "Radera klient", + "admin.clients.deleteMessage": "Ta bort klienten {{name}}? Appar som använder den kan inte längre logga in via Plainpages.", + "admin.clients.error.rejected": "Hydra nekade klienten — kontrollera omdirigerings-URI:erna och scopen.", + "admin.clients.field.name": "Namn", + "admin.clients.field.redirectUris": "Omdirigerings-URI:er", + "admin.clients.field.redirectUrisHint": "En per rad — dit appen skickas tillbaka efter inloggning.", + "admin.clients.field.scopes": "Scope", + "admin.clients.field.scopesHint": "Mellanslagsseparerade scope som klienten får begära.", + "admin.clients.field.typeHint": + "Webbläsar- och mobilappar kan inte hålla en hemlighet — välj Publik. Serverappar som kan lagra en — låt stå som Konfidentiell.", + "admin.clients.filter": "Filtrera klienter", + "admin.clients.pagination": "Sidnavigering för klienter", + "admin.clients.public": "Publik", + "admin.clients.publicPkce": "Publik (PKCE)", + "admin.clients.register": "Registrera", + "admin.clients.registerClient": "Registrera klient", + "admin.clients.registerTitle": "Registrera klient", + "admin.clients.rereg": + "För att ändra en klient: ta bort den och registrera på nytt — det ger ett nytt klient-ID och en ny hemlighet. Hemligheten visas bara en gång, vid registreringen.", + "admin.clients.searchLabel": "Sök klienter", + "admin.clients.searchPlaceholder": "Sök på namn eller klient-ID…", + "admin.clients.secret": "Klienthemlighet", + "admin.clients.secretHint": "Kopiera nu — hemligheten kan inte visas igen. Spara uppgifterna där appen läser dem.", + "admin.clients.title": "OAuth2-klienter", + "admin.clients.validation.name": "Ange ett namn för klienten.", + "admin.clients.validation.redirectUri": "\"{{uri}}\" är inte en giltig omdirigerings-URI — använd en absolut URL som https://app.example.com/callback.", + "admin.clients.validation.redirectUris": "Lägg till minst en omdirigerings-URI.", + + "admin.common.chooseMember": "Välj en användare eller grupp…", + "admin.common.group": "Grupp", + "admin.common.member": "Medlem", + "admin.common.type": "Typ", + "admin.common.user": "Användare", + + "admin.groups.actions": "Gruppåtgärder", + "admin.groups.addMember": "Lägg till en medlem", + "admin.groups.allMembers": "Alla användare och grupper är redan medlemmar.", + "admin.groups.column.members": "Medlemmar", + "admin.groups.column.name": "Grupp", + "admin.groups.create": "Skapa grupp", + "admin.groups.delete": "Radera grupp", + "admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det tar bort gruppen och alla dess medlemskap.", + "admin.groups.field.name": "Gruppnamn", + "admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.", + "admin.groups.filter": "Filtrera grupper", + "admin.groups.firstMember": "Första medlem", + "admin.groups.firstMemberHint": "En grupp finns så snart den har en medlem; lägg till fler efteråt.", + "admin.groups.members": "Medlemmar", + "admin.groups.membersOf": "Medlemmar i {{name}}", + "admin.groups.new": "Ny grupp", + "admin.groups.noMembers": "Inga medlemmar ännu.", + "admin.groups.pagination": "Sidnavigering för grupper", + "admin.groups.searchLabel": "Sök grupper", + "admin.groups.searchPlaceholder": "Sök på gruppnamn…", + "admin.groups.title": "Grupper", + "admin.groups.validation.member": "Välj en medlem som gruppens första medlem.", + "admin.groups.validation.name": "Gruppnamn använder små bokstäver, siffror, bindestreck och understreck.", + + "admin.nav.clients": "OAuth2-klienter", + "admin.nav.groups": "Grupper", + "admin.nav.permissions": "Behörigheter", + "admin.nav.section": "Administration", + "admin.nav.users": "Användare", + + "admin.notFound.message": "Objektet finns 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.assignedTo": "Tilldelad till", + "admin.permissions.column.members": "Medlemmar", + "admin.permissions.column.name": "Behörighet", + "admin.permissions.create": "Skapa behörighet", + "admin.permissions.delete": "Radera behörighet", + "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.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.nameHint": "Små bokstäver, siffror, bindestreck och understreck.", + "admin.permissions.filter": "Filtrera behörigheter", + "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.pagination": "Sidnavigering för behörigheter", + "admin.permissions.revoke": "Återkalla", + "admin.permissions.searchLabel": "Sök behörigheter", + "admin.permissions.searchPlaceholder": "Sök på behörighetens namn…", + "admin.permissions.title": "Behörigheter", + "admin.permissions.validation.member": "Välj en användare eller grupp att tilldela behörigheten till.", + "admin.permissions.validation.name": "Behörighetsnamn använder små bokstäver, siffror, bindestreck och understreck.", + + "admin.unavailable.message": "{{what}} är inte konfigurerat i den här installationen.", + "admin.unavailable.title": "Administrationen är otillgänglig", + + "admin.users.actions": "Kontoåtgärder", + "admin.users.column.email": "E-postadress", + "admin.users.column.name": "Namn", + "admin.users.column.status": "Status", + "admin.users.confirm": "Bekräfta åtgärden", + "admin.users.create": "Skapa användare", + "admin.users.deactivate": "Inaktivera", + "admin.users.delete": "Radera användare", + "admin.users.deleteMessage": "Ta bort {{email}}? Kontot tas bort permanent och det går inte att ångra.", + "admin.users.edit": "Redigera användare", + "admin.users.error.create": "Användaren kunde inte skapas — kontrollera e-postadressen och försök igen.", + "admin.users.error.duplicate": "Det finns redan en användare med den e-postadressen.", + "admin.users.error.save": "Ändringarna kunde inte sparas — kontrollera fälten och försök igen.", + "admin.users.error.selfDeactivate": "Du kan inte inaktivera ditt eget konto.", + "admin.users.error.selfDelete": "Du kan inte ta bort ditt eget konto.", + "admin.users.field.email": "E-postadress", + "admin.users.field.emailHint": "Inloggningsidentiteten — den kan inte ändras här.", + "admin.users.field.first": "Förnamn", + "admin.users.field.last": "Efternamn", + "admin.users.field.password": "Lösenord", + "admin.users.field.passwordHint": "Frivilligt — lämna tomt så får användaren sätta det själv via en återställningskod.", + "admin.users.filter": "Filtrera användare", + "admin.users.new": "Ny användare", + "admin.users.pagination": "Sidnavigering för användare", + "admin.users.reactivate": "Aktivera igen", + "admin.users.recovery.body": "Ge den till användaren — koden anges för att sätta ett nytt lösenord (skapa en ny om den hunnit gå ut):", + "admin.users.recovery.link": "sidan för lösenordsåterställning", + "admin.users.recovery.generate": "Skapa återställningskod", + "admin.users.recovery.title": "Återställningskod skapad", + "admin.users.save": "Spara ändringar", + "admin.users.searchLabel": "Sök användare", + "admin.users.searchPlaceholder": "Sök på namn eller e-postadress…", + "admin.users.status.active": "Aktiv", + "admin.users.status.all": "Alla", + "admin.users.status.inactive": "Inaktiv", + "admin.users.status.label": "Status", + "admin.users.title": "Användare", +}; + +export default messages; diff --git a/examples/plugins/admin/views/clients.ejs b/examples/plugins/admin/views/clients.ejs index 0228df0..f0dca77 100644 --- a/examples/plugins/admin/views/clients.ejs +++ b/examples/plugins/admin/views/clients.ejs @@ -6,7 +6,7 @@ const filters = include("partials/filter-bar", model.filterBar); const table = include("partials/data-table", model.table); const pager = include("partials/pagination", model.pagination); - const actions = 'Register client'; + const actions = '' + t("admin.clients.registerClient") + ''; -%> <%- include("partials/shell", { actions, diff --git a/examples/plugins/admin/views/groups.ejs b/examples/plugins/admin/views/groups.ejs index 14e4a80..40a0738 100644 --- a/examples/plugins/admin/views/groups.ejs +++ b/examples/plugins/admin/views/groups.ejs @@ -6,7 +6,7 @@ const filters = include("partials/filter-bar", model.filterBar); const table = include("partials/data-table", model.table); const pager = include("partials/pagination", model.pagination); - const actions = 'Add group'; + const actions = '' + t("admin.groups.new") + ''; -%> <%- include("partials/shell", { actions, diff --git a/examples/plugins/admin/views/partials/client-detail-body.ejs b/examples/plugins/admin/views/partials/client-detail-body.ejs index 3392520..1fee872 100644 --- a/examples/plugins/admin/views/partials/client-detail-body.ejs +++ b/examples/plugins/admin/views/partials/client-detail-body.ejs @@ -11,28 +11,28 @@ -%>
<% if (locals.created) { -%> -<%- include("partials/alert", { text: "Client registered.", tone: "pos" }) %> +<%- include("partials/alert", { text: t("admin.clients.createdNotice"), tone: "pos" }) %> <% } -%> <% if (locals.secret) { -%>
-

Client secret

-

Copy these now — the secret can't be shown again. Store them where the app reads its credentials.

-
-
+

<%= t("admin.clients.secret") %>

+

<%= t("admin.clients.secretHint") %>

+
+
<% } -%>

<%= c.name %>

-
Client ID
<%= c.id %>
-
Type
<%= c.public ? "Public (PKCE)" : "Confidential" %>
-
Consent
<%= c.firstParty ? "First-party (auto-granted)" : "Shows the consent screen" %>
-
Scopes
<%= c.scopes.length ? c.scopes.join(" ") : "—" %>
-
Redirect URIs
<% if (c.redirectUris.length) { %>
    <% c.redirectUris.forEach((u) => { %>
  • <%= u %>
  • <% }) %>
<% } else { %>—<% } %>
+
<%= t("admin.clients.column.id") %>
<%= c.id %>
+
<%= t("admin.clients.column.type") %>
<%= c.public ? t("admin.clients.publicPkce") : t("admin.clients.confidential") %>
+
<%= t("admin.clients.consent.label") %>
<%= c.firstParty ? t("admin.clients.consent.firstParty") : t("admin.clients.consent.screen") %>
+
<%= t("admin.clients.field.scopes") %>
<%= c.scopes.length ? c.scopes.join(" ") : "—" %>
+
<%= t("admin.clients.field.redirectUris") %>
<% if (c.redirectUris.length) { %>
    <% c.redirectUris.forEach((u) => { %>
  • <%= u %>
  • <% }) %>
<% } else { %>—<% } %>
-
-

To change a client, delete and re-register — this issues a new client ID and secret. The secret is shown only once, at registration.

- Delete client +
"> +

<%= t("admin.clients.rereg") %>

+ <%= t("admin.clients.delete") %>
diff --git a/examples/plugins/admin/views/partials/client-form-body.ejs b/examples/plugins/admin/views/partials/client-form-body.ejs index b85b3d0..e55be3c 100644 --- a/examples/plugins/admin/views/partials/client-form-body.ejs +++ b/examples/plugins/admin/views/partials/client-form-body.ejs @@ -10,20 +10,20 @@ <% if (locals.error) { -%> <%- include("partials/alert", { text: locals.error, tone: "neg" }) %> <% } -%> -
+ <%- include("partials/field", form.nameField) %>
- + - One per line — where the app is sent back after sign-in. + <%= t("admin.clients.field.redirectUrisHint") %>
<%- include("partials/field", form.scopeField) %> - Browser and mobile apps can't keep a secret — choose Public. Server-side apps that can store one — leave it Confidential. + <%= t("admin.clients.field.typeHint") %>
- Cancel + <%= t("common.cancel") %>
diff --git a/examples/plugins/admin/views/partials/confirm-body.ejs b/examples/plugins/admin/views/partials/confirm-body.ejs index acc3b34..8b5cbca 100644 --- a/examples/plugins/admin/views/partials/confirm-body.ejs +++ b/examples/plugins/admin/views/partials/confirm-body.ejs @@ -7,11 +7,11 @@ csrfToken %>
-
+
">

<%= locals.message %>

- Cancel -
+ <%= t("common.cancel") %> +
diff --git a/examples/plugins/admin/views/partials/group-detail-body.ejs b/examples/plugins/admin/views/partials/group-detail-body.ejs index 94c2029..6b0fcba 100644 --- a/examples/plugins/admin/views/partials/group-detail-body.ejs +++ b/examples/plugins/admin/views/partials/group-detail-body.ejs @@ -17,26 +17,26 @@ <%- include("partials/alert", { text: locals.error, tone: "neg" }) %> <% } -%>
-

Members

+

<%= t("admin.groups.members") %>

<% if (members.rows.length) { -%> -
+
Members of <%= group.name %>
MemberTypeActions
<% members.rows.forEach((m) => { -%> - + <% }) -%>
<%= t("admin.groups.membersOf", { name: group.name }) %>
<%= t("admin.common.member") %><%= t("admin.common.type") %><%= t("table.actions") %>
<%= m.label %><%= m.kind === "group" ? "Group" : "User" %>
<%= m.label %><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %>
<% } else { -%> -

No members yet.

+

<%= t("admin.groups.noMembers") %>

<% } -%>
-

Add a member

+

<%= t("admin.groups.addMember") %>

<% if (add.options.length) { -%> -
+
<% } else { -%> -

All users and groups are already members.

+

<%= t("admin.groups.allMembers") %>

<% } -%>
-
- Delete group +
"> + <%= t("admin.groups.delete") %>
diff --git a/examples/plugins/admin/views/partials/group-form-body.ejs b/examples/plugins/admin/views/partials/group-form-body.ejs index f540591..a3880d5 100644 --- a/examples/plugins/admin/views/partials/group-form-body.ejs +++ b/examples/plugins/admin/views/partials/group-form-body.ejs @@ -10,16 +10,16 @@ <% if (locals.error) { -%> <%- include("partials/alert", { text: locals.error, tone: "neg" }) %> <% } -%> -
+ <%- include("partials/field", form.nameField) %>
- - - A group exists once it has a member; add more after creating it. + + + <%= t("admin.groups.firstMemberHint") %>
- Cancel + <%= t("common.cancel") %>
diff --git a/examples/plugins/admin/views/partials/permission-detail-body.ejs b/examples/plugins/admin/views/partials/permission-detail-body.ejs index 030a312..14470bd 100644 --- a/examples/plugins/admin/views/partials/permission-detail-body.ejs +++ b/examples/plugins/admin/views/partials/permission-detail-body.ejs @@ -19,20 +19,20 @@ <%- include("partials/alert", { text: locals.error, tone: "neg" }) %> <% } -%>
-

Assigned to

+

<%= t("admin.permissions.assignedTo") %>

<% if (members.rows.length) { -%> -
+
Members of <%= permission.name %>
MemberTypeActions
<% members.rows.forEach((m) => { -%> - + <% }) -%>
<%= t("admin.groups.membersOf", { name: permission.name }) %>
<%= t("admin.common.member") %><%= t("admin.common.type") %><%= t("table.actions") %>
<%= m.label %><%= m.kind === "group" ? "Group" : "User" %>
<%= m.label %><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %>
<% } else { -%> -

Not assigned to anyone yet.

+

<%= t("admin.permissions.noMembers") %>

<% } -%>
-

Effective access

-

Everyone who holds this permission — directly or through a group (resolved by Keto).

+

<%= t("admin.permissions.effective") %>

+

<%= t("admin.permissions.effectiveHint") %>

<% if (effective.length) { -%>
    <% effective.forEach((u) => { -%> @@ -40,18 +40,18 @@ <% }) -%>
<% } else { -%> -

No users hold this permission yet.

+

<%= t("admin.permissions.noEffective") %>

<% } -%>
-

Assign the permission

+

<%= t("admin.permissions.assign") %>

<% if (add.options.length) { -%> -
+
<% } else { -%> -

All users and groups already have this permission.

+

<%= t("admin.permissions.allAssigned") %>

<% } -%>
-
- Delete permission +
"> + <%= t("admin.permissions.delete") %>
diff --git a/examples/plugins/admin/views/partials/permission-form-body.ejs b/examples/plugins/admin/views/partials/permission-form-body.ejs index 63cab01..81f748b 100644 --- a/examples/plugins/admin/views/partials/permission-form-body.ejs +++ b/examples/plugins/admin/views/partials/permission-form-body.ejs @@ -10,16 +10,16 @@ <% if (locals.error) { -%> <%- include("partials/alert", { text: locals.error, tone: "neg" }) %> <% } -%> -
+ <%- include("partials/field", form.nameField) %>
- - + + A permission exists once assigned; add more users or groups after creating it.
- Cancel + <%= t("common.cancel") %>
diff --git a/examples/plugins/admin/views/partials/user-form-body.ejs b/examples/plugins/admin/views/partials/user-form-body.ejs index 6aeb259..e9f2971 100644 --- a/examples/plugins/admin/views/partials/user-form-body.ejs +++ b/examples/plugins/admin/views/partials/user-form-body.ejs @@ -14,23 +14,23 @@ <%- include("partials/alert", { text: locals.error, tone: "neg" }) %> <% } -%> <% if (recovery) { -%> -
Recovery code generatedGive it to the user — they enter it on the password-reset screen to set a new password (generate a fresh one if it has expired).<% if (recovery.code) { %><%= recovery.code %><% } %>
+
<%= t("admin.users.recovery.title") %><%= t("admin.users.recovery.body") %> "><%= t("admin.users.recovery.link") %><% if (recovery.code) { %><%= recovery.code %><% } %>
<% } -%> -
+ <% form.fields.forEach((field) => { -%> <%- include("partials/field", field) %> <% }) -%>
- Cancel + <%= t("common.cancel") %>
<% if (edit) { -%> -
-
-
- Delete user +
"> +
+
+ <%= t("admin.users.delete") %>
<% } -%> diff --git a/examples/plugins/admin/views/permissions.ejs b/examples/plugins/admin/views/permissions.ejs index 8eff683..c8cc03f 100644 --- a/examples/plugins/admin/views/permissions.ejs +++ b/examples/plugins/admin/views/permissions.ejs @@ -6,7 +6,7 @@ const filters = include("partials/filter-bar", model.filterBar); const table = include("partials/data-table", model.table); const pager = include("partials/pagination", model.pagination); - const actions = 'Add permission'; + const actions = '' + t("admin.permissions.new") + ''; -%> <%- include("partials/shell", { actions, diff --git a/examples/plugins/admin/views/users.ejs b/examples/plugins/admin/views/users.ejs index 606dac3..ecef9b1 100644 --- a/examples/plugins/admin/views/users.ejs +++ b/examples/plugins/admin/views/users.ejs @@ -6,7 +6,7 @@ const filters = include("partials/filter-bar", model.filterBar); const table = include("partials/data-table", model.table); const pager = include("partials/pagination", model.pagination); - const actions = 'Add user'; + const actions = '' + t("admin.users.new") + ''; -%> <%- include("partials/shell", { actions, diff --git a/examples/plugins/scheduling/README.md b/examples/plugins/scheduling/README.md index 8586401..44894db 100644 --- a/examples/plugins/scheduling/README.md +++ b/examples/plugins/scheduling/README.md @@ -17,6 +17,10 @@ What it demonstrates: reusing the core `field` partial. - **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` / `scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant. +- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including + the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and + the views carry the visitor's language onto their links with `localeHref()`. + (README → [Languages](../../../README.md#languages-i18n).) The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and `fetch` is injectable, so they unit-test as pure functions (`shifts.test.ts`). diff --git a/examples/plugins/scheduling/i18n/en-US.ts b/examples/plugins/scheduling/i18n/en-US.ts new file mode 100644 index 0000000..e3120e7 --- /dev/null +++ b/examples/plugins/scheduling/i18n/en-US.ts @@ -0,0 +1,40 @@ +// This plugin's own catalog, and the baseline its other locales are written against. Keys are +// looked up here first and fall back to the host's, so a plugin owns its words without prefixing +// them, and `shifts.count` shows the plural form (host: README → Translating). + +import type { PluralMessage } from "#plugin-api"; + +const messages = { + "scheduling.field.assignee": "Assignee", + "scheduling.field.end": "End", + "scheduling.field.start": "Start", + "scheduling.field.title": "Shift title", + "scheduling.filter.label": "Filter shifts", + "scheduling.filter.searchLabel": "Search shifts", + "scheduling.filter.searchPlaceholder": "Search title or assignee…", + "scheduling.form.submit": "Create shift", + "scheduling.nav.overview": "Overview", + "scheduling.nav.section": "Scheduling", + "scheduling.nav.shifts": "Shifts", + "scheduling.new.title": "New shift", + "scheduling.overview.lead": + "Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the scheduling:read permission.", + "scheduling.overview.signIn": "Sign in to view shifts", + "scheduling.overview.title": "Scheduling", + "scheduling.overview.view": "View shifts", + "scheduling.shifts.count": { one: "{{count}} shift", other: "{{count}} shifts" } as PluralMessage, + "scheduling.shifts.new": "New shift", + "scheduling.shifts.title": "Shifts", + "scheduling.table.assignee": "Assignee", + "scheduling.table.end": "End", + "scheduling.table.shift": "Shift", + "scheduling.table.start": "Start", + "scheduling.upstream.create": "Couldn't save the shift — the scheduling service is unavailable.", + "scheduling.upstream.list": "Couldn't reach the scheduling service — try again shortly.", + "scheduling.validation.assignee": "Assign the shift to someone.", + "scheduling.validation.title": "A shift needs a title.", +}; + +export type SchedulingMessages = typeof messages; + +export default messages; diff --git a/examples/plugins/scheduling/i18n/sv-SE.ts b/examples/plugins/scheduling/i18n/sv-SE.ts new file mode 100644 index 0000000..24edc86 --- /dev/null +++ b/examples/plugins/scheduling/i18n/sv-SE.ts @@ -0,0 +1,34 @@ +import type { SchedulingMessages } from "./en-US.ts"; + +const messages: SchedulingMessages = { + "scheduling.field.assignee": "Tilldelad", + "scheduling.field.end": "Slut", + "scheduling.field.start": "Start", + "scheduling.field.title": "Passets namn", + "scheduling.filter.label": "Filtrera pass", + "scheduling.filter.searchLabel": "Sök pass", + "scheduling.filter.searchPlaceholder": "Sök på namn eller person…", + "scheduling.form.submit": "Skapa pass", + "scheduling.nav.overview": "Översikt", + "scheduling.nav.section": "Schemaläggning", + "scheduling.nav.shifts": "Pass", + "scheduling.new.title": "Nytt pass", + "scheduling.overview.lead": + "Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten scheduling:read.", + "scheduling.overview.signIn": "Logga in för att se passen", + "scheduling.overview.title": "Schemaläggning", + "scheduling.overview.view": "Visa pass", + "scheduling.shifts.count": { one: "{{count}} pass", other: "{{count}} pass" }, + "scheduling.shifts.new": "Nytt pass", + "scheduling.shifts.title": "Pass", + "scheduling.table.assignee": "Tilldelad", + "scheduling.table.end": "Slut", + "scheduling.table.shift": "Pass", + "scheduling.table.start": "Start", + "scheduling.upstream.create": "Passet kunde inte sparas — schemaläggningstjänsten är otillgänglig.", + "scheduling.upstream.list": "Vi når inte schemaläggningstjänsten — försök igen om en stund.", + "scheduling.validation.assignee": "Passet måste tilldelas någon.", + "scheduling.validation.title": "Passet behöver ett namn.", +}; + +export default messages; diff --git a/examples/plugins/scheduling/plugin.ts b/examples/plugins/scheduling/plugin.ts index a7282ff..c779323 100644 --- a/examples/plugins/scheduling/plugin.ts +++ b/examples/plugins/scheduling/plugin.ts @@ -17,17 +17,18 @@ export default definePlugin({ // typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later. hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") }, - // Merged into the global menu + filtered per user. "Overview" is `public`, so the "Scheduling" + // Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog + // (i18n/.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling" // header shows for everyone (even signed out); "Shifts" needs `scheduling:read`, so the gated data // stays hidden until a reader signs in (a plugin may make a page + its menu option public). nav: [{ children: [ - { href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true }, - { href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ }, + { href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true }, + { href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ }, ], icon: "i-cal", id: "scheduling", - label: "Scheduling", + label: "scheduling.nav.section", }], // Roles this plugin introduces (docs + Keto seeding). Namespaced `:`. diff --git a/examples/plugins/scheduling/shifts.test.ts b/examples/plugins/scheduling/shifts.test.ts index 74986de..a91b06c 100644 --- a/examples/plugins/scheduling/shifts.test.ts +++ b/examples/plugins/scheduling/shifts.test.ts @@ -4,20 +4,23 @@ import { Readable } from "node:stream"; import test from "node:test"; // Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may // refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches. -import { GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api"; +import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api"; +import enUS from "./i18n/en-US.ts"; import { assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput, SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate, } from "./shifts.ts"; +const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } }; function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { const url = new URL(opts.url ?? "http://localhost/scheduling/shifts"); const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; return { - chrome: CHROME, user: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse, - permissions: opts.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true), + chrome: CHROME, user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {}, + query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url, + verifyCsrf: opts.verifyCsrf ?? (() => true), }; } diff --git a/examples/plugins/scheduling/shifts.ts b/examples/plugins/scheduling/shifts.ts index 27bca69..2db2540 100644 --- a/examples/plugins/scheduling/shifts.ts +++ b/examples/plugins/scheduling/shifts.ts @@ -6,7 +6,13 @@ // pure functions against a mock upstream with no network (README.md → Local dev & test story). // One import from the host's #plugin-api barrel — the stable author surface (see README.md → Building plugins). -import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, tracedFetch } from "#plugin-api"; +import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "#plugin-api"; +import enUS from "./i18n/en-US.ts"; + +// The plugin's own English (its catalog, then the host's), for a view model built outside a request: +// its unit tests. At runtime a handler passes ctx.t, which reads this catalog in the visitor's +// locale first, then the host's. +const EN: Translate = englishTranslator(enUS); export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page export const SHIFTS_PATH = "/scheduling/shifts"; @@ -87,58 +93,63 @@ function toShift(raw: unknown): Shift { // ---- view models (pure; the EJS views read these) ----------------------------------- -export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[] }) { +export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[]; t?: Translate }) { + const t = opts.t ?? EN; return { - breadcrumbs: [{ label: "Shifts" }], // SHIFTS_PATH is the list itself; the form links back to it as "Shifts" + breadcrumbs: [{ label: t("scheduling.shifts.title") }], // SHIFTS_PATH is the list itself; the form links back to it canWrite: opts.canWrite, chrome: opts.chrome, + // A plural message: one catalog key, the right form per locale and count (Intl.PluralRules). + count: t("scheduling.shifts.count", { count: opts.shifts.length }), ...(opts.error ? { error: opts.error } : {}), filterBar: { - applyLabel: "Search", + applyLabel: t("filter.search"), clearHref: SHIFTS_PATH, - label: "Filter shifts", - pills: opts.q ? [{ label: "Search", remove: SHIFTS_PATH, value: opts.q }] : [], + label: t("scheduling.filter.label"), + pills: opts.q ? [{ label: t("filter.search"), remove: SHIFTS_PATH, value: opts.q }] : [], rows: [[ - { label: "Search shifts", name: "q", placeholder: "Search title or assignee…", type: "search", value: opts.q }, + { label: t("scheduling.filter.searchLabel"), name: "q", placeholder: t("scheduling.filter.searchPlaceholder"), type: "search", value: opts.q }, { type: "spacer" }, ]], }, newHref: `${SHIFTS_PATH}/new`, table: { - caption: "Shifts", - columns: [{ label: "Shift" }, { label: "Assignee" }, { label: "Start" }, { label: "End" }], + caption: t("scheduling.shifts.title"), + columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.assignee") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }], rows: opts.shifts.map((s) => ({ cells: [{ rowHeader: { text: s.title } }, s.assignee, s.start, s.end], name: s.title, })), }, - title: "Shifts", + title: t("scheduling.shifts.title"), }; } -export function buildFormModel(opts: { chrome: PageChrome; errors?: Record; formError?: string; values?: Partial }) { +export function buildFormModel(opts: { chrome: PageChrome; errors?: Record; formError?: string; t?: Translate; values?: Partial }) { + const t = opts.t ?? EN; const v = opts.values ?? {}; const e = opts.errors ?? {}; const field = (cfg: { icon?: string; id: string; label: string; type?: string; value: string }) => ({ ...cfg, name: cfg.id, ...(e[cfg.id] ? { error: e[cfg.id] } : {}), ...(cfg.id === "title" || cfg.id === "assignee" ? { required: true } : {}), }); return { - breadcrumbs: [{ href: SHIFTS_PATH, label: "Shifts" }, { label: "New shift" }], + breadcrumbs: [{ href: SHIFTS_PATH, label: t("scheduling.shifts.title") }, { label: t("scheduling.new.title") }], chrome: opts.chrome, ...(opts.formError ? { formError: opts.formError } : {}), form: { action: SHIFTS_PATH, cancelHref: SHIFTS_PATH, csrfToken: opts.chrome.csrfToken, + cancelLabel: t("common.cancel"), fields: [ - field({ icon: "i-cal", id: "title", label: "Shift title", value: v.title ?? "" }), - field({ icon: "i-user", id: "assignee", label: "Assignee", value: v.assignee ?? "" }), - field({ id: "start", label: "Start", type: "datetime-local", value: v.start ?? "" }), - field({ id: "end", label: "End", type: "datetime-local", value: v.end ?? "" }), + field({ icon: "i-cal", id: "title", label: t("scheduling.field.title"), value: v.title ?? "" }), + field({ icon: "i-user", id: "assignee", label: t("scheduling.field.assignee"), value: v.assignee ?? "" }), + field({ id: "start", label: t("scheduling.field.start"), type: "datetime-local", value: v.start ?? "" }), + field({ id: "end", label: t("scheduling.field.end"), type: "datetime-local", value: v.end ?? "" }), ], - submitLabel: "Create shift", + submitLabel: t("scheduling.form.submit"), }, - title: "New shift", + title: t("scheduling.new.title"), }; } @@ -155,10 +166,10 @@ export function readInput(form: URLSearchParams): ShiftInput { // Required-field validation → { field: message } or null. Kept deliberately small; the upstream // owns the real domain rules (overlap, capacity, …) and rejects with a 4xx the handler surfaces. -export function validate(input: ShiftInput): Record | null { +export function validate(input: ShiftInput, t: Translate = EN): Record | null { const errors: Record = {}; - if (!input.title) errors["title"] = "A shift needs a title."; - if (!input.assignee) errors["assignee"] = "Assign the shift to someone."; + if (!input.title) errors["title"] = t("scheduling.validation.title"); + if (!input.assignee) errors["assignee"] = t("scheduling.validation.assignee"); return Object.keys(errors).length ? errors : null; } @@ -173,16 +184,16 @@ export function listShifts(upstream: ShiftsUpstream): RouteHandler { shifts = await upstream.list(); } catch (err) { ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); // plugin logging via ctx.log - error = "Couldn't reach the scheduling service — try again shortly."; + error = ctx.t("scheduling.upstream.list"); } const needle = q.toLowerCase(); const rows = needle ? shifts.filter((s) => s.title.toLowerCase().includes(needle) || s.assignee.toLowerCase().includes(needle)) : shifts; - return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows }), view: "shifts" }; + return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows, t: ctx.t }), view: "shifts" }; }; } export function newShiftForm(): RouteHandler { - return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome }), view: "shift-new" }); + return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" }); } // Public overview: a page anyone may reach — its route + nav node are marked `public`, so the @@ -191,7 +202,14 @@ export function newShiftForm(): RouteHandler { // else a prompt to sign in. ctx.user may be null here, so read the permission via can() (zero I/O). export function overview(): RouteHandler { return (ctx) => ({ - data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" }, + data: { + breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }], + canRead: can(ctx, READ), + chrome: ctx.chrome, + shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links + signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`), + title: ctx.t("scheduling.overview.title"), + }, view: "overview", }); } @@ -202,13 +220,13 @@ export function createShift(upstream: ShiftsUpstream): RouteHandler { // A write is a first-party form, so guard it with the host's double-submit token (ctx.verifyCsrf). if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token"); const input = readInput(form); - const errors = validate(input); - if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, values: input }), status: 400, view: "shift-new" }; + const errors = validate(input, ctx.t); + if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, t: ctx.t, values: input }), status: 400, view: "shift-new" }; try { await upstream.create(input); } catch (err) { ctx.log.warn("scheduling shift create failed (upstream)", { error: String(err) }); - return { data: buildFormModel({ chrome: ctx.chrome, formError: "Couldn't save the shift — the scheduling service is unavailable.", values: input }), status: 502, view: "shift-new" }; + return { data: buildFormModel({ chrome: ctx.chrome, formError: ctx.t("scheduling.upstream.create"), t: ctx.t, values: input }), status: 502, view: "shift-new" }; } ctx.log.info("scheduling shift created", { assignee: input.assignee, title: input.title }); return { redirect: SHIFTS_PATH }; // POST-redirect-GET diff --git a/examples/plugins/scheduling/views/overview.ejs b/examples/plugins/scheduling/views/overview.ejs index 444d21e..01ac6cf 100644 --- a/examples/plugins/scheduling/views/overview.ejs +++ b/examples/plugins/scheduling/views/overview.ejs @@ -3,16 +3,16 @@ nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome. - Data: chrome, title, breadcrumbs, canRead, shiftsHref + Data: chrome, title, breadcrumbs, canRead, shiftsHref, signInHref %><% const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); const cta = canRead - ? 'View shifts' - : 'Sign in to view shifts'; + ? '' + t("scheduling.overview.view") + '' + : '' + t("scheduling.overview.signIn") + ''; -%> <%- include("partials/shell", { actions: "", - body: '

Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the scheduling:read permission.

' + cta + '
', + body: '

' + t("scheduling.overview.lead") + '

' + cta + '
', brand: chrome.brand, breadcrumbs, csrfToken: chrome.csrfToken, diff --git a/examples/plugins/scheduling/views/partials/shift-form.ejs b/examples/plugins/scheduling/views/partials/shift-form.ejs index c5499c2..1d8ed5d 100644 --- a/examples/plugins/scheduling/views/partials/shift-form.ejs +++ b/examples/plugins/scheduling/views/partials/shift-form.ejs @@ -1,7 +1,7 @@ <%# A plugin's own partial (resolved before the core ones). The new-shift form body, reusing the core `partials/field` + `partials/alert`. Config: form { action, csrfToken, submitLabel, cancelHref, - fields: field.ejs config[] }, formError? + cancelLabel, fields: field.ejs config[] }, formError? %><% const form = locals.form; -%> @@ -9,13 +9,13 @@ <% if (locals.formError) { -%> <%- include("partials/alert", { text: locals.formError, tone: "neg" }) %> <% } -%> -
+ <% form.fields.forEach((field) => { -%> <%- include("partials/field", field) %> <% }) -%>
- Cancel + <%= form.cancelLabel %>
diff --git a/examples/plugins/scheduling/views/shifts.ejs b/examples/plugins/scheduling/views/shifts.ejs index f6aee06..e426f3b 100644 --- a/examples/plugins/scheduling/views/shifts.ejs +++ b/examples/plugins/scheduling/views/shifts.ejs @@ -3,19 +3,19 @@ service; this view renders them with the core building blocks inside the native app shell (ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table, alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL. - Data: chrome, title, breadcrumbs, filterBar, table, canWrite, newHref, error? + Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error? %><% const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); const filtersHtml = include("partials/filter-bar", filterBar); const tableHtml = include("partials/data-table", table); const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : ""; const actions = canWrite - ? 'New shift' + ? '' + t("scheduling.shifts.new") + '' : ""; -%> <%- include("partials/shell", { actions, - body: '
' + alertHtml + filtersHtml + tableHtml + '
', + body: '
' + alertHtml + filtersHtml + '

' + count + '

' + tableHtml + '
', brand: chrome.brand, breadcrumbs, csrfToken: chrome.csrfToken, diff --git a/locales/.gitkeep b/locales/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/css/styles.css b/public/css/styles.css index a937229..bbea499 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -500,6 +500,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */ } .menu-item-form { display: contents; } /* form wraps the Sign-out button without changing layout */ .menu-item:hover { background: var(--surface-2); } +.menu-item[aria-current] { font-weight: 600; color: var(--text); } /* the language you are in */ .menu-item.danger { color: var(--neg); } .menu-item .ico { color: var(--text-faint); } .menu-item.danger .ico { color: var(--neg); } diff --git a/src/auth/flow-view.test.ts b/src/auth/flow-view.test.ts index ee60b4a..0aa0ec2 100644 --- a/src/auth/flow-view.test.ts +++ b/src/auth/flow-view.test.ts @@ -34,9 +34,11 @@ test("maps a password login flow: csrf hidden, themed email/password fields, a s assert.equal(view.method, "post"); assert.deepEqual(view.hidden, [{ name: "csrf_token", value: "tok123" }]); - // Visible fields carry label, type, required, autocomplete + a themed input icon. + // Visible fields carry label, type, required, autocomplete + a themed input icon. The label is + // ours (auth.field.identifier) rather than Kratos' wording — Kratos' generic trait-label id is + // ambiguous, so field labels are keyed on the input name. assert.equal(view.fields.length, 2); - assert.deepEqual(view.fields[0], { autocomplete: "username", icon: "i-mail", id: "field-identifier", label: "E-Mail", name: "identifier", required: true, type: "email" }); + assert.deepEqual(view.fields[0], { autocomplete: "username", icon: "i-mail", id: "field-identifier", label: "Email", name: "identifier", required: true, type: "email" }); assert.equal(view.fields[1]?.icon, "i-lock"); assert.equal(view.fields[1]?.type, "password"); @@ -53,7 +55,7 @@ test("maps a password login flow: csrf hidden, themed email/password fields, a s assert.equal(view.messages.length, 0); }); -test("maps field errors and flow-level messages by tone", () => { +test("maps field errors and flow-level messages by tone, translating the ids we cover", () => { const view = buildFlowView( flow( [ @@ -65,13 +67,15 @@ test("maps field errors and flow-level messages by tone", () => { "login", ); - // Submitted value is preserved; the node's error rides on the field. + // Submitted value is preserved; the node's error rides on the field — with our wording for the + // id (4000002), since Kratos writes "Property password is missing." for every required field. assert.equal(view.fields[0]?.value, "taken@example.com"); - assert.deepEqual(view.fields[0]?.error, { text: "This email is already in use." }); + assert.deepEqual(view.fields[0]?.error, { text: "This field is required." }); - // Flow messages map error→neg, info→info (success→pos covered by the tone map). + // Flow messages map error→neg, info→info (success→pos covered by the tone map). A mapped id + // (4000006) is replaced; an id we hold no key for keeps Kratos' own text. assert.deepEqual(view.messages, [ - { text: "The provided credentials are invalid.", tone: "neg" }, + { text: "The credentials are invalid. Check for typos in your email address or password.", tone: "neg" }, { text: "Check your email.", tone: "info" }, ]); }); diff --git a/src/auth/flow-view.ts b/src/auth/flow-view.ts index 312b37e..04826cb 100644 --- a/src/auth/flow-view.ts +++ b/src/auth/flow-view.ts @@ -4,6 +4,8 @@ // configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos // owns its CSRF; we only render and map errors. No providers configured ⇒ no SSO buttons. +import { ENGLISH } from "../i18n/english.ts"; +import type { Translate } from "../i18n/translate.ts"; import type { Flow, FlowType, UiNode } from "./kratos-public.ts"; export interface FlowField { @@ -66,14 +68,39 @@ export const AUTH_FLOWS: Record = { "/verification": "verification", }; -const CHROME: Record = { - login: { alt: { href: "/registration", label: "Create one", text: "Don't have an account?" }, sub: "Welcome back. Enter your details to continue.", title: "Sign in" }, - recovery: { alt: { href: "/login", label: "Sign in", text: "Remembered it?" }, back: { href: "/login", label: "Back to sign in" }, sub: "Enter your email and we'll send you a recovery code.", title: "Reset password" }, - registration: { alt: { href: "/login", label: "Sign in", text: "Already have an account?" }, sub: "Get started — it only takes a minute.", title: "Create account" }, - settings: { sub: "Update your account details.", title: "Account settings" }, - verification: { back: { href: "/login", label: "Back to sign in" }, sub: "Enter the code we sent you.", title: "Verify your email" }, +// Where each flow's card links; its words come from the catalog under `auth..*`. +const LINKS: Record = { + login: { alt: "/registration" }, + recovery: { alt: "/login", back: true }, + registration: { alt: "/login" }, + settings: {}, + verification: { back: true }, }; +function chromeFor(type: FlowType, t: Translate): FlowChrome { + const links = LINKS[type]; + return { + ...(links.alt ? { alt: { href: links.alt, label: t(`auth.${type}.altLabel`), text: t(`auth.${type}.altText`) } } : {}), + ...(links.back ? { back: { href: "/login", label: t(`auth.${type}.back`) } } : {}), + sub: t(`auth.${type}.sub`), + title: t(`auth.${type}.title`), + }; +} + +// A string Kratos authored (a field label, a button, a validation message). Kratos writes English +// and tags it with a stable numeric id, so the first key we hold a translation for wins and +// anything unmapped keeps Kratos' own words — never a bare key on screen. +function kratosText(t: Translate, fallback: string, ...keys: (string | undefined)[]): string { + for (const key of keys) { + if (key === undefined) continue; + const text = t(key); + if (text !== key) return text; + } + return fallback; +} + +const idKey = (id: number | undefined): string | undefined => (id === undefined ? undefined : `kratos.${id}`); + const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined); // Themed input icon by field semantics; undefined ⇒ no icon. @@ -93,7 +120,7 @@ function tone(type: string): FlowMessage["tone"] { const ssoLogo = (value: string): string => (value.charAt(0) || "?").toUpperCase(); -function toField(node: UiNode, name: string, type: string): FlowField { +function toField(node: UiNode, name: string, type: string, t: Translate): FlowField { const value = str(node.attributes["value"]); // The recovery/verification one-time code: numeric, and Kratos doesn't trim it, so a stray pasted // space makes it reject the code as "invalid". A digits-only pattern + numeric keypad block that in @@ -104,11 +131,13 @@ function toField(node: UiNode, name: string, type: string): FlowField { const errorMsg = node.messages.find((m) => m.type === "error"); return { id: "field-" + name.replace(/[^a-z0-9]+/gi, "-"), - label: node.meta.label?.text ?? name, + // Kratos' generic trait label (id 1070002) is "Email" here and "First name" on a schema with + // that trait, so a field falls back to its input name — the one thing that is unambiguous. + label: kratosText(t, node.meta.label?.text ?? name, idKey(node.meta.label?.id), `auth.field.${name}`), name, type, ...(autocomplete ? { autocomplete } : {}), - ...(errorMsg ? { error: { text: errorMsg.text } } : {}), + ...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}), ...(icon ? { icon } : {}), ...(isCode ? { inputmode: "numeric", pattern: "[0-9]*" } : {}), ...(node.attributes["required"] === true ? { required: true } : {}), @@ -116,7 +145,7 @@ function toField(node: UiNode, name: string, type: string): FlowField { }; } -export function buildFlowView(flow: Flow, type: FlowType): FlowView { +export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH): FlowView { const hidden: { name: string; value: string }[] = []; const fields: FlowField[] = []; const buttons: FlowButton[] = []; @@ -136,9 +165,10 @@ export function buildFlowView(flow: Flow, type: FlowType): FlowView { hidden.push({ name, value: str(node.attributes["value"]) ?? "" }); } else if (inputType === "submit" || inputType === "button") { const value = str(node.attributes["value"]); - buttons.push({ label: node.meta.label?.text ?? "Continue", ...(name ? { name } : {}), ...(value != null ? { value } : {}) }); + const label = kratosText(t, node.meta.label?.text ?? t("auth.continue"), idKey(node.meta.label?.id)); + buttons.push({ label, ...(name ? { name } : {}), ...(value != null ? { value } : {}) }); } else { - fields.push(toField(node, name, inputType)); + fields.push(toField(node, name, inputType, t)); } } @@ -147,10 +177,10 @@ export function buildFlowView(flow: Flow, type: FlowType): FlowView { buttons, fields, hidden, - messages: (flow.ui.messages ?? []).map((m) => ({ text: m.text, tone: tone(m.type) })), + messages: (flow.ui.messages ?? []).map((m) => ({ text: kratosText(t, m.text, idKey(m.id)), tone: tone(m.type) })), method: flow.ui.method || "post", sso, ...(type === "login" ? { recoverHref: "/recovery" } : {}), - ...CHROME[type], + ...chromeFor(type, t), }; } diff --git a/src/auth/routes.ts b/src/auth/routes.ts index c37717a..5f98592 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -7,6 +7,7 @@ import { readFormBody } from "../http/body.ts"; import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts"; import type { RequestContext } from "../http/context.ts"; import { CSRF_FIELD } from "./csrf.ts"; +import { chosenLocale } from "../i18n/locale.ts"; import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts"; import { HydraError, type HydraAdmin } from "./hydra-admin.ts"; import type { KetoClient } from "./keto-client.ts"; @@ -29,7 +30,7 @@ export interface AuthRouteDeps { } const TEXT_PLAIN = { "content-type": "text/plain; charset=utf-8" }; -const FORBIDDEN: RouteResult = { data: { title: "Forbidden" }, status: 403, view: "403" }; +const FORBIDDEN: RouteResult = { status: 403, view: "403" }; // Scheme + host for a self-referencing absolute URL (Kratos/Hydra return targets). Host reflects // what the browser used (so it matches the allow-lists); scheme follows SECURE_COOKIES. A spoofed @@ -60,10 +61,16 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole // as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com". const raw = ctx.url.searchParams.get("return_to"); const local = localPath(raw); + const chosen = chosenLocale(ctx); let returnTo: string | undefined; - if (local) { + if (local || chosen) { + // The flow's return target is the host's, not Kratos' — so the language the visitor picked + // on the sign-in page survives the round-trip through Kratos and lands on the page after + // it. Without this the most-travelled path in the product (pick Swedish → sign in) drops + // straight back to Accept-Language. const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`); - complete.searchParams.set("return_to", local); + if (local) complete.searchParams.set("return_to", ctx.localeHref(local)); + if (chosen) complete.searchParams.set("locale", chosen); returnTo = complete.toString(); } else if (raw) returnTo = raw; const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) }); @@ -87,14 +94,14 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole // documented, so render an honest 503 rather than the catch-all "error on our end" 500. if (!(err instanceof KratosError) || err.status >= 500) { ctx.log.warn("auth flow failed (Ory unreachable?)", { error: String(err), path: pathname }); - return { data: { title: "Sign-in unavailable" }, status: 503, view: "503" }; + return { status: 503, view: "503" }; } throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected) } // Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the // shell's Sign-out form (shown on /settings, where the user is signed in) needs the token. csrf.setCookie(); - return { data: { chrome: ctx.chrome, flow: buildFlowView(flow, flowType) }, view: "auth" }; + return { data: { chrome: ctx.chrome, flow: buildFlowView(flow, flowType, ctx.t) }, view: "auth" }; }; } @@ -114,7 +121,7 @@ function oauthLogin(deps: { hydra: HydraAdmin; kratos: KratosPublic }, secureCoo // A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login, re-used URL) is // user-reachable: tell them to restart rather than 500. A 5xx (Hydra down) rethrows → 500. if (err instanceof HydraError && err.status < 500) { - return { headers: TEXT_PLAIN, html: "This sign-in request has expired. Please start again from the application you were signing in to.", status: 400 }; + return { headers: TEXT_PLAIN, html: ctx.t("oauth.loginExpired"), status: 400 }; } throw err; } @@ -122,9 +129,9 @@ function oauthLogin(deps: { hydra: HydraAdmin; kratos: KratosPublic }, secureCoo } // Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500 (as /oauth2/login). -function consentError(err: unknown): RouteResult { +function consentError(err: unknown, ctx: RequestContext): RouteResult { if (err instanceof HydraError && err.status < 500) { - return { headers: TEXT_PLAIN, html: "This authorization request has expired. Please start again from the application you were signing in to.", status: 400 }; + return { headers: TEXT_PLAIN, html: ctx.t("oauth.consentExpired"), status: 400 }; } throw err; } @@ -143,7 +150,7 @@ function consentScreen(deps: { hydra: HydraAdmin; kratos: KratosPublic }, brand: csrf.setCookie(); return { data: { brand, consent: view, csrfField: CSRF_FIELD, csrfToken: csrf.token }, view: "oauth-consent" }; } catch (err) { - return consentError(err); + return consentError(err, ctx); } }; } @@ -164,7 +171,7 @@ function consentDecision(deps: { hydra: HydraAdmin; kratos: KratosPublic }): Bui : await rejectConsent(deps, challenge); return { redirect }; } catch (err) { - return consentError(err); + return consentError(err, ctx); } }; } @@ -184,7 +191,7 @@ function oauthLogout(hydra: HydraAdmin): BuiltinRoute["handler"] { } catch (err) { // Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500. if (err instanceof HydraError && err.status < 500) { - return { headers: TEXT_PLAIN, html: "This logout request has expired. Please start again from the application you were signing out of.", status: 400 }; + return { headers: TEXT_PLAIN, html: ctx.t("oauth.logoutExpired"), status: 400 }; } throw err; } @@ -229,7 +236,7 @@ function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["han // canonical-host redirect prevents the common cause (a lost cross-host CSRF cookie); this is the // honest fallback for any genuine flow error. The id is shown only for support reference. const errorSink = (ctx: RequestContext): RouteResult => - ({ data: { id: ctx.url.searchParams.get("id"), title: "Sign-in problem" }, view: "error" }); + ({ data: { id: ctx.url.searchParams.get("id") }, view: "error" }); export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }: AuthRouteDeps): BuiltinRoute[] { const routes: BuiltinRoute[] = []; diff --git a/src/http/app.test.ts b/src/http/app.test.ts index 0b799a3..21fe6cf 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -22,6 +22,9 @@ import { SESSION_COOKIE } from "../auth/login.ts"; import type { Plugin } from "../plugin-host/plugin.ts"; import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts"; import adminManifest from "../../examples/plugins/admin/plugin.ts"; +import { createI18n } from "../i18n/runtime.ts"; +import type { MenuConfig } from "../ui/menu-config.ts"; +import { loadI18n } from "../i18n/load.ts"; const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views"); // The admin screens ship as a drop-in example plugin; the HTTP-level admin tests mount it via @@ -369,8 +372,8 @@ test("/error renders a themed sign-in error page (Kratos' flow error sink), not test("renders the 500 HTML page when a handler throws", async () => { const dir = mkdtempSync(join(tmpdir(), "pp-views-")); - writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // the dashboard view - cpSync(join(viewsDir, "500.ejs"), join(dir, "500.ejs")); + cpSync(viewsDir, dir, { recursive: true }); // the real views: 500.ejs includes the language picker + writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir }); try { await new Promise((resolve) => app.listen(0, resolve)); @@ -822,7 +825,7 @@ test("renders a fetched flow as the themed auth page: fields post straight to Kr assert.match(html, / + <%= t("consent.notYou") %> <% } %> diff --git a/views/partials/auth-card.ejs b/views/partials/auth-card.ejs index a733c42..d7e3a8b 100644 --- a/views/partials/auth-card.ejs +++ b/views/partials/auth-card.ejs @@ -17,16 +17,16 @@ const providers = (sso && sso.providers) || []; const alt = locals.alt; -%> -
action="<%= locals.action %>"<% } %>> -
<% if (back) { %><%= back.label %><% } %>

<%= locals.title %>

<% if (locals.sub) { %>

<%= locals.sub %>

<% } %>
+ action="<%= localeHref(locals.action) %>"<% } %>> +
<% if (back) { %><%= back.label %><% } %>

<%= locals.title %>

<% if (locals.sub) { %>

<%= locals.sub %>

<% } %>
<% if (providers.length) { -%> -
"> - -
<%= sso.divider || "or" %>
+ <% } -%>
<%- locals.body || "" %>
<% if (alt) { -%> -

<%= alt.text %> <%= alt.label %>

+

<%= alt.text %> <%= alt.label %>

<% } -%> diff --git a/views/partials/consent-body.ejs b/views/partials/consent-body.ejs index 7e3180c..c6bf4f9 100644 --- a/views/partials/consent-body.ejs +++ b/views/partials/consent-body.ejs @@ -4,9 +4,9 @@ `decision` field). Locals: account?, challenge, csrfField, csrfToken, scopes (string[]). Captured by views/oauth-consent.ejs. -%> -<% const labels = { email: "Your email address", offline_access: "Stay signed in (offline access)", openid: "Verify your identity", profile: "Your basic profile (name)" }; -%> +<% const labels = { email: t("consent.scope.email"), offline_access: t("consent.scope.offline_access"), openid: t("consent.scope.openid"), profile: t("consent.scope.profile") }; -%> <% if (locals.account) { -%> -

Signed in as <%= account %>

+

<%= t("consent.signedInAs") %> <%= account %>

<% } -%> @@ -17,5 +17,5 @@ <% }) -%> <% } -%> - - + + diff --git a/views/partials/data-table.ejs b/views/partials/data-table.ejs index 2603002..3018b19 100644 --- a/views/partials/data-table.ejs +++ b/views/partials/data-table.ejs @@ -8,13 +8,15 @@ Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? } user + rowHeader cells render as — they identify the row (the row header). Action = { label, icon?, href?, danger?, separatorBefore? } + Every href (sort headers, row-header links, row actions) is run through localeHref, so a sorted or + paged list stays in the visitor's language without the caller wiring it. %><% const caption = locals.caption; const selectable = !!locals.selectable; const withActions = !!locals.actions; const columns = locals.columns || []; const rows = locals.rows || []; - const emptyText = locals.emptyText || "Nothing here yet."; // shown when a table that has columns has no rows + const emptyText = locals.emptyText || t("table.empty"); // shown when a table that has columns has no rows -%>
@@ -24,17 +26,17 @@ <% if (selectable) { -%> - + <% } -%> <% columns.forEach((col) => { -%> <% if (col.sortable) { -%> - + <% } else { -%> <% } -%> <% }) -%> <% if (withActions) { -%> - + <% } -%> @@ -45,7 +47,7 @@ <% rows.forEach((row) => { -%> <% if (selectable) { -%> - + <% } -%> <% (row.cells || []).forEach((cell) => { -%> <% if (typeof cell === "string") { -%> @@ -53,7 +55,7 @@ <% } else if (cell.user) { -%> <% } else if (cell.rowHeader) { -%> - + <% } else if (cell.badge) { -%> <% } else if (cell.html != null) { -%> @@ -64,8 +66,8 @@ <% }) -%> <% if (withActions) { -%> <% if ((row.actions || []).length) { -%> - <% } else { -%> diff --git a/views/partials/field.ejs b/views/partials/field.ejs index 01adecf..a8df996 100644 --- a/views/partials/field.ejs +++ b/views/partials/field.ejs @@ -21,7 +21,7 @@ -%>
<% if (link || optional) { -%> -
<% if (link) { %><%= link.label %><% } else { %>Optional<% } %>
+
<% if (link) { %><%= link.label %><% } else { %><%= t("field.optional") %><% } %>
<% } else { -%> <% } -%> diff --git a/views/partials/filter-bar.ejs b/views/partials/filter-bar.ejs index df8869e..85dc033 100644 --- a/views/partials/filter-bar.ejs +++ b/views/partials/filter-bar.ejs @@ -8,21 +8,26 @@ select { name, label, value?, options:{value,label}[] } chips { name, legend?, value?:string[], options:{value,label}[] } (checkboxes) daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} } + The form is a GET, which replaces the whole query string — so the visitor's chosen language rides + along as a hidden input, and every href here (pills, clear) is run through localeHref. %><% const action = locals.action || ""; - const label = locals.label || "Filter"; + const label = locals.label || t("filter.label"); const rows = locals.rows || []; const pills = locals.pills || []; const clearHref = locals.clearHref || "?"; - const applyLabel = locals.applyLabel || "Apply filters"; + const applyLabel = locals.applyLabel || t("filter.apply"); const eq = (a, b) => String(a ?? "") === String(b); -%> -
action="<%= action %>"<% } %> aria-label="<%= label %>"> + action="<%= localeHref(action) %>"<% } %> aria-label="<%= label %>"> +<% if (localeParam) { -%> + +<% } -%> <% rows.forEach((row) => { -%>
<% row.forEach((c) => { -%> <% if (c.type === "search") { -%> - + <% } else if (c.type === "segmented") { -%>
<%= c.legend || c.name %>
<% c.options.forEach((o) => { %><% }) %>
<% } else if (c.type === "select") { -%> @@ -30,7 +35,7 @@ <% } else if (c.type === "chips") { -%>
<%= c.legend || c.name %>
<% (c.options).forEach((o) => { const on = (c.value || []).map(String).includes(String(o.value)); %><% }) %>
<% } else if (c.type === "daterange") { -%> -
<%= c.legend || "Date range" %>
value="<%= c.from.value %>"<% } %>> value="<%= c.to.value %>"<% } %>>
+
<%= c.legend || t("filter.dateRange") %>
value="<%= c.from.value %>"<% } %>> value="<%= c.to.value %>"<% } %>>
<% } else if (c.type === "spacer") { -%>
<% } -%> @@ -39,11 +44,11 @@ <% }) -%>
<% if (pills.length) { -%> -
Applied<% pills.forEach((p) => { %><%= p.label %>: <%= p.value %> <% }) %>Clear all
+
"><%= t("filter.applied") %><% pills.forEach((p) => { %><%= p.label %>: <%= p.value %> "><% }) %><%= t("filter.clearAll") %>
<% } -%>
- +
diff --git a/views/partials/flow-body.ejs b/views/partials/flow-body.ejs index a9b18ef..ba88806 100644 --- a/views/partials/flow-body.ejs +++ b/views/partials/flow-body.ejs @@ -13,7 +13,7 @@ <%- include("field", field) %> <% }) -%> <% if (flow.recoverHref) { -%> -

Forgot password?

+

<%= t("auth.forgotPassword") %>

<% } -%> <% flow.buttons.forEach((b, i) => { -%> diff --git a/views/partials/landing-body.ejs b/views/partials/landing-body.ejs index fc6fe2a..cfe4810 100644 --- a/views/partials/landing-body.ejs +++ b/views/partials/landing-body.ejs @@ -3,11 +3,11 @@ Rendered into the app-shell content. Locals: brand (name), signedIn (bool). %>
-

Operational web apps, without the boilerplate.

-

<%= brand %> is a self-hostable foundation for admin and operational UIs — sign-in, a config-driven menu, and a server-rendered, zero-JS design system. You add the domain-specific screens by dropping in plugin folders.

+

<%= t("landing.title") %>

+

<%= t("landing.lead", { brand }) %>

- <% if (signedIn) { %>Go to your dashboard - <% } else { %>Log inCreate account<% } %> + <% if (signedIn) { %>"><%= t("landing.dashboard") %> + <% } else { %>"><%= t("landing.signIn") %>"><%= t("landing.register") %><% } %>
diff --git a/views/partials/locale-switch.ejs b/views/partials/locale-switch.ejs new file mode 100644 index 0000000..cbe24c6 --- /dev/null +++ b/views/partials/locale-switch.ejs @@ -0,0 +1,20 @@ +<%# + Language picker: one link per installed locale, each pointing at this same page with ?locale set, + so switching is a plain navigation — zero-JS, and the address bar always says which language the + page is in. Renders nothing for a single-language deployment. + Locals: localeSwitch (host-supplied: { current, href, label, tag }[]) · up? (open upward, default true) +-%> +<% const choices = locals.localeSwitch || []; -%> +<% if (choices.length > 1) { -%> +<%- include("menu", { + up: locals.up !== false, + trigger: { class: "btn icon-btn", icon: "i-globe", label: t("locale.label") }, + items: [ + { head: t("locale.label") }, + ...choices.map((c) => ({ current: c.current, href: c.href, hreflang: c.tag, label: c.label, ownLocale: true })), + // This page can't be re-rendered in another language (its URL answers no GET), so switching + // navigates away — and may leave a one-time secret behind. Say it before the click. + ...(locals.leavesPage ? [{ head: t("locale.leavesPage") }] : []), + ], +}) %> +<% } -%> diff --git a/views/partials/menu.ejs b/views/partials/menu.ejs index f73ca49..5cc72cf 100644 --- a/views/partials/menu.ejs +++ b/views/partials/menu.ejs @@ -8,17 +8,18 @@ kebab? boolean bare kebab trigger (adds .kebab) width? number|string popover min-width (number ⇒ px) items: Item[] popover content, top→bottom - Item ∈ { head } · { sep } · { label, icon?, href? ⇒ , danger? } (default: menu-item button) + Item ∈ { head } · { sep } · { label, icon?, href? ⇒ , hreflang?, ownLocale?, current?, danger? } (default: menu-item button) + ownLocale: the href already states its language (the picker) — don't carry the current one onto it · { group: { legend?, name, control?(="checkbox"|"radio"), options:{value,label,checked?}[] } } %><% - const t = locals.trigger || {}; - const sumCls = "class" in t ? t.class : "btn"; + const trigger = locals.trigger || {}; // not `t` — that name is the translator in every view + const sumCls = "class" in trigger ? trigger.class : "btn"; const items = locals.items || []; const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : ""); const width = locals.width; -%>
"> aria-sort="ascending"<% } else if (col.sort === "desc") { %> aria-sort="descending"<% } %><% if (col.className) { %> class="<%= col.className %>"<% } %>><%= col.label %> "/> aria-sort="ascending"<% } else if (col.sort === "desc") { %> aria-sort="descending"<% } %><% if (col.className) { %> class="<%= col.className %>"<% } %>><%= col.label %> "/> class="<%= col.className %>"<% } %>><%= col.label %>Actions<%= t("table.actions") %>
">"><%= cell.user.name %><% if (cell.rowHeader.href) { %><%= cell.rowHeader.text %><% } else { %><%= cell.rowHeader.text %><% } %><% if (cell.rowHeader.href) { %><%= cell.rowHeader.text %><% } else { %><%= cell.rowHeader.text %><% } %><%= cell.badge.label %>