Add i18n support: per-locale catalogs, URL-driven locale, translated core and examples
CI / full-gate (push) Successful in 2m37s

This commit is contained in:
2026-08-03 22:37:27 +02:00
parent c30cd95ebd
commit 245d1ad5b5
93 changed files with 2480 additions and 464 deletions
+22 -1
View File
@@ -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,21 @@ 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.
- **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 must not interpolate
untrusted data. Don't move escaping into `t()`.
- **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),
@@ -190,6 +206,11 @@ Same test before adding a row to a table or the file map — a clause, not a par
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
- 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".
+111 -8
View File
@@ -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)
@@ -356,14 +357,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 +437,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 +572,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<string, string>; // 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 +607,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 +904,87 @@ 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
plugins/<id>/i18n/en-US.ts a plugin's own words, looked up before the host's
plugins/<id>/i18n/sv-SE.ts
```
**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 lands on `en-US`
rather than 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 only when more than one is installed.
**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:
```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" };
```
```html
<!-- view -->
<h1><%= t("shop.title") %></h1>
<p><%= t("shop.orders", { count: orders.length }) %></p>
<a href="<%= localeHref("/shop/new") %>"><%= t("shop.new") %></a>
```
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 must never interpolate
untrusted data, since nothing escapes it there.
- **Dates and numbers are `Intl`'s job**, not the catalog's: `new Intl.DateTimeFormat(ctx.locale)`.
**Kratos writes the auth flow's own text** (field labels, validation errors) and tags each string
with a stable numeric id; a `kratos.<id>` 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 +1358,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 +1745,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/<id>/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)
+5 -3
View File
@@ -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
+78
View File
@@ -0,0 +1,78 @@
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: "Panel" })).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
// …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");
});
+2 -2
View File
@@ -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)
+5
View File
@@ -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
+46 -39
View File
@@ -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<string, unknown> {
};
}
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<ListState> = {}): 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("admin.common.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
return {
applyLabel: "Apply",
applyLabel: t("admin.common.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<typeof paginate>) {
function listPagination(state: ListState, page: ReturnType<typeof paginate>, 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("admin.common.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("admin.common.go"), value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
@@ -175,18 +177,20 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
export function buildClientFormModel(opts: {
csrfToken?: string;
error?: string;
t?: Translate;
values?: Partial<ClientInput>;
}) {
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<RouteResult>): 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<ClientInput> }): 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("admin.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" });
});
+42 -35
View File
@@ -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<string, (g: GroupView) => 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<ListState> = {}): 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("admin.common.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
return {
applyLabel: "Apply",
applyLabel: t("admin.common.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<typeof paginate>) {
function listPagination(state: ListState, page: ReturnType<typeof paginate>, 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("admin.common.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("admin.common.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("admin.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<RouteResult>): 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<RouteR
const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
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<RouteResult> => ({ ...(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("admin.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" });
});
+45 -38
View File
@@ -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<string, (r: PermissionView) => 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<ListState> = {}): 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("admin.common.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
return {
applyLabel: "Apply",
applyLabel: t("admin.common.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<typeof paginate>) {
function listPagination(state: ListState, page: ReturnType<typeof paginate>, 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("admin.common.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("admin.common.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("admin.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<RouteResult>): 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<RouteRes
const roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
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<RouteResult> => ({ ...(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("admin.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) };
+8 -4
View File
@@ -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
});
+13 -8
View File
@@ -3,7 +3,12 @@
// (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, createTranslator, CSRF_FIELD, 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, 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 = createTranslator({ catalogs: [enUS], locale: "en-US" });
export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section
export const ADMIN_USERS_BASE = "/admin/users";
@@ -18,14 +23,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 +54,13 @@ export async function guardedForm(ctx: RequestContext): Promise<URLSearchParams
// A themed "not found" (bad id/name in the path) rendered in the admin shell — 404, never a 500.
export function notFound(ctx: RequestContext): RouteResult {
return { data: { chrome: ctx.chrome, message: "That item doesn't exist.", title: "Not found" }, status: 404, view: "notice" };
return { data: { chrome: ctx.chrome, message: ctx.t("admin.notFound.message"), title: ctx.t("admin.notFound.title") }, status: 404, view: "notice" };
}
// A capability the plugin needs isn't on ctx.system (Ory not wired). Login already requires these in
// a real deployment, so this is the honest 503 fallback for a misconfigured host, not a crash.
export function unavailable(ctx: RequestContext, what: string): RouteResult {
return { data: { chrome: ctx.chrome, message: `${what} is not configured on this deployment.`, title: "Admin unavailable" }, status: 503, view: "notice" };
return { data: { chrome: ctx.chrome, message: ctx.t("admin.unavailable.message", { what }), title: ctx.t("admin.unavailable.title") }, status: 503, view: "notice" };
}
// Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields
+56 -53
View File
@@ -4,8 +4,8 @@
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
import { ADMIN_EN, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25;
@@ -30,8 +30,6 @@ export interface UserInput {
password: string;
}
const cap = (s: string): string => 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, (u: UserView) => 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<ListState> = {}): 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("admin.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("admin.common.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<typeof paginate>) {
function listPagination(state: ListState, page: ReturnType<typeof paginate>, 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("admin.common.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("admin.common.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<UserInput>;
}) {
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("admin.common.edit") : t("admin.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<RouteResult>): 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<typeof buildUserFormModel>[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("admin.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");
}
+161
View File
@@ -0,0 +1,161 @@
// 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.actions": "Actions",
"admin.common.add": "Add",
"admin.common.apply": "Apply",
"admin.common.cancel": "Cancel",
"admin.common.chooseMember": "Choose a user or group…",
"admin.common.delete": "Delete",
"admin.common.edit": "Edit",
"admin.common.go": "Go",
"admin.common.group": "Group",
"admin.common.member": "Member",
"admin.common.new": "New",
"admin.common.remove": "Remove",
"admin.common.rows": "Rows",
"admin.common.search": "Search",
"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.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.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.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 on the <a href=\"/recovery\">password-reset screen</a> to set a new password (generate a fresh one if it has expired).",
"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;
+159
View File
@@ -0,0 +1,159 @@
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": "Ta bort 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 dem där appen läser sina uppgifter.",
"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.actions": "Åtgärder",
"admin.common.add": "Lägg till",
"admin.common.apply": "Använd",
"admin.common.cancel": "Avbryt",
"admin.common.chooseMember": "Välj en användare eller grupp…",
"admin.common.delete": "Ta bort",
"admin.common.edit": "Redigera",
"admin.common.go": "Visa",
"admin.common.group": "Grupp",
"admin.common.member": "Medlem",
"admin.common.new": "Ny",
"admin.common.remove": "Ta bort",
"admin.common.rows": "Rader",
"admin.common.search": "Sök",
"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": "Ta bort 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.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": "Ta bort 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.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.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": "Ta bort 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 på <a href=\"/recovery\">sidan för lösenordsåterställning</a> för att sätta ett nytt lösenord (skapa en ny om den hunnit gå ut).",
"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;
+1 -1
View File
@@ -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 = '<a class="btn btn-primary" href="/admin/clients/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Register client</a>';
const actions = '<a class="btn btn-primary" href="' + localeHref("/admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>';
-%>
<%- include("partials/shell", {
actions,
+1 -1
View File
@@ -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 = '<a class="btn btn-primary" href="/admin/groups/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add group</a>';
const actions = '<a class="btn btn-primary" href="' + localeHref("/admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
-%>
<%- include("partials/shell", {
actions,
@@ -11,28 +11,28 @@
-%>
<div class="form-page">
<% if (locals.created) { -%>
<%- include("partials/alert", { text: "Client registered.", tone: "pos" }) %>
<%- include("partials/alert", { text: t("admin.clients.createdNotice"), tone: "pos" }) %>
<% } -%>
<% if (locals.secret) { -%>
<section class="form-card" aria-labelledby="secret-h">
<h2 class="card-title" id="secret-h">Client secret</h2>
<p class="field-hint">Copy these now — the secret can't be shown again. Store them where the app reads its credentials.</p>
<div class="field"><label for="cid">Client ID</label><input class="input" id="cid" type="text" value="<%= c.id %>" readonly></div>
<div class="field"><label for="csecret">Client secret</label><input class="input" id="csecret" type="text" value="<%= locals.secret %>" readonly></div>
<h2 class="card-title" id="secret-h"><%= t("admin.clients.secret") %></h2>
<p class="field-hint"><%= t("admin.clients.secretHint") %></p>
<div class="field"><label for="cid"><%= t("admin.clients.column.id") %></label><input class="input" id="cid" type="text" value="<%= c.id %>" readonly></div>
<div class="field"><label for="csecret"><%= t("admin.clients.secret") %></label><input class="input" id="csecret" type="text" value="<%= locals.secret %>" readonly></div>
</section>
<% } -%>
<section class="form-card" aria-labelledby="client-h">
<h2 class="card-title" id="client-h"><%= c.name %></h2>
<dl class="detail-list">
<dt>Client ID</dt><dd><%= c.id %></dd>
<dt>Type</dt><dd><%= c.public ? "Public (PKCE)" : "Confidential" %></dd>
<dt>Consent</dt><dd><%= c.firstParty ? "First-party (auto-granted)" : "Shows the consent screen" %></dd>
<dt>Scopes</dt><dd><%= c.scopes.length ? c.scopes.join(" ") : "—" %></dd>
<dt>Redirect URIs</dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd>
<dt><%= t("admin.clients.column.id") %></dt><dd><%= c.id %></dd>
<dt><%= t("admin.clients.column.type") %></dt><dd><%= c.public ? t("admin.clients.publicPkce") : t("admin.clients.confidential") %></dd>
<dt><%= t("admin.clients.consent.label") %></dt><dd><%= c.firstParty ? t("admin.clients.consent.firstParty") : t("admin.clients.consent.screen") %></dd>
<dt><%= t("admin.clients.field.scopes") %></dt><dd><%= c.scopes.length ? c.scopes.join(" ") : "—" %></dd>
<dt><%= t("admin.clients.field.redirectUris") %></dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd>
</dl>
</section>
<section class="form-card admin-actions" aria-label="Client actions">
<p class="field-hint">To change a client, delete and re-register — this issues a new client ID and secret. The secret is shown only once, at registration.</p>
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete client</a>
<section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>">
<p class="field-hint"><%= t("admin.clients.rereg") %></p>
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a>
</section>
</div>
@@ -14,16 +14,16 @@
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="redirectUris">Redirect URIs</label>
<label for="redirectUris"><%= t("admin.clients.field.redirectUris") %></label>
<textarea class="input" id="redirectUris" name="redirectUris" rows="3" placeholder="https://app.example.com/callback"><%= form.redirectUris %></textarea>
<span class="field-hint">One per line — where the app is sent back after sign-in.</span>
<span class="field-hint"><%= t("admin.clients.field.redirectUrisHint") %></span>
</div>
<%- include("partials/field", form.scopeField) %>
<label class="check"><input type="checkbox" name="public"<% if (form.public) { %> checked<% } %>> Public client (SPA / native app, PKCE — no secret)</label>
<span class="field-hint">Browser and mobile apps can't keep a secret — choose Public. Server-side apps that can store one — leave it Confidential.</span>
<span class="field-hint"><%= t("admin.clients.field.typeHint") %></span>
<label class="check"><input type="checkbox" name="firstParty"<% if (form.firstParty) { %> checked<% } %>> First-party (auto-grant consent — skip the consent screen)</label>
<div class="form-actions">
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("admin.common.cancel") %></a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
@@ -7,10 +7,10 @@
csrfToken
%>
<div class="form-page">
<section class="form-card admin-actions" aria-label="Confirm action">
<section class="form-card admin-actions" aria-label="<%= t("admin.users.confirm") %>">
<p><%= locals.message %></p>
<div class="form-actions">
<a class="btn" href="<%= locals.cancelHref %>">Cancel</a>
<a class="btn" href="<%= localeHref(locals.cancelHref) %>"><%= t("admin.common.cancel") %></a>
<form method="post" action="<%= locals.confirm.action %>"><input type="hidden" name="_csrf" value="<%= locals.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= locals.confirm.label %></button></form>
</div>
</section>
@@ -17,26 +17,26 @@
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h">Members</h2>
<h2 class="card-title" id="members-h"><%= t("admin.groups.members") %></h2>
<% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= group.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: group.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("admin.common.actions") %></span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg>Remove</button></form></td></tr>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("admin.common.remove") %></button></form></td></tr>
<% }) -%>
</tbody></table></div>
<% } else { -%>
<p class="cell-muted">No members yet.</p>
<p class="cell-muted"><%= t("admin.groups.noMembers") %></p>
<% } -%>
</section>
<section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h">Add a member</h2>
<h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2>
<% if (add.options.length) { -%>
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add</button></form>
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("admin.common.add") %></button></form>
<% } else { -%>
<p class="cell-muted">All users and groups are already members.</p>
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
<% } -%>
</section>
<section class="form-card admin-actions" aria-label="Group actions">
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete group</a>
<section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>">
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
</section>
</div>
@@ -14,12 +14,12 @@
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="member">First member</label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a member</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<span class="field-hint">A group exists once it has a member; add more after creating it.</span>
<label for="member"><%= t("admin.groups.firstMember") %></label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>><%= t("admin.common.chooseMember") %></option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<span class="field-hint"><%= t("admin.groups.firstMemberHint") %></span>
</div>
<div class="form-actions">
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("admin.common.cancel") %></a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
@@ -19,15 +19,15 @@
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h">Assigned to</h2>
<h2 class="card-title" id="members-h"><%= t("admin.permissions.assignedTo") %></h2>
<% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= permission.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: permission.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("admin.common.actions") %></span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg>Revoke</button></form></td></tr>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("admin.permissions.revoke") %></button></form></td></tr>
<% }) -%>
</tbody></table></div>
<% } else { -%>
<p class="cell-muted">Not assigned to anyone yet.</p>
<p class="cell-muted"><%= t("admin.permissions.noMembers") %></p>
<% } -%>
</section>
<section class="form-card" aria-labelledby="effective-h">
@@ -14,12 +14,12 @@
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="member">Assign to</label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<label for="member"><%= t("admin.permissions.assignTo") %></label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>><%= t("admin.common.chooseMember") %></option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<span class="field-hint">A permission exists once assigned; add more users or groups after creating it.</span>
</div>
<div class="form-actions">
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("admin.common.cancel") %></a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
@@ -14,7 +14,7 @@
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<% if (recovery) { -%>
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong>Recovery code generated</strong><span>Give it to the user — they enter it on the <a href="/recovery">password-reset screen</a> to set a new password (generate a fresh one if it has expired).</span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong><%= t("admin.users.recovery.title") %></strong><span><%- t("admin.users.recovery.body") %></span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
<% } -%>
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
@@ -22,15 +22,15 @@
<%- include("partials/field", field) %>
<% }) -%>
<div class="form-actions">
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("admin.common.cancel") %></a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
<% if (edit) { -%>
<section class="form-card admin-actions" aria-label="Account actions">
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg>Generate recovery code</button></form>
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
<form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
<a class="btn btn-danger" href="<%= edit.deleteAction %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete user</a>
<a class="btn btn-danger" href="<%= edit.deleteAction %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a>
</section>
<% } -%>
</div>
+1 -1
View File
@@ -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 = '<a class="btn btn-primary" href="/admin/permissions/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add permission</a>';
const actions = '<a class="btn btn-primary" href="' + localeHref("/admin/permissions/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.permissions.new") + '</a>';
-%>
<%- include("partials/shell", {
actions,
+1 -1
View File
@@ -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 = '<a class="btn btn-primary" href="/admin/users/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add user</a>';
const actions = '<a class="btn btn-primary" href="' + localeHref("/admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
-%>
<%- include("partials/shell", {
actions,
+4
View File
@@ -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`).
+42
View File
@@ -0,0 +1,42 @@
// 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.cancel": "Cancel",
"scheduling.field.assignee": "Assignee",
"scheduling.field.end": "End",
"scheduling.field.start": "Start",
"scheduling.field.title": "Shift title",
"scheduling.filter.label": "Filter shifts",
"scheduling.filter.search": "Search",
"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 <code>scheduling:read</code> 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;
+36
View File
@@ -0,0 +1,36 @@
import type { SchedulingMessages } from "./en-US.ts";
const messages: SchedulingMessages = {
"scheduling.cancel": "Avbryt",
"scheduling.field.assignee": "Tilldelad",
"scheduling.field.end": "Slut",
"scheduling.field.start": "Start",
"scheduling.field.title": "Passets namn",
"scheduling.filter.label": "Filtrera pass",
"scheduling.filter.search": "Sök",
"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 <code>scheduling:read</code>.",
"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;
+5 -4
View File
@@ -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/<locale>.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 `<id>:<action>`.
+6 -3
View File
@@ -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 { createTranslator, 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 = createTranslator({ catalogs: [enUS], locale: "en-US" }); // this plugin's own catalog, as the host would pass it
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),
};
}
+45 -28
View File
@@ -6,7 +6,12 @@
// 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, createTranslator, CSRF_FIELD, 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, for a view model built outside a request (its unit tests). At runtime a
// handler passes ctx.t, which reads this plugin's catalog for the visitor's locale first.
const EN: Translate = createTranslator({ catalogs: [enUS], locale: "en-US" });
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
export const SHIFTS_PATH = "/scheduling/shifts";
@@ -87,58 +92,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("scheduling.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("scheduling.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<string, string>; formError?: string; values?: Partial<ShiftInput> }) {
export function buildFormModel(opts: { chrome: PageChrome; errors?: Record<string, string>; formError?: string; t?: Translate; values?: Partial<ShiftInput> }) {
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("scheduling.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 +165,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<string, string> | null {
export function validate(input: ShiftInput, t: Translate = EN): Record<string, string> | null {
const errors: Record<string, string> = {};
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 +183,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 +201,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 +219,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
@@ -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
? '<a class="btn btn-primary" href="' + shiftsHref + '">View shifts</a>'
: '<a class="btn btn-primary" href="/login?return_to=' + encodeURIComponent(shiftsHref) + '">Sign in to view shifts</a>';
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
-%>
<%- include("partials/shell", {
actions: "",
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.</p>' + cta + '</div>',
body: '<div class="scheduling-page"><p>' + t("scheduling.overview.lead") + '</p>' + cta + '</div>',
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
@@ -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;
-%>
@@ -15,7 +15,7 @@
<%- include("partials/field", field) %>
<% }) -%>
<div class="form-actions">
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= form.cancelLabel %></a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
+3 -3
View File
@@ -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
? '<a class="btn btn-primary" href="' + newHref + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>New shift</a>'
? '<a class="btn btn-primary" href="' + localeHref(newHref) + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("scheduling.shifts.new") + '</a>'
: "";
-%>
<%- include("partials/shell", {
actions,
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + tableHtml + '</div>',
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>',
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
+11 -7
View File
@@ -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" },
]);
});
+44 -14
View File
@@ -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<string, FlowType> = {
"/verification": "verification",
};
const CHROME: Record<FlowType, FlowChrome> = {
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.<flow>.*`.
const LINKS: Record<FlowType, { alt?: string; back?: boolean }> = {
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),
};
}
+10 -10
View File
@@ -29,7 +29,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
@@ -87,14 +87,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 +114,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 +122,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 +143,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 +164,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 +184,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 +229,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[] = [];
+67 -3
View File
@@ -22,6 +22,8 @@ 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 { 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
@@ -822,7 +824,7 @@ test("renders a fetched flow as the themed auth page: fields post straight to Kr
assert.match(html, /<button type="submit" class="sso-btn" name="provider" value="google" formnovalidate>.*Sign in with Google<\/span><\/button>/s);
// The flow-level error renders as an alert.
assert.match(html, /class="alert alert-neg"/);
assert.match(html, /The provided credentials are invalid\./);
assert.match(html, /The credentials are invalid\./); // 4000006 → our wording (README → Translating)
});
// Login completion: /auth/complete is where Kratos lands the browser after login.
@@ -859,7 +861,9 @@ const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockK
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
const ADMIN_CSRF = "admin-secret";
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
// Mount the plugin's catalogs the way server.ts does, so its screens render words, not keys.
const i18n = createI18n(await loadI18n({ pluginIds: [adminPlugin.id], pluginsDir: examplesPluginsDir }));
const app = createApp({ csrfSecret: ADMIN_CSRF, i18n, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
@@ -1343,7 +1347,7 @@ test("admin OAuth2 clients screen: gate, list, register (one-time secret), detai
// client and shows the one-time secret + id.
const formHtml = await (await get("/admin/clients/new")).text();
assert.match(formHtml, /Register client/);
assert.match(formHtml, /can't keep a secret/i); // guidance on the public-vs-confidential choice
assert.match(formHtml, /keep a secret/i); // guidance on the public-vs-confidential choice (apostrophes arrive escaped: t() text goes through <%= %>)
const created = await post("/admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`);
assert.equal(created.status, 200); // not a redirect — the secret is shown once
const createdHtml = await created.text();
@@ -1395,3 +1399,63 @@ test("routePublic sends a plugin-id segment to its public/ dir, everything else
assert.deepEqual(routePublic("scheduling", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "" }); // bare /public/<id>, no file
assert.deepEqual(routePublic("css/styles.css", "/core", "/plugins", ids), { dir: "/core", subPath: "css/styles.css" }); // not a plugin → core
});
// ---- language (i18n) ----
// The installed catalogs, as server.ts wires them: the shipped core locales (en-US + sv-SE).
async function localeApp(t: TestContext): Promise<string> {
const app = createApp({ i18n: createI18n(await loadI18n()), jwks: staticJwks([ecJwk]) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
return `http://localhost:${(app.address() as AddressInfo).port}`;
}
test("?locale serves that language and carries the choice onto the links the page renders", async (t) => {
const url = await localeApp(t);
const html = await (await fetch(`${url}/?locale=sv-SE`)).text();
assert.match(html, /<html lang="sv-SE" dir="ltr">/); // the document says what language it is in
assert.match(html, /Logga in/); // the landing page's own words
assert.doesNotMatch(html, /Operational web apps/);
// The chosen locale rides along, so clicking through the app stays in Swedish without a cookie.
assert.match(html, /href="\/login\?locale=sv-SE"/);
// …and the picker offers the other installed locale, pointing at this same page.
assert.match(html, /hreflang="en-US"/);
assert.match(html, /href="\/\?locale=en-US"/);
});
test("Accept-Language decides when the URL doesn't, and a lone language matches its region", async (t) => {
const url = await localeApp(t);
const swedish = await (await fetch(`${url}/`, { headers: { "accept-language": "sv;q=0.9, en;q=0.4" } })).text();
assert.match(swedish, /<html lang="sv-SE"/);
// The visitor never asked for a locale in the URL, so the links stay clean.
assert.match(swedish, /href="\/login"/);
const english = await (await fetch(`${url}/`, { headers: { "accept-language": "de-DE" } })).text();
assert.match(english, /<html lang="en-US"/); // nothing matches ⇒ the baseline
});
test("an uninstalled or malformed ?locale falls back instead of failing", async (t) => {
const url = await localeApp(t);
for (const bad of ["sv-FI", "klingon", "../../etc"]) {
const res = await fetch(`${url}/?locale=${encodeURIComponent(bad)}`);
assert.equal(res.status, 200);
assert.match(await res.text(), /<html lang="en-US"/, `expected en-US for ${bad}`);
}
});
test("a redirect the host emits keeps the visitor's language", async (t) => {
const url = await localeApp(t);
const res = await fetch(`${url}/dashboard?locale=sv-SE`, { redirect: "manual" }); // anonymous ⇒ sign in first
assert.equal(res.status, 303);
const location = res.headers.get("location") ?? "";
assert.match(location, /^\/login\?/);
assert.match(location, /locale=sv-SE/);
});
test("the error pages speak the visitor's language too", async (t) => {
const url = await localeApp(t);
const html = await (await fetch(`${url}/no-such-page?locale=sv-SE`)).text();
assert.match(html, /<html lang="sv-SE"/);
assert.match(html, /Sidan hittades inte/);
});
+53 -16
View File
@@ -11,6 +11,10 @@ import type { Denylist } from "../auth/denylist.ts";
import { buildDashboardModel } from "../ui/dashboard.ts";
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
import { GuardError, loginRedirect } from "../auth/guards.ts";
import { ENGLISH_I18N } from "../i18n/english.ts";
import type { I18n } from "../i18n/runtime.ts";
import { localeHref } from "../i18n/locale.ts";
import { ENGLISH_LOCALS, i18nLocals } from "../i18n/view-locals.ts";
import { runRequestHooks, runResponseHooks } from "../plugin-host/hooks.ts";
import type { HydraAdmin } from "../auth/hydra-admin.ts";
import type { JwksProvider } from "../auth/jwks.ts";
@@ -40,6 +44,9 @@ export interface AppOptions {
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
// Loaded translation catalogs (server.ts passes the discovered ones). Omitted ⇒ the built-in
// en-US catalog only, so an unwired app still renders real English.
i18n?: I18n;
jwks?: JwksProvider; // verify the session JWT → ctx.user/permissions; absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
@@ -69,6 +76,7 @@ export function createApp(options: AppOptions = {}): Server {
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
const secureCookies = options.secureCookies ?? false;
const hydra = options.hydra;
const i18n = options.i18n ?? ENGLISH_I18N;
const jwks = options.jwks;
const keto = options.keto;
const kratos = options.kratos;
@@ -107,6 +115,12 @@ export function createApp(options: AppOptions = {}): Server {
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
// Every view renders with its context's i18n locals (t/locale/dir/localeSwitch) merged in, so a
// view — core or plugin, at any include depth — calls `t(...)` without its handler passing it.
// A plugin's context carries that plugin's translator, so its own catalog wins in its own views.
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...i18nLocals(ctx), ...data });
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...i18nLocals(ctx), ...data });
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
res.end(html);
@@ -121,7 +135,7 @@ export function createApp(options: AppOptions = {}): Server {
if (homePlugin) {
const result = (await homePlugin.home(ctx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, ctx, result);
await sendResult(ctx.res, result, (view, data) => renderView(homePlugin.id, view, data));
await sendResult(ctx.res, result, pluginViewsFor(ctx, homePlugin.id), ctx.localeHref);
return null;
}
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
@@ -138,10 +152,10 @@ export function createApp(options: AppOptions = {}): Server {
if (dashboardPlugin) {
const result = (await dashboardPlugin.dashboard(ctx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, ctx, result);
await sendResult(ctx.res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
await sendResult(ctx.res, result, pluginViewsFor(ctx, dashboardPlugin.id), ctx.localeHref);
return null;
}
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav }) }, view: "index" };
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav, t: ctx.t }) }, view: "index" };
};
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
@@ -156,9 +170,13 @@ export function createApp(options: AppOptions = {}): Server {
// outbound fetch (the Ory clients via tracedFetch) and any deep module joins this request's trace
// and correlation with no logger threaded through their signatures.
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
// Error pages can render before this request has a context at all (a throw on the way to one),
// so they start on the built-in English and switch to the visitor's locale once it is resolved.
let renderPage: ViewRenderer = (view, data) => render(view, { ...ENGLISH_LOCALS, ...data });
try {
const method = req.method ?? "GET";
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
const url = new URL(req.url ?? "/", "http://localhost");
const pathname = url.pathname;
// Set before any branch so every response — static/redirect/error included — inherits them
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
@@ -186,6 +204,13 @@ export function createApp(options: AppOptions = {}): Server {
}
}
// Which language this request is served in: ?locale wins, else Accept-Language, else en-US.
// `explicit` (the URL asked) is what makes the choice travel: the chrome, this request's
// redirects and ctx.localeHref then carry ?locale onto the links they emit.
const { explicit, locale } = i18n.resolve({ acceptLanguage: req.headers["accept-language"], param: url.searchParams.get("locale") });
const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
const t = i18n.translator(locale);
// Verify the session JWT once (cached JWKS) → ctx.user/permissions; none/invalid ⇒ anonymous.
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
// clients), silently re-mint it — "stay signed in": re-read permissions from Keto, re-tokenize,
@@ -223,10 +248,20 @@ export function createApp(options: AppOptions = {}): Server {
// ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler,
// or the public "/" with a standalone home, never composes the menu).
let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, localeHref: carryLocale, menu, plugins, t, translatorFor: (id) => i18n.translator(locale, id), user }));
// The i18n half of every context: the locale, its translator, and the link carrier. A plugin
// route swaps in the plugin's own translator (its catalog first, then core).
const i18nFor = (pluginId?: string) => ({
locale,
localeHref: carryLocale,
locales: i18n.available,
t: pluginId === undefined ? t : i18n.translator(locale, pluginId),
});
// base context (no route params yet); reused for onRequest hooks and the landing routes.
const ctx = buildContext(req, res, { chrome, user, log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
const ctx = buildContext(req, res, { chrome, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
renderPage = viewsFor(ctx);
// Plugin onRequest hooks run before routing and may short-circuit the request.
if (anyRequestHooks) {
@@ -235,7 +270,7 @@ export function createApp(options: AppOptions = {}): Server {
// Set the fresh CSRF cookie like every other page-emitting path, so a form the hook
// renders (its token is in ctx.chrome.csrfToken) has the matching double-submit cookie.
csrfMint.setCookie();
await sendResult(res, short.result, (view, data) => renderView(short.plugin.id, view, data));
await sendResult(res, short.result, pluginViewsFor(ctx, short.plugin.id), carryLocale);
return;
}
}
@@ -245,19 +280,19 @@ export function createApp(options: AppOptions = {}): Server {
// CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname);
if (match) {
const routeCtx = buildContext(req, res, { chrome, user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
const routeCtx = buildContext(req, res, { chrome, user, ...i18nFor(match.plugin.id), log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
if (!isAuthorized(match.route, routeCtx.permissions)) {
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
// return_to; a signed-in user who simply lacks the permission gets the 403 page.
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
sendHtml(res, 403, await renderPage("403", {}));
return;
}
csrfMint.setCookie();
const result = (await match.route.handler(routeCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, routeCtx, result); // observers; a throw → 500
await sendResult(res, result, (view, data) => renderView(match.plugin.id, view, data));
await sendResult(res, result, pluginViewsFor(routeCtx, match.plugin.id), carryLocale);
return;
}
@@ -266,7 +301,7 @@ export function createApp(options: AppOptions = {}): Server {
// null means the handler wrote to ctx.res itself.
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
if (builtin) {
await sendResult(res, await builtin.handler(ctx, csrfMint), render);
await sendResult(res, await builtin.handler(ctx, csrfMint), viewsFor(ctx), carryLocale);
return;
}
@@ -276,21 +311,21 @@ export function createApp(options: AppOptions = {}): Server {
res.writeHead(405, { allow: allow.join(", "), "content-type": "text/plain; charset=utf-8" }).end("Method Not Allowed");
return;
}
sendHtml(res, 404, await render("404", { title: "Not found" }));
sendHtml(res, 404, await renderPage("404", {}));
} catch (err) {
// A guard thrown anywhere in handling maps to a response (not a 500): a `location` ⇒ a
// redirect (requireSession → /login), otherwise the status renders the error page.
if (err instanceof GuardError) {
if (res.headersSent) return void res.end();
if (err.location) return void res.writeHead(303, { location: err.location }).end();
return void sendHtml(res, err.status, await render("403", { title: "Forbidden" }));
return void sendHtml(res, err.status, await renderPage("403", {}));
}
reqLog.error("unhandled request error", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) });
if (res.headersSent) return void res.end(); // a partial body is already on the wire
try {
// Render before writing: if the 500 page itself throws, headers stay unsent
// and we fall back to plain text below instead of a half-written response.
sendHtml(res, 500, await render("500", { title: "Server error" }));
sendHtml(res, 500, await renderPage("500", {}));
} catch (renderErr) {
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Internal Server Error");
@@ -337,10 +372,12 @@ type ViewRenderer = (view: string, data: Record<string, unknown>) => Promise<str
// Turn a handler's RouteResult into the HTTP response. `null` = the handler took over `ctx.res`
// itself (the void escape hatch). Author `headers` override the content-type default.
async function sendResult(res: ServerResponse, result: RouteResult | null, renderView: ViewRenderer): Promise<void> {
async function sendResult(res: ServerResponse, result: RouteResult | null, renderView: ViewRenderer, carryLocale: (href: string) => string = (href) => href): Promise<void> {
if (result == null || res.writableEnded) return;
if ("redirect" in result) {
res.writeHead(result.status ?? 303, { location: result.redirect }).end();
// A redirect to one of our own pages keeps the visitor's chosen locale (a POST→redirect→GET
// would otherwise drop it); an off-site target is left exactly as the handler wrote it.
res.writeHead(result.status ?? 303, { location: carryLocale(result.redirect) }).end();
return;
}
if ("json" in result) {
+22
View File
@@ -1,6 +1,9 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
import { DEFAULT_LOCALE } from "../i18n/catalog.ts";
import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts";
import { createLogger, type Log } from "../logger.ts";
// The request context threaded to every route handler (plugin + built-in), built once
@@ -23,6 +26,13 @@ export interface RequestContext {
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
locale: string; // the locale this request is served in, e.g. "sv-SE" — also <html lang>
// Carry the visitor's chosen locale onto a link this page renders. A no-op unless the request
// asked for one with ?locale (there is no locale cookie — the URL is where the choice lives), and
// on off-site URLs. The host already does this for the chrome and its own redirects; a plugin
// wraps the hrefs it builds itself.
localeHref(href: string): string;
locales: string[]; // every installed locale, sorted — for a plugin building its own language picker
log: Log;
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
@@ -32,6 +42,10 @@ export interface RequestContext {
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
// unless the host wired them; every field optional. Ordinary domain plugins ignore it.
system?: SystemCapabilities;
// Translate a key in this request's locale: `ctx.t("shifts.title")`, `ctx.t("greeting", { name })`.
// Returns raw text — escape it like any other value when rendering. An unknown key renders as
// itself, so a plain string is always safe to pass.
t: Translate;
url: URL;
user: User | null; // the signed-in user, or null when anonymous
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
@@ -45,9 +59,13 @@ export interface BuildContextOptions {
// The host's factory is memoised, so the menu composes at most once per request across contexts.
chrome?: () => PageChrome;
user?: User | null;
locale?: string;
localeHref?: (href: string) => string;
locales?: string[];
log?: Log;
params?: Record<string, string>;
system?: SystemCapabilities;
t?: Translate;
verifyCsrf?: (submitted: string | null | undefined) => boolean;
}
@@ -69,6 +87,9 @@ export function buildContext(
return {
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
user,
locale: options.locale ?? DEFAULT_LOCALE,
localeHref: options.localeHref ?? ((href) => href),
locales: options.locales ?? [DEFAULT_LOCALE],
log: options.log ?? SILENT_LOG,
params: options.params ?? {},
query: url.searchParams,
@@ -76,6 +97,7 @@ export function buildContext(
res,
permissions: user?.permissions ?? [],
...(options.system ? { system: options.system } : {}),
t: options.t ?? ENGLISH,
url,
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
};
+57
View File
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { type Catalog, checkCatalog, isCatalog } from "./catalog.ts";
const baseline: Catalog = { greeting: "Hello", "shifts.count": { one: "{{count}} shift", other: "{{count}} shifts" } };
const parity = (locale: string, catalog: Catalog): string[] =>
checkCatalog({ baseline, baselineLocale: "en-US", catalog, locale });
test("a complete translation reports nothing", () => {
assert.deepEqual(parity("sv-SE", { greeting: "Hej", "shifts.count": { one: "{{count}} pass", other: "{{count}} pass" } }), []);
});
test("a missing or unknown key is reported", () => {
const missing = parity("sv-SE", { "shifts.count": { one: "a", other: "b" } });
assert.equal(missing.length, 1);
assert.match(missing[0] ?? "", /missing key "greeting"/);
const extra = parity("sv-SE", { ...baseline, stray: "x" });
assert.equal(extra.length, 1);
assert.match(extra[0] ?? "", /unknown key "stray".*en-US/);
});
test("a key must stay the same kind as in the baseline", () => {
const flat = parity("sv-SE", { greeting: "Hej", "shifts.count": "pass" });
assert.equal(flat.length, 1);
assert.match(flat[0] ?? "", /"shifts.count" must be a plural message/);
const plural = parity("sv-SE", { greeting: { one: "Hej", other: "Hej" }, "shifts.count": { one: "a", other: "b" } });
assert.equal(plural.length, 1);
assert.match(plural[0] ?? "", /"greeting" must be a string/);
});
test("a plural message must cover exactly its own locale's categories", () => {
const short = parity("cs-CZ", { greeting: "Ahoj", "shifts.count": { one: "a", other: "b" } });
assert.equal(short.length, 1);
assert.match(short[0] ?? "", /"shifts\.count".*cs-CZ.*few, many/);
const long = parity("sv-SE", { greeting: "Hej", "shifts.count": { few: "x", one: "a", other: "b" } });
assert.equal(long.length, 1);
assert.match(long[0] ?? "", /"shifts\.count".*few/);
});
test("the baseline is checked against itself, so an incomplete plural fails at home too", () => {
assert.deepEqual(checkCatalog({ baseline, baselineLocale: "en-US", catalog: baseline, locale: "en-US" }), []);
const bad: Catalog = { greeting: "Hello", "shifts.count": { one: "{{count}} shift" } };
assert.match(checkCatalog({ baseline: bad, baselineLocale: "en-US", catalog: bad, locale: "en-US" })[0] ?? "", /other/);
});
test("isCatalog accepts strings and plural objects, rejects anything else", () => {
assert.equal(isCatalog({ a: "x", b: { other: "y" } }), true);
assert.equal(isCatalog({ a: 1 }), false);
assert.equal(isCatalog({ a: { other: 1 } }), false);
assert.equal(isCatalog({ a: {} }), false); // an empty plural message says nothing
assert.equal(isCatalog({ a: { bogus: "x" } }), false); // not a plural category
assert.equal(isCatalog(null), false);
assert.equal(isCatalog([]), false);
});
+77
View File
@@ -0,0 +1,77 @@
// What a translation catalog is, and the boot-time parity rules that keep every locale
// in step with its en-US baseline. Pure: `load.ts` reads the files, this decides whether they are
// sound. A plural message carries exactly the categories its own locale needs (Intl.PluralRules),
// so a translator can't ship half a plural and a Czech catalog isn't held to English's two forms.
export type PluralMessage = Partial<Record<Intl.LDMLPluralRule, string>>;
export type Message = PluralMessage | string;
export type Catalog = Record<string, Message>;
// The baseline every catalog set is checked against, and the locale served when a request matches
// nothing. A core catalog for it must exist — the host refuses to boot otherwise.
export const DEFAULT_LOCALE = "en-US";
const CATEGORIES: ReadonlySet<string> = new Set(["few", "many", "one", "other", "two", "zero"]);
export function isPluralMessage(value: Message): value is PluralMessage {
return typeof value !== "string";
}
// Shape guard for an imported catalog module — a mounted plugin's file is untyped at runtime.
export function isCatalog(value: unknown): value is Catalog {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
return Object.values(value).every((message) => {
if (typeof message === "string") return true;
if (typeof message !== "object" || message === null || Array.isArray(message)) return false;
const forms = Object.entries(message);
return forms.length > 0 && forms.every(([category, text]) => CATEGORIES.has(category) && typeof text === "string");
});
}
export interface ParityInput {
baseline: Catalog;
baselineLocale: string;
catalog: Catalog;
locale: string;
}
// Every problem with `catalog` relative to `baseline`, as ready-to-print lines. Empty ⇒ sound.
// Run the baseline against itself too: that is what validates its own plural completeness.
export function checkCatalog({ baseline, baselineLocale, catalog, locale }: ParityInput): string[] {
const problems: string[] = [];
const categories = pluralCategories(locale);
for (const [key, expected] of Object.entries(baseline)) {
const actual = catalog[key];
if (actual === undefined) {
problems.push(`missing key "${key}"`);
continue;
}
if (isPluralMessage(expected) !== isPluralMessage(actual)) {
problems.push(`"${key}" must be a ${isPluralMessage(expected) ? "plural message" : "string"}, like ${baselineLocale}`);
continue;
}
if (!isPluralMessage(actual)) continue;
const forms = new Set(Object.keys(actual));
const missing = categories.filter((category) => !forms.has(category));
const selected = new Set<string>(categories);
const unknown = [...forms].filter((category) => !selected.has(category)).sort();
if (missing.length) problems.push(`"${key}" is missing the ${locale} plural forms: ${missing.join(", ")}`);
if (unknown.length) problems.push(`"${key}" has plural forms ${locale} never selects: ${unknown.join(", ")}`);
}
for (const key of Object.keys(catalog)) {
if (!(key in baseline)) problems.push(`unknown key "${key}" — add it to ${baselineLocale} first`);
}
return problems;
}
// The plural categories a locale actually selects, sorted; unknown tags fall back to English's.
export function pluralCategories(locale: string): Intl.LDMLPluralRule[] {
try {
return [...new Intl.PluralRules(locale).resolvedOptions().pluralCategories].sort();
} catch {
return ["one", "other"];
}
}
+17
View File
@@ -0,0 +1,17 @@
// The shipped en-US catalog, ready to use without loading anything from disk. This is what the
// host falls back to wherever the loaded catalogs haven't been wired — a context built ad hoc, a
// view model built outside a request, an app created without `i18n` — so an unwired path renders
// real English rather than bare keys. server.ts replaces it with the discovered catalogs at boot.
import { DEFAULT_LOCALE } from "./catalog.ts";
import enUS from "./locales/en-US.ts";
import { createI18n, type I18n } from "./runtime.ts";
import { createTranslator, type Translate } from "./translate.ts";
export const ENGLISH: Translate = createTranslator({ catalogs: [enUS], locale: DEFAULT_LOCALE });
export const ENGLISH_I18N: I18n = createI18n({
available: [DEFAULT_LOCALE],
core: new Map([[DEFAULT_LOCALE, enUS]]),
plugins: new Map(),
});
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { loadI18n } from "./load.ts";
const catalog = (body: string): string => `const messages = ${body};\nexport default messages;\n`;
// A throwaway host tree: <root>/locales/*.ts and <root>/plugins/<id>/i18n/*.ts.
async function fixture(files: Record<string, string>): Promise<{ localesDir: string; pluginsDir: string }> {
const root = await mkdtemp(join(tmpdir(), "i18n-"));
for (const [path, body] of Object.entries(files)) {
const file = join(root, path);
await mkdir(join(file, ".."), { recursive: true });
await writeFile(file, body);
}
return { localesDir: join(root, "locales"), pluginsDir: join(root, "plugins") };
}
test("the shipped core catalogs load and agree key for key", async () => {
const loaded = await loadI18n(); // no args ⇒ the real src/i18n/locales + plugins/
assert.ok(loaded.available.includes("en-US"));
assert.ok(loaded.available.includes("sv-SE"));
assert.deepEqual([...loaded.available].sort(), loaded.available); // sorted, so "sv" resolves deterministically
assert.ok(Object.keys(loaded.core.get("en-US") ?? {}).length > 20);
});
test("a plugin's catalogs load under its id and may cover fewer locales than the host", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
"locales/sv-SE.ts": catalog(`{ hello: "Hej" }`),
"plugins/shop/i18n/en-US.ts": catalog(`{ "shop.title": "Shop" }`),
});
const loaded = await loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir });
assert.deepEqual(loaded.available, ["en-US", "sv-SE"]);
assert.deepEqual(loaded.plugins.get("shop")?.get("en-US"), { "shop.title": "Shop" });
assert.equal(loaded.plugins.get("shop")?.has("sv-SE"), false);
});
test("a locale that disagrees with the en-US baseline stops the boot", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello", bye: "Bye" }`),
"locales/sv-SE.ts": catalog(`{ hello: "Hej", hej: "Hej" }`),
});
await assert.rejects(loadI18n({ localesDir, pluginsDir }), (err: Error) => {
assert.match(err.message, /sv-SE/);
assert.match(err.message, /missing key "bye"/);
assert.match(err.message, /unknown key "hej"/);
return true;
});
});
test("the en-US baseline itself must exist", async () => {
const { localesDir, pluginsDir } = await fixture({ "locales/sv-SE.ts": catalog(`{ hello: "Hej" }`) });
await assert.rejects(loadI18n({ localesDir, pluginsDir }), /en-US\.ts/);
});
test("a file in locales/ that is not a locale is an error, never silently skipped", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
"locales/swedish.ts": catalog(`{ hello: "Hej" }`),
});
await assert.rejects(loadI18n({ localesDir, pluginsDir }), /swedish\.ts/);
});
test("a catalog that is not a catalog is an error", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: 42 }`),
});
await assert.rejects(loadI18n({ localesDir, pluginsDir }), /en-US/);
});
test("a plugin locale the host does not have is an error", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
"plugins/shop/i18n/en-US.ts": catalog(`{ "shop.title": "Shop" }`),
"plugins/shop/i18n/fr-FR.ts": catalog(`{ "shop.title": "Boutique" }`),
});
await assert.rejects(loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir }), /fr-FR/);
});
test("a plugin translation is checked against the plugin's own en-US", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
"locales/sv-SE.ts": catalog(`{ hello: "Hej" }`),
"plugins/shop/i18n/en-US.ts": catalog(`{ "shop.title": "Shop" }`),
"plugins/shop/i18n/sv-SE.ts": catalog(`{ "shop.name": "Butik" }`),
});
await assert.rejects(loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir }), /shop.*sv-SE|sv-SE.*shop/s);
});
test("a plugin with translations but no en-US baseline is an error", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
"locales/sv-SE.ts": catalog(`{ hello: "Hej" }`),
"plugins/shop/i18n/sv-SE.ts": catalog(`{ "shop.title": "Butik" }`),
});
await assert.rejects(loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir }), /shop/);
});
test("a plugin without an i18n folder is fine", async () => {
const { localesDir, pluginsDir } = await fixture({ "locales/en-US.ts": catalog(`{ hello: "Hello" }`) });
const loaded = await loadI18n({ localesDir, pluginIds: ["plain"], pluginsDir });
assert.equal(loaded.plugins.size, 0);
});
+98
View File
@@ -0,0 +1,98 @@
// Catalog discovery: import src/i18n/locales/<tag>.ts and plugins/<id>/i18n/<tag>.ts, then
// check every one against its set's en-US baseline. The imperative shell over catalog.ts's pure
// rules — the same contract as plugin discovery: one boot-stopping Error listing every problem,
// so a half-translated deploy is caught at startup rather than as a stray English word in production.
//
// Installed locales are whatever the core folder holds; a plugin may translate fewer of them (its
// strings then render in en-US on that page) but never one the host does not have.
import { existsSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { checkCatalog, DEFAULT_LOCALE, isCatalog, type Catalog } from "./catalog.ts";
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
export const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales");
// A catalog file is named for the full locale it holds — sv-SE.ts, never sv.ts. Anything else in
// the folder is a mistake worth stopping for.
const LOCALE_FILE = /^([a-z]{2,3}-[A-Z]{2})\.ts$/;
export interface LoadI18nOptions {
localesDir?: string;
pluginIds?: string[]; // discovered plugins; their i18n/ folders are loaded under their id
pluginsDir?: string;
}
export interface LoadedI18n {
available: string[]; // installed locales, sorted — the switcher's list, and "sv" resolution order
core: Map<string, Catalog>;
plugins: Map<string, Map<string, Catalog>>; // plugin id → locale → catalog
}
export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18n> {
const localesDir = options.localesDir ?? LOCALES_DIR;
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
const errors: string[] = [];
const core = await readSet(localesDir, "core", errors);
if (!core.has(DEFAULT_LOCALE)) errors.push(`core: no ${DEFAULT_LOCALE}.ts — it is the baseline every other locale is checked against`);
checkSet(core, "core", errors);
const available = [...core.keys()].sort();
const plugins = new Map<string, Map<string, Catalog>>();
for (const id of options.pluginIds ?? []) {
const dir = join(pluginsDir, id, "i18n");
if (!existsSync(dir)) continue;
const set = await readSet(dir, `plugins/${id}`, errors);
if (set.size === 0) continue;
if (!set.has(DEFAULT_LOCALE)) errors.push(`plugins/${id}: no ${DEFAULT_LOCALE}.ts — a plugin's own baseline`);
for (const locale of set.keys()) {
if (!available.includes(locale)) errors.push(`plugins/${id}: ${locale} is not installed — add src/i18n/locales/${locale}.ts first`);
}
checkSet(set, `plugins/${id}`, errors);
plugins.set(id, set);
}
if (errors.length) throw new Error(`Translation catalogs failed to load:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
return { available, core, plugins };
}
// Import every catalog in one folder. A stray file, a failed import or a value that is not a
// catalog is collected as an error — never skipped, or the locale would just go quietly missing.
async function readSet(dir: string, label: string, errors: string[]): Promise<Map<string, Catalog>> {
const set = new Map<string, Catalog>();
if (!existsSync(dir)) return set;
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
if (entry.isDirectory() || entry.name.startsWith(".")) continue;
const locale = LOCALE_FILE.exec(entry.name)?.[1];
if (locale === undefined) {
errors.push(`${label}: "${entry.name}" is not a locale catalog — name it <language>-<REGION>.ts (e.g. sv-SE.ts)`);
continue;
}
let mod: { default?: unknown };
try {
mod = (await import(pathToFileURL(join(dir, entry.name)).href)) as { default?: unknown };
} catch (err) {
errors.push(`${label}: ${entry.name} failed to import — ${err instanceof Error ? err.message : String(err)}`);
continue;
}
if (!isCatalog(mod.default)) {
errors.push(`${label}: ${entry.name} must default-export an object of strings (or plural forms)`);
continue;
}
set.set(locale, mod.default);
}
return set;
}
function checkSet(set: Map<string, Catalog>, label: string, errors: string[]): void {
const baseline = set.get(DEFAULT_LOCALE);
if (baseline === undefined) return; // already reported; nothing to compare against
for (const [locale, catalog] of set) {
for (const problem of checkCatalog({ baseline, baselineLocale: DEFAULT_LOCALE, catalog, locale })) {
errors.push(`${label} ${locale}: ${problem}`);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { localeHref, localeLabel, matchLocale, parseAcceptLanguage, resolveLocale, textDirection } from "./locale.ts";
const available = ["en-US", "sv-FI", "sv-SE"];
test("parseAcceptLanguage orders tags by q, dropping wildcards and junk", () => {
assert.deepEqual(parseAcceptLanguage("sv-SE,sv;q=0.9,en-US;q=0.8"), ["sv-SE", "sv", "en-US"]);
assert.deepEqual(parseAcceptLanguage("en;q=0.2, sv;q=0.9, de"), ["de", "sv", "en"]); // no q ⇒ 1.0
assert.deepEqual(parseAcceptLanguage("*, sv;q=0.5"), ["sv"]);
assert.deepEqual(parseAcceptLanguage(""), []);
assert.deepEqual(parseAcceptLanguage(undefined), []);
});
test("matchLocale takes an exact tag, case-insensitively", () => {
assert.equal(matchLocale("sv-SE", available), "sv-SE");
assert.equal(matchLocale("SV-se", available), "sv-SE");
});
test("matchLocale never substitutes another region", () => {
assert.equal(matchLocale("sv-NO", available), null); // sv-SE exists, but the request asked for Norway
assert.equal(matchLocale("de-DE", available), null);
});
test("matchLocale resolves a lone language to the first matching regional catalog", () => {
assert.equal(matchLocale("sv", available), "sv-FI"); // alphabetically first of sv-FI / sv-SE
assert.equal(matchLocale("sv", ["en-US", "sv-SE"]), "sv-SE");
assert.equal(matchLocale("sv", ["sv-SE", "sv-FI"]), "sv-FI"); // input order must not matter
assert.equal(matchLocale("en", available), "en-US");
});
test("matchLocale rejects malformed input instead of guessing", () => {
for (const bad of ["", "!!", "sv_SE", "e", "../../etc", undefined, null]) {
assert.equal(matchLocale(bad, available), null, `expected null for ${JSON.stringify(bad)}`);
}
});
test("resolveLocale: ?locale wins over Accept-Language", () => {
const got = resolveLocale({ acceptLanguage: "en-US", available, param: "sv-SE" });
assert.deepEqual(got, { explicit: true, locale: "sv-SE" });
});
test("resolveLocale: an unmatched ?locale falls through to Accept-Language", () => {
const got = resolveLocale({ acceptLanguage: "de-DE;q=0.9, sv;q=0.8", available, param: "es-ES" });
assert.deepEqual(got, { explicit: false, locale: "sv-FI" });
});
test("resolveLocale: nothing matches ⇒ en-US, and no request carried a locale", () => {
assert.deepEqual(resolveLocale({ available, param: null }), { explicit: false, locale: "en-US" });
assert.deepEqual(resolveLocale({ acceptLanguage: "de-DE", available, param: "" }), { explicit: false, locale: "en-US" });
});
test("localeHref carries the locale on host-relative links only", () => {
assert.equal(localeHref("/admin/users", "sv-SE"), "/admin/users?locale=sv-SE");
assert.equal(localeHref("/admin/users?q=a", "sv-SE"), "/admin/users?q=a&locale=sv-SE");
assert.equal(localeHref("/admin/users?locale=en-US", "sv-SE"), "/admin/users?locale=sv-SE"); // replaced, never doubled
assert.equal(localeHref("/docs#top", "sv-SE"), "/docs?locale=sv-SE#top");
assert.equal(localeHref("/admin/users", null), "/admin/users"); // no explicit locale ⇒ untouched
assert.equal(localeHref("https://example.com/x", "sv-SE"), "https://example.com/x"); // off-site
assert.equal(localeHref("//example.com/x", "sv-SE"), "//example.com/x"); // protocol-relative is off-site too
assert.equal(localeHref("", "sv-SE"), "");
});
test("textDirection reads the script direction, defaulting to ltr", () => {
assert.equal(textDirection("en-US"), "ltr");
assert.equal(textDirection("sv-SE"), "ltr");
assert.equal(textDirection("ar-EG"), "rtl");
assert.equal(textDirection("not a locale"), "ltr");
});
test("localeLabel names a locale in its own language", () => {
assert.match(localeLabel("sv-SE"), /svenska/i);
assert.equal(localeLabel("not a locale"), "not a locale"); // fail soft: the tag itself
});
+118
View File
@@ -0,0 +1,118 @@
// Which language a request is served in, and how a chosen one travels.
//
// Precedence: `?locale=sv-SE` → Accept-Language (by q) → en-US. Matching is exact on a full tag —
// asking for sv-FI when only sv-SE is installed lands on en-US rather than 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. `localeHref` is how the choice survives a click.
import { DEFAULT_LOCALE } from "./catalog.ts";
// Accept-Language tags, best first. Wildcards and malformed entries are dropped, not guessed at.
export function parseAcceptLanguage(header: string | undefined): string[] {
if (!header) return [];
return header
.split(",")
.map((part, index) => {
const [tag = "", ...params] = part.trim().split(";");
const q = params.map((p) => /^\s*q=([0-9.]+)\s*$/.exec(p)).find((m) => m !== null);
return { index, q: q ? Number(q[1]) : 1, tag: tag.trim() };
})
.filter((entry) => /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i.test(entry.tag) && Number.isFinite(entry.q))
.sort((a, b) => b.q - a.q || a.index - b.index)
.map((entry) => entry.tag);
}
// The installed locale a request for `requested` should be served in, or null when none fits.
export function matchLocale(requested: string | null | undefined, available: string[]): string | null {
const canonical = canonicalize(requested);
if (canonical === null) return null;
const exact = available.find((tag) => tag.toLowerCase() === canonical.toLowerCase());
if (exact !== undefined) return exact;
if (canonical.includes("-")) return null; // a region was asked for; another region is a different locale
const language = `${canonical.toLowerCase()}-`;
return [...available].sort().find((tag) => tag.toLowerCase().startsWith(language)) ?? null;
}
export interface ResolveInput {
acceptLanguage?: string | undefined;
available: string[];
param?: string | null | undefined; // the ?locale query value
}
export interface ResolvedLocale {
explicit: boolean; // the URL asked for this locale — the host then carries it on the links it renders
locale: string;
}
export function resolveLocale({ acceptLanguage, available, param }: ResolveInput): ResolvedLocale {
const asked = matchLocale(param, available);
if (asked !== null) return { explicit: true, locale: asked };
for (const tag of parseAcceptLanguage(acceptLanguage)) {
const matched = matchLocale(tag, available);
if (matched !== null) return { explicit: false, locale: matched };
}
return { explicit: false, locale: DEFAULT_LOCALE };
}
// Carry `locale` on a host-relative link. Off-site and protocol-relative URLs are left alone — the
// locale is ours to state, not theirs. `locale` null (the visitor never asked for one) ⇒ unchanged.
export function localeHref(href: string, locale: string | null): string {
if (locale === null || href === "" || !href.startsWith("/") || href.startsWith("//")) return href;
const url = new URL(href, "http://localhost");
url.searchParams.set("locale", locale);
return `${url.pathname}${url.search}${url.hash}`;
}
// Both are asked for on every render (the <html> tag, the language picker) but depend only on the
// tag, so each locale pays the ICU lookup once per process.
const directions = new Map<string, "ltr" | "rtl">();
const labels = new Map<string, string>();
interface TextInfoLocale {
getTextInfo?: () => { direction?: string };
textInfo?: { direction?: string };
}
// The document direction for <html dir>. Derived from the locale's script, so an RTL catalog flips
// the document the day it is added.
export function textDirection(locale: string): "ltr" | "rtl" {
const cached = directions.get(locale);
if (cached !== undefined) return cached;
const direction = readDirection(locale);
directions.set(locale, direction);
return direction;
}
function readDirection(locale: string): "ltr" | "rtl" {
try {
const info = new Intl.Locale(locale) as Intl.Locale & TextInfoLocale;
const direction = info.getTextInfo?.().direction ?? info.textInfo?.direction;
return direction === "rtl" ? "rtl" : "ltr";
} catch {
return "ltr";
}
}
// A locale named in its own language ("svenska (Sverige)") — what a language picker should show.
export function localeLabel(locale: string): string {
const cached = labels.get(locale);
if (cached !== undefined) return cached;
let label: string;
try {
label = new Intl.DisplayNames([locale], { type: "language" }).of(locale) ?? locale;
} catch {
label = locale;
}
labels.set(locale, label);
return label;
}
function canonicalize(tag: string | null | undefined): string | null {
if (typeof tag !== "string" || tag === "") return null;
try {
return Intl.getCanonicalLocales(tag)[0] ?? null;
} catch {
return null;
}
}
+171
View File
@@ -0,0 +1,171 @@
// The core catalog: every string the host itself renders, and the baseline every other locale
// is checked against at boot (its keys and its plural/string kinds are the contract). Add a key
// here first, then to each sv-SE.ts et al — a locale that drifts stops the boot.
//
// Values are raw text; views escape them. A value carrying markup is rendered with <%- %> and must
// never interpolate untrusted data (see README → Translating).
const messages = {
"auth.continue": "Continue",
// Kratos labels its own form fields; these translate the ones the built-in identity schema uses,
// keyed on the input name. A deployment's extra traits keep Kratos' label until a plugin covers them.
"auth.field.email": "Email",
"auth.field.identifier": "Email",
"auth.field.password": "Password",
"auth.field.traits.email": "Email",
"auth.forgotPassword": "Forgot password?",
"auth.login.altLabel": "Create one",
"auth.login.altText": "Don't have an account?",
"auth.login.sub": "Welcome back. Enter your details to continue.",
"auth.login.title": "Sign in",
"auth.recovery.altLabel": "Sign in",
"auth.recovery.altText": "Remembered it?",
"auth.recovery.back": "Back to sign in",
"auth.recovery.sub": "Enter your email and we'll send you a recovery code.",
"auth.recovery.title": "Reset password",
"auth.registration.altLabel": "Sign in",
"auth.registration.altText": "Already have an account?",
"auth.registration.sub": "Get started — it only takes a minute.",
"auth.registration.title": "Create account",
"auth.settings.sub": "Update your account details.",
"auth.settings.title": "Account settings",
"auth.sso.divider": "or",
"auth.sso.label": "Single sign-on options",
"auth.verification.back": "Back to sign in",
"auth.verification.sub": "Enter the code we sent you.",
"auth.verification.title": "Verify your email",
"brand.sub": "Console",
"consent.allow": "Allow",
"consent.deny": "Deny",
"consent.notYou": "Not you?",
"consent.scope.email": "Your email address",
"consent.scope.offline_access": "Stay signed in (offline access)",
"consent.scope.openid": "Verify your identity",
"consent.scope.profile": "Your basic profile (name)",
"consent.signedInAs": "Signed in as",
"consent.sub": "{{client}} wants access to your account.",
"consent.title": "Authorize {{client}}",
"dashboard.starter.browse": "Browse the example plugin",
"dashboard.starter.intro":
"This is the built-in <code>/dashboard</code> — the gated home shown to a signed-in user. It's a placeholder so a fresh clone has something here; it holds no real data.",
"dashboard.starter.reference":
"See the plugin contract in <code>docs/plugin-contract.md</code> (the landing-pages section) and the bundled <code>plugins/scheduling/</code> reference.",
"dashboard.starter.replace":
"Replace it from a plugin: export a <code>dashboard</code> handler from your plugin's manifest and it owns this page, rendered against your own views with the native app shell (the same menu you see now) via <code>ctx.chrome</code>.",
"dashboard.starter.title": "Starter dashboard",
"dashboard.title": "Dashboard",
"error.403.body": "You don't have permission to view that (403).",
"error.403.docTitle": "Forbidden",
"error.403.title": "Access denied",
"error.404.body": "We couldn't find that page (404).",
"error.404.docTitle": "Not found",
"error.404.title": "Page not found",
"error.500.body": "An unexpected error occurred on our end (500).",
"error.500.docTitle": "Server error",
"error.500.title": "Something went wrong",
"error.503.body": "We can't reach the identity service right now (503). Please try again in a moment.",
"error.503.docTitle": "Sign-in unavailable",
"error.503.title": "Sign-in is temporarily unavailable",
"error.backHome": "Back home",
"error.backToSignIn": "Back to sign in",
"error.flow.body": "We couldn't complete that sign-in step. It may have expired or been opened twice — please try again.",
"error.flow.docTitle": "Sign-in error",
"error.flow.title": "Something went wrong",
"error.reference": "Reference: {{id}}",
"error.tryAgain": "Try again",
"field.optional": "Optional",
"filter.applied": "Applied",
"filter.appliedFilters": "Applied filters",
"filter.apply": "Apply filters",
"filter.clearAll": "Clear all",
"filter.dateRange": "Date range",
"filter.from": "From",
"filter.label": "Filter",
"filter.remove": "Remove {{label}} filter",
"filter.reset": "Reset",
"filter.search": "Search",
"filter.to": "To",
"filter.toSeparator": "to",
// Kratos writes the auth flow's own text and returns it with a stable numeric id. A key here
// replaces that text; anything unmapped renders Kratos' English as-is (README → Translating).
// Ids not in this list are deliberate: 1070002 is Kratos' generic identity-trait label — it is
// "Email" on the login form and "First name" on a registration form with that trait, so it can
// only be translated per field (auth.field.* above), never per id.
"kratos.1010022": "Sign in with password",
"kratos.1040001": "Sign up",
"kratos.1060003":
"An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.",
"kratos.1070008": "Resend code",
"kratos.1070009": "Continue",
"kratos.1070010": "Recovery code",
"kratos.1070011": "Verification code",
"kratos.1080003":
"An email containing a verification code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.",
"kratos.4000002": "This field is required.",
"kratos.4000006": "The credentials are invalid. Check for typos in your email address or password.",
"kratos.4000007": "An account with that email address already exists.",
"kratos.4060006": "That recovery code is invalid or has already been used. Please try again.",
"kratos.4070006": "That verification code is invalid or has already been used. Please try again.",
"landing.dashboard": "Go to your dashboard",
"landing.lead":
"{{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.",
"landing.register": "Create account",
"landing.signIn": "Log in",
"landing.title": "Operational web apps, without the boilerplate.",
"locale.label": "Language",
"nav.dashboard": "Dashboard",
"oauth.consentExpired": "This authorization request has expired. Please start again from the application you were signing in to.",
"oauth.loginExpired": "This sign-in request has expired. Please start again from the application you were signing in to.",
"oauth.logoutExpired": "This logout request has expired. Please start again from the application you were signing out of.",
"pagination.go": "Go",
"pagination.label": "Pagination",
"pagination.next": "Next page",
"pagination.of": "of",
"pagination.previous": "Previous page",
"pagination.rows": "Rows",
"shell.breadcrumb": "Breadcrumb",
"shell.closeMenu": "Close menu",
"shell.guest": "Guest",
"shell.mainNav": "Main navigation",
"shell.openMenu": "Open menu",
"shell.preferences": "Preferences",
"shell.profile": "Profile",
"shell.settings": "Settings",
"shell.sidebar": "Primary",
"shell.signedInAs": "Signed in as {{name}}",
"shell.signIn": "Sign in",
"shell.signOut": "Sign out",
"shell.skipToContent": "Skip to content",
"shell.toggleSection": "Toggle {{label}}",
"table.actions": "Actions",
"table.empty": "Nothing here yet.",
"table.row": "row",
"table.rowActions": "Row actions for {{name}}",
"table.select": "Select {{name}}",
"table.selectAll": "Select all rows",
"theme.auto": "Auto",
"theme.dark": "Dark",
"theme.label": "Color theme",
"theme.light": "Light",
};
// The shape every other core locale is written against: `const messages: CoreMessages = { … }` in
// sv-SE.ts et al, so a missing or misspelled key is a type error before the boot check ever runs.
export type CoreMessages = typeof messages;
export default messages;
+155
View File
@@ -0,0 +1,155 @@
import type { CoreMessages } from "./en-US.ts";
const messages: CoreMessages = {
"auth.continue": "Fortsätt",
"auth.field.email": "E-postadress",
"auth.field.identifier": "E-postadress",
"auth.field.password": "Lösenord",
"auth.field.traits.email": "E-postadress",
"auth.forgotPassword": "Glömt lösenordet?",
"auth.login.altLabel": "Skapa ett",
"auth.login.altText": "Har du inget konto?",
"auth.login.sub": "Välkommen tillbaka. Fyll i dina uppgifter för att fortsätta.",
"auth.login.title": "Logga in",
"auth.recovery.altLabel": "Logga in",
"auth.recovery.altText": "Kom du på det?",
"auth.recovery.back": "Tillbaka till inloggningen",
"auth.recovery.sub": "Ange din e-postadress så skickar vi en återställningskod.",
"auth.recovery.title": "Återställ lösenord",
"auth.registration.altLabel": "Logga in",
"auth.registration.altText": "Har du redan ett konto?",
"auth.registration.sub": "Kom igång — det tar bara en minut.",
"auth.registration.title": "Skapa konto",
"auth.settings.sub": "Uppdatera dina kontouppgifter.",
"auth.settings.title": "Kontoinställningar",
"auth.sso.divider": "eller",
"auth.sso.label": "Alternativ för enkel inloggning",
"auth.verification.back": "Tillbaka till inloggningen",
"auth.verification.sub": "Ange koden vi skickade till dig.",
"auth.verification.title": "Verifiera din e-postadress",
"brand.sub": "Konsol",
"consent.allow": "Tillåt",
"consent.deny": "Neka",
"consent.notYou": "Inte du?",
"consent.scope.email": "Din e-postadress",
"consent.scope.offline_access": "Håll dig inloggad (offlineåtkomst)",
"consent.scope.openid": "Verifiera din identitet",
"consent.scope.profile": "Din grundläggande profil (namn)",
"consent.signedInAs": "Inloggad som",
"consent.sub": "{{client}} vill få åtkomst till ditt konto.",
"consent.title": "Godkänn {{client}}",
"dashboard.starter.browse": "Utforska exempelpluginet",
"dashboard.starter.intro":
"Detta är den inbyggda <code>/dashboard</code> — den inloggade startsidan. Den är en platshållare så att en färsk klon har något här; den innehåller inga riktiga data.",
"dashboard.starter.reference":
"Se plugin-kontraktet i <code>docs/plugin-contract.md</code> (avsnittet om startsidorna) och referensen <code>plugins/scheduling/</code>.",
"dashboard.starter.replace":
"Ersätt den från ett plugin: exportera en <code>dashboard</code>-hanterare från pluginets manifest så äger det den här sidan, renderad mot dina egna vyer med appens eget skal (samma meny du ser nu) via <code>ctx.chrome</code>.",
"dashboard.starter.title": "Startpanel",
"dashboard.title": "Panel",
"error.403.body": "Du har inte behörighet att se det här (403).",
"error.403.docTitle": "Åtkomst nekad",
"error.403.title": "Åtkomst nekad",
"error.404.body": "Vi hittade inte sidan (404).",
"error.404.docTitle": "Sidan finns inte",
"error.404.title": "Sidan hittades inte",
"error.500.body": "Ett oväntat fel uppstod hos oss (500).",
"error.500.docTitle": "Serverfel",
"error.500.title": "Något gick fel",
"error.503.body": "Vi når inte identitetstjänsten just nu (503). Försök igen om en liten stund.",
"error.503.docTitle": "Inloggning otillgänglig",
"error.503.title": "Inloggningen är tillfälligt otillgänglig",
"error.backHome": "Tillbaka till startsidan",
"error.backToSignIn": "Tillbaka till inloggningen",
"error.flow.body": "Vi kunde inte slutföra det inloggningssteget. Det kan ha gått ut eller öppnats två gånger — försök igen.",
"error.flow.docTitle": "Inloggningsfel",
"error.flow.title": "Något gick fel",
"error.reference": "Referens: {{id}}",
"error.tryAgain": "Försök igen",
"field.optional": "Frivilligt",
"filter.applied": "Aktiva",
"filter.appliedFilters": "Aktiva filter",
"filter.apply": "Använd filter",
"filter.clearAll": "Rensa alla",
"filter.dateRange": "Datumintervall",
"filter.from": "Från",
"filter.label": "Filter",
"filter.remove": "Ta bort filtret {{label}}",
"filter.reset": "Återställ",
"filter.search": "Sök",
"filter.to": "Till",
"filter.toSeparator": "till",
"kratos.1010022": "Logga in med lösenord",
"kratos.1040001": "Skapa konto",
"kratos.1060003":
"Ett mejl med en återställningskod har skickats till adressen du angav. Har du inte fått något mejl, kontrollera stavningen och att du använder adressen du registrerade dig med.",
"kratos.1070008": "Skicka koden igen",
"kratos.1070009": "Fortsätt",
"kratos.1070010": "Återställningskod",
"kratos.1070011": "Verifieringskod",
"kratos.1080003":
"Ett mejl med en verifieringskod har skickats till adressen du angav. Har du inte fått något mejl, kontrollera stavningen och att du använder adressen du registrerade dig med.",
"kratos.4000002": "Fältet är obligatoriskt.",
"kratos.4000006": "Uppgifterna stämmer inte. Kontrollera att e-postadressen och lösenordet är rätt stavade.",
"kratos.4000007": "Det finns redan ett konto med den e-postadressen.",
"kratos.4060006": "Återställningskoden är ogiltig eller redan använd. Försök igen.",
"kratos.4070006": "Verifieringskoden är ogiltig eller redan använd. Försök igen.",
"landing.dashboard": "Gå till din panel",
"landing.lead":
"{{brand}} är en självhostad grund för administrativa och operativa gränssnitt — inloggning, en konfigurationsstyrd meny och ett serverrenderat designsystem utan JavaScript. Du lägger till de verksamhetsnära skärmarna genom att släppa in plugin-mappar.",
"landing.register": "Skapa konto",
"landing.signIn": "Logga in",
"landing.title": "Operativa webbappar, utan all pannplåt.",
"locale.label": "Språk",
"nav.dashboard": "Panel",
"oauth.consentExpired": "Den här behörighetsbegäran har gått ut. Börja om från appen du skulle logga in i.",
"oauth.loginExpired": "Den här inloggningsbegäran har gått ut. Börja om från appen du skulle logga in i.",
"oauth.logoutExpired": "Den här utloggningsbegäran har gått ut. Börja om från appen du skulle logga ut från.",
"pagination.go": "Visa",
"pagination.label": "Sidnavigering",
"pagination.next": "Nästa sida",
"pagination.of": "av",
"pagination.previous": "Föregående sida",
"pagination.rows": "Rader",
"shell.breadcrumb": "Sidsökväg",
"shell.closeMenu": "Stäng menyn",
"shell.guest": "Gäst",
"shell.mainNav": "Huvudmeny",
"shell.openMenu": "Öppna menyn",
"shell.preferences": "Inställningar",
"shell.profile": "Profil",
"shell.settings": "Inställningar",
"shell.sidebar": "Primär",
"shell.signedInAs": "Inloggad som {{name}}",
"shell.signIn": "Logga in",
"shell.signOut": "Logga ut",
"shell.skipToContent": "Hoppa till innehållet",
"shell.toggleSection": "Visa eller dölj {{label}}",
"table.actions": "Åtgärder",
"table.empty": "Inget här ännu.",
"table.row": "raden",
"table.rowActions": "Radåtgärder för {{name}}",
"table.select": "Markera {{name}}",
"table.selectAll": "Markera alla rader",
"theme.auto": "Auto",
"theme.dark": "Mörkt",
"theme.label": "Färgtema",
"theme.light": "Ljust",
};
export default messages;
+40
View File
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Catalog } from "./catalog.ts";
import { createI18n } from "./runtime.ts";
const core = new Map<string, Catalog>([
["en-US", { "shell.signOut": "Sign out", "shop.title": "Core" }],
["sv-SE", { "shell.signOut": "Logga ut", "shop.title": "Kärna" }],
]);
const plugins = new Map<string, Map<string, Catalog>>([
["shop", new Map<string, Catalog>([["en-US", { "shop.new": "New order", "shop.title": "Shop" }], ["sv-SE", { "shop.new": "Ny order", "shop.title": "Butik" }]])],
["thin", new Map<string, Catalog>([["en-US", { "thin.title": "Thin" }]])],
]);
const i18n = createI18n({ available: ["en-US", "sv-SE"], core, plugins });
test("resolve applies the request precedence over the installed locales", () => {
assert.deepEqual(i18n.resolve({ param: "sv-SE" }), { explicit: true, locale: "sv-SE" });
assert.deepEqual(i18n.resolve({ acceptLanguage: "sv,en;q=0.5" }), { explicit: false, locale: "sv-SE" });
assert.deepEqual(i18n.resolve({}), { explicit: false, locale: "en-US" });
});
test("a plugin's own translation wins over the core one", () => {
assert.equal(i18n.translator("sv-SE", "shop")("shop.title"), "Butik");
assert.equal(i18n.translator("sv-SE")("shop.title"), "Kärna");
});
test("a plugin key untranslated in this locale falls back to the plugin's en-US, not to core", () => {
assert.equal(i18n.translator("sv-SE", "thin")("thin.title"), "Thin");
assert.equal(i18n.translator("sv-SE", "thin")("shell.signOut"), "Logga ut"); // core still speaks Swedish
});
test("an unknown plugin or locale still translates what it can", () => {
assert.equal(i18n.translator("sv-SE", "nope")("shell.signOut"), "Logga ut");
assert.equal(i18n.translator("de-DE")("shell.signOut"), "Sign out"); // uninstalled locale ⇒ the baseline
});
test("translators are memoised per locale and plugin", () => {
assert.equal(i18n.translator("sv-SE", "shop"), i18n.translator("sv-SE", "shop"));
assert.notEqual(i18n.translator("sv-SE", "shop"), i18n.translator("sv-SE"));
});
Binary file not shown.
+71
View File
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Catalog } from "./catalog.ts";
import { createTranslator } from "./translate.ts";
const core: Catalog = {
"greeting": "Hello, {{name}}!",
"shell.signOut": "Sign out",
"shifts.count": { one: "{{count}} shift", other: "{{count}} shifts" },
};
const coreSv: Catalog = {
"greeting": "Hej, {{name}}!",
"shell.signOut": "Logga ut",
"shifts.count": { one: "{{count}} pass", other: "{{count}} pass" },
};
test("a key resolves from the first catalog that has it", () => {
const t = createTranslator({ catalogs: [coreSv, core], locale: "sv-SE" });
assert.equal(t("shell.signOut"), "Logga ut");
});
test("a key missing from the active locale falls back down the chain", () => {
const t = createTranslator({ catalogs: [{ "shell.signOut": "Logga ut" }, core], locale: "sv-SE" });
assert.equal(t("greeting", { name: "Li" }), "Hello, Li!");
});
test("a plugin catalog wins over the core one", () => {
const plugin: Catalog = { "shell.signOut": "Leave" };
const t = createTranslator({ catalogs: [plugin, core], locale: "en-US" });
assert.equal(t("shell.signOut"), "Leave");
});
test("a key missing everywhere renders as itself", () => {
const t = createTranslator({ catalogs: [core], locale: "en-US" });
assert.equal(t("nope.at.all"), "nope.at.all");
assert.equal(t("Shifts"), "Shifts"); // the nav-label contract: a plain label is its own fallback
});
test("{{vars}} interpolate; an unsupplied one stays visible", () => {
const t = createTranslator({ catalogs: [{ both: "{{a}} and {{b}}", n: "n={{n}}" }], locale: "en-US" });
assert.equal(t("both", { a: "x", b: "y" }), "x and y");
assert.equal(t("both", { a: "x" }), "x and {{b}}");
assert.equal(t("n", { n: 3 }), "n=3");
});
test("t returns raw text — escaping is the view's job", () => {
const t = createTranslator({ catalogs: [{ hi: "Hi {{name}}" }], locale: "en-US" });
assert.equal(t("hi", { name: "<b>ok</b>" }), "Hi <b>ok</b>");
});
test("plural messages select on count via Intl.PluralRules", () => {
const t = createTranslator({ catalogs: [core], locale: "en-US" });
assert.equal(t("shifts.count", { count: 1 }), "1 shift");
assert.equal(t("shifts.count", { count: 0 }), "0 shifts");
assert.equal(t("shifts.count", { count: 7 }), "7 shifts");
});
test("plural selection follows the active locale's own categories", () => {
const cs: Catalog = { files: { few: "{{count}} soubory", many: "{{count}} souboru", one: "{{count}} soubor", other: "{{count}} souborů" } };
const t = createTranslator({ catalogs: [cs], locale: "cs-CZ" });
assert.equal(t("files", { count: 1 }), "1 soubor");
assert.equal(t("files", { count: 3 }), "3 soubory");
assert.equal(t("files", { count: 10 }), "10 souborů");
});
test("a plural message without a count, or without the selected category, falls back to other", () => {
const t = createTranslator({ catalogs: [core], locale: "en-US" });
assert.equal(t("shifts.count"), "{{count}} shifts");
const partial = createTranslator({ catalogs: [{ x: { other: "many" } }], locale: "en-US" });
assert.equal(partial("x", { count: 1 }), "many");
});
+62
View File
@@ -0,0 +1,62 @@
// The translator: a key + vars → the string to render. Pure and synchronous — views call it
// as `t("shell.signOut")` and handlers as `ctx.t(...)`.
//
// Two rules the rest of the app leans on:
// · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US) and,
// when nothing has the key, returns the key itself. That is what makes a plain nav label like
// "Shifts" its own fallback — a manifest needs no catalog to keep working.
// · the result is raw text. Views escape with <%= %> exactly as they do for any other value, so a
// translation is never double-escaped, and a message that carries markup is rendered with <%- %>.
import { isPluralMessage, type Catalog, type PluralMessage } from "./catalog.ts";
export type TranslateVars = Record<string, number | string>;
export type Translate = (key: string, vars?: TranslateVars) => string;
export interface TranslatorOptions {
catalogs: Catalog[]; // lookup order, most specific first
locale: string;
}
const PLACEHOLDER = /\{\{(\w+)\}\}/g;
const pluralRules = new Map<string, Intl.PluralRules>();
export function createTranslator({ catalogs, locale }: TranslatorOptions): Translate {
return (key, vars) => {
for (const catalog of catalogs) {
const message = catalog[key];
if (message === undefined) continue;
return interpolate(isPluralMessage(message) ? selectPlural(message, locale, vars?.["count"]) : message, vars);
}
return key;
};
}
// The form for `count` in this locale, falling back to `other` (and then to any form present, so a
// half-filled catalog still renders words rather than a blank).
function selectPlural(message: PluralMessage, locale: string, count: number | string | undefined): string {
const category = count === undefined ? "other" : rulesFor(locale).select(Number(count));
return message[category] ?? message.other ?? Object.values(message)[0] ?? "";
}
function rulesFor(locale: string): Intl.PluralRules {
let rules = pluralRules.get(locale);
if (!rules) {
try {
rules = new Intl.PluralRules(locale);
} catch {
rules = new Intl.PluralRules("en-US");
}
pluralRules.set(locale, rules);
}
return rules;
}
// An unsupplied {{var}} is left standing: a visible placeholder beats a silent blank.
function interpolate(text: string, vars: TranslateVars | undefined): string {
if (vars === undefined) return text;
return text.replace(PLACEHOLDER, (whole, name: string) => {
const value = vars[name];
return value === undefined ? whole : String(value);
});
}
+48
View File
@@ -0,0 +1,48 @@
// The i18n block every rendered view receives. EJS passes a template's locals down into its
// includes, so injecting this at the top level is what lets any partial — core or plugin — call
// `t(...)` and read `locale` without its caller threading them through.
import { DEFAULT_LOCALE } from "./catalog.ts";
import { ENGLISH } from "./english.ts";
import type { RequestContext } from "../http/context.ts";
import { localeHref, localeLabel, textDirection } from "./locale.ts";
import type { Translate } from "./translate.ts";
export interface LocaleChoice {
current: boolean;
href: string; // this same page in that locale
label: string; // the locale named in its own language
tag: string;
}
export interface I18nLocals {
dir: "ltr" | "rtl";
locale: string;
localeHref: (href: string) => string;
localeSwitch: LocaleChoice[];
locales: string[];
t: Translate;
}
// For a render with no request behind it — a partial exercised directly, a one-off render: English,
// left-to-right, no language picker.
export const ENGLISH_LOCALS: I18nLocals = {
dir: "ltr",
locale: DEFAULT_LOCALE,
localeHref: (href) => href,
localeSwitch: [],
locales: [DEFAULT_LOCALE],
t: ENGLISH,
};
export function i18nLocals(ctx: RequestContext): I18nLocals {
const here = `${ctx.url.pathname}${ctx.url.search}`;
return {
dir: textDirection(ctx.locale),
locale: ctx.locale,
localeHref: (href) => ctx.localeHref(href),
localeSwitch: ctx.locales.map((tag) => ({ current: tag === ctx.locale, href: localeHref(here, tag), label: localeLabel(tag), tag })),
locales: ctx.locales,
t: ctx.t,
};
}
+6
View File
@@ -10,6 +10,12 @@ export type { RequestContext, User } from "../http/context.ts";
export type { PageChrome } from "../ui/chrome.ts";
export type { NavNode } from "../ui/nav.ts";
export { can, check, GuardError, requireSession } from "../auth/guards.ts";
// Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for
// authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator
// in a unit test. `PluralMessage` types a message that varies with a count.
export { createTranslator } from "../i18n/translate.ts";
export type { Translate, TranslateVars } from "../i18n/translate.ts";
export type { Catalog, PluralMessage } from "../i18n/catalog.ts";
export { parseListQuery } from "../ui/list-query.ts";
export { paginate } from "../ui/paginate.ts";
export type { PageModel } from "../ui/paginate.ts";
+2 -1
View File
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { test, type TestContext } from "node:test";
import { fileURLToPath } from "node:url";
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
import { renderPluginView, resolveViewPath } from "./view-resolver.ts";
const coreViewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
@@ -30,7 +31,7 @@ test("renderPluginView: a (nested) view includes a core building-block partial a
);
const render = renderPluginView({ cache: false, coreViewsDir, pluginsDir });
const html = await render("demo", "sub/page", { who: "Plug" });
const html = await render("demo", "sub/page", { ...ENGLISH_LOCALS, who: "Plug" }); // the host merges these into every view's data
assert.match(html, /role="radiogroup"/); // core partial, resolved from coreViewsDir
assert.match(html, /<span class=local>Plug<\/span>/); // the plugin's own partial, with data
});
+8
View File
@@ -5,6 +5,8 @@ import { discoverPlugins } from "./plugin-host/discovery.ts";
import { withTimeout } from "./auth/fetch-timeout.ts";
import { runBootHooks } from "./plugin-host/hooks.ts";
import { createHydraAdmin } from "./auth/hydra-admin.ts";
import { createI18n } from "./i18n/runtime.ts";
import { loadI18n } from "./i18n/load.ts";
import { createJwksProvider } from "./auth/jwks.ts";
import { createKetoClient } from "./auth/keto-client.ts";
import { createKratosAdmin } from "./auth/kratos-admin.ts";
@@ -38,6 +40,11 @@ const plugins = await discoverPlugins(); // scans plugins/, validates — fails
log.info("plugins discovered", { count: plugins.length, ids: plugins.map((p) => p.id).join(", ") });
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
// Translation catalogs: the core locales plus each discovered plugin's — fails loud if a locale
// drifts from its en-US baseline, so a half-translated deploy never reaches a visitor.
const i18n = createI18n(await loadI18n({ pluginIds: plugins.map((p) => p.id) }));
log.info("locales loaded", { locales: i18n.available.join(", ") });
const server = createApp({
// Canonical-host redirect target (off-host GET/HEAD visitors are sent here). Opt-in: omitted unless
// APP_URL is set, so the redirect is fully off — and costs nothing — when unconfigured.
@@ -47,6 +54,7 @@ const server = createApp({
csrfSecret: config.csrfSecret,
...(denylist ? { denylist } : {}),
hydra,
i18n,
jwks,
keto,
kratos,
+2 -1
View File
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import ejs from "ejs";
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
const authCard = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "auth-card.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(authCard, data);
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(authCard, { ...ENGLISH_LOCALS, ...data });
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
test("auth-card renders head, SSO providers (text logo + icon link), body slot and alt footer", async () => {
+40 -9
View File
@@ -6,14 +6,17 @@
// current-marked for the request path.
import type { User } from "../http/context.ts";
import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts";
import { type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import { shellUser, type ShellUser } from "./shell-context.ts";
// The "Dashboard" link to the gated app home (/dashboard). It targets a gated route, so it's shown
// only to a signed-in user (an anonymous click would only dead-end at /login).
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" };
// only to a signed-in user (an anonymous click would only dead-end at /login). Its label is a
// catalog key — composeNav translates every label, and an unknown one renders as written.
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard" };
export interface PageChrome {
brand: { logo?: string; name: string; sub?: string };
@@ -27,39 +30,67 @@ export interface PageChrome {
export interface ChromeOptions {
csrfToken?: string;
currentPath?: string; // request pathname; the matching nav leaf is marked current
localeHref?: (href: string) => string; // carries an explicitly chosen locale onto every chrome link
menu: MenuConfig;
plugins?: Plugin[];
t?: Translate; // the core translator: the built-in nodes, the central override's labels, branding
translatorFor?: (pluginId: string) => Translate; // a plugin's own translator, for its nav fragment
user?: User | null;
}
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
const t = opts.t ?? ENGLISH;
const carryLocale = opts.localeHref ?? ((href: string) => href);
// The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an
// anonymous visitor (a public page in the shell) it would only dead-end at /login. The admin
// section, when present, is just another plugin's nav fragment (examples/plugins/admin).
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
// translator before they are merged. composeNav then runs the core one over the result for the
// built-in nodes and the central override's labels; already-translated text passes through it.
for (const p of opts.plugins ?? []) {
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
}
const permissions = opts.user?.permissions ?? [];
const nav = composeNav(fragments, opts.menu.override, permissions);
const nav = composeNav(fragments, opts.menu.override, permissions, t);
if (opts.currentPath) {
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
// Marked before the locale rides along, so an href still matches the plain request path.
const target = bestHref(nav, opts.currentPath);
if (target) markCurrent(nav, target);
}
const b = opts.menu.branding;
// The sign-in link keeps the visitor's locale, and brings it back afterwards via return_to.
const returnTo = opts.currentPath ? `/login?return_to=${encodeURIComponent(carryLocale(opts.currentPath))}` : "/login";
return {
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: b.sub } : {}) },
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: t(b.name), ...(b.sub != null ? { sub: t(b.sub) } : {}) },
csrfToken: opts.csrfToken ?? "",
nav,
// Anonymous "Sign in" returns to the current page (it's host-relative, our own pathname).
signInHref: opts.currentPath ? `/login?return_to=${encodeURIComponent(opts.currentPath)}` : "/login",
nav: carryLocaleInto(nav, carryLocale),
signInHref: carryLocale(returnTo),
...(b.theme != null ? { theme: b.theme } : {}),
user: shellUser(opts.user),
user: shellUser(opts.user, t),
};
}
function translateNav(nodes: NavNode[], t: Translate): NavNode[] {
return nodes.map((node) => ({
...node,
label: t(node.label),
...(node.children ? { children: translateNav(node.children, t) } : {}),
}));
}
function carryLocaleInto(nodes: NavNode[], carryLocale: (href: string) => string): NavNode[] {
return nodes.map((node) => ({
...node,
...(node.href != null ? { href: carryLocale(node.href) } : {}),
...(node.children ? { children: carryLocaleInto(node.children, carryLocale) } : {}),
}));
}
// The href of the leaf that owns `path`: an exact match, else the longest href that is a parent of
// it (href + "/" prefixes path), so /admin/users/123 resolves to the /admin/users leaf. "/" never
// counts as a parent (it would own everything). Returns undefined when nothing matches.
+7 -3
View File
@@ -5,18 +5,22 @@
// once per request by the host, so the dashboard shows the exact same menu as every other page.
import type { User } from "../http/context.ts";
import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { buildShellContext } from "./shell-context.ts";
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; user?: User | null } = {}) {
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; t?: Translate; user?: User | null } = {}) {
const t = opts.t ?? ENGLISH;
return {
nav: opts.nav ?? [],
shell: buildShellContext({
breadcrumbs: [{ label: "Dashboard" }],
breadcrumbs: [{ label: t("dashboard.title") }],
csrfToken: opts.csrfToken ?? "",
menu: opts.menu ?? DEFAULT_MENU,
title: "Dashboard",
t,
title: t("dashboard.title"),
user: opts.user ?? null,
}),
};
+2 -1
View File
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import ejs from "ejs";
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
const dataTable = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "data-table.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(dataTable, data);
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(dataTable, { ...ENGLISH_LOCALS, ...data });
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
const config = {
+2 -1
View File
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import ejs from "ejs";
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
const field = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "field.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(field, data);
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(field, { ...ENGLISH_LOCALS, ...data });
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
test("field renders label, icon input, hint, inline link/optional, and a server-driven error", async () => {
+2 -1
View File
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import ejs from "ejs";
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
const filterBar = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "filter-bar.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(filterBar, data);
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(filterBar, { ...ENGLISH_LOCALS, ...data });
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
const config = {
+1 -1
View File
@@ -26,7 +26,7 @@ test("loadMenuConfig reads branding + override, merging branding over defaults",
const menu = await loadMenuConfig({ file });
assert.equal(menu.branding.name, "Acme Ops");
assert.equal(menu.branding.sub, "Console"); // default kept (only `name`/`theme` overridden)
assert.equal(menu.branding.sub, "brand.sub"); // default kept (only `name`/`theme` overridden); chrome translates it
assert.equal(menu.branding.theme, "dark");
assert.deepEqual(menu.override.hide, ["teams"]);
assert.deepEqual(menu.override.rename, { people: "Staff" });
+3 -1
View File
@@ -29,7 +29,9 @@ export interface MenuConfigInput {
override?: NavOverride;
}
export const DEFAULT_BRANDING: Branding = { name: "Plainpages", sub: "Console" };
// The shipped default. `sub` is a catalog key so a clean clone reads in the visitor's language;
// an operator's own text in config/menu.ts renders as written (chrome runs both through t()).
export const DEFAULT_BRANDING: Branding = { name: "Plainpages", sub: "brand.sub" };
export const DEFAULT_MENU: MenuConfig = { branding: DEFAULT_BRANDING, override: {} };
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
+2 -1
View File
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import ejs from "ejs";
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
const navTree = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "nav-tree.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(navTree, data);
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(navTree, { ...ENGLISH_LOCALS, ...data });
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
const nodes = [
+9 -5
View File
@@ -6,6 +6,8 @@
// the override (+ branding); this helper only transforms data, so its result is per-deployment
// up to the final permission filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
import type { Translate } from "../i18n/translate.ts";
export interface NavNode {
id?: string; // stable key for override targeting; stripped from the rendered tree
children?: NavNode[];
@@ -40,13 +42,14 @@ export function composeNav(
fragments: NavNode[][] = [],
override: NavOverride = {},
permissions: string[] = [],
t: Translate = (key) => key,
): NavNode[] {
let nodes: NavNode[] = fragments.flat();
if (override.rename) nodes = renameTree(nodes, override.rename);
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
if (override.order?.length) nodes = applyOrder(nodes, override.order);
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
return filterByRoles(nodes, new Set(permissions)).map(toRenderNode);
return filterByRoles(nodes, new Set(permissions)).map((node) => toRenderNode(node, t));
}
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
@@ -116,14 +119,15 @@ function filterByRoles(nodes: NavNode[], permissions: Set<string>): NavNode[] {
}
// Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly
// what nav-tree.ejs reads.
function toRenderNode(n: NavNode): NavNode {
const out: NavNode = { label: n.label };
// what nav-tree.ejs reads. Labels (a manifest's, or the central override's rename) pass through
// `t` on the way out: a label that names a catalog key is translated, any other renders as written.
function toRenderNode(n: NavNode, t: Translate): NavNode {
const out: NavNode = { label: t(n.label) };
if (n.icon != null) out.icon = n.icon;
if (n.href != null) out.href = n.href;
if (n.count != null) out.count = n.count;
if (n.current != null) out.current = n.current;
if (n.open != null) out.open = n.open;
if (n.children && n.children.length) out.children = n.children.map(toRenderNode);
if (n.children && n.children.length) out.children = n.children.map((child) => toRenderNode(child, t));
return out;
}
+2 -1
View File
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import ejs from "ejs";
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
const pagination = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "pagination.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(pagination, data);
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(pagination, { ...ENGLISH_LOCALS, ...data });
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
const config = {
+11 -4
View File
@@ -6,6 +6,8 @@
// the local part; anonymous ⇒ "Guest".
import type { User } from "../http/context.ts";
import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts";
import { type MenuConfig } from "./menu-config.ts";
export interface ShellUser {
@@ -24,8 +26,11 @@ export interface ShellModel {
user: ShellUser;
}
export function shellUser(user: User | null | undefined): ShellUser {
if (!user) return { email: "", initials: "G", name: "Guest" };
export function shellUser(user: User | null | undefined, t: Translate = ENGLISH): ShellUser {
if (!user) {
const guest = t("shell.guest");
return { email: "", initials: guest.slice(0, 1).toUpperCase(), name: guest };
}
const local = user.email.split("@")[0] || user.email;
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
}
@@ -35,17 +40,19 @@ export function buildShellContext(opts: {
csrfToken?: string;
menu: MenuConfig;
signInHref?: string;
t?: Translate;
title: string;
user?: User | null;
}): ShellModel {
const b = opts.menu.branding;
const t = opts.t ?? ENGLISH;
return {
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: b.sub } : {}) },
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: t(b.name), ...(b.sub != null ? { sub: t(b.sub) } : {}) },
...(opts.breadcrumbs ? { breadcrumbs: opts.breadcrumbs } : {}),
csrfToken: opts.csrfToken ?? "",
...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}),
...(b.theme != null ? { theme: b.theme } : {}),
title: opts.title,
user: shellUser(opts.user),
user: shellUser(opts.user, t),
};
}
+2 -1
View File
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import ejs from "ejs";
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
const shell = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "shell.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, data);
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, { ...ENGLISH_LOCALS, ...data });
test("app shell renders sidebar, topbar and the content slot", async () => {
const html = await render({
+2 -1
View File
@@ -3,9 +3,10 @@ import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import ejs from "ejs";
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
const themeSwitch = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "theme-switch.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(themeSwitch, data);
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(themeSwitch, { ...ENGLISH_LOCALS, ...data });
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
test("theme switch renders the Light/Auto/Dark radiogroup with CSS-coupled ids", async () => {
+1 -1
View File
@@ -16,7 +16,7 @@
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.)
- [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.)
- [x] The human developer understands the security model in the auth in this project. (Two README sections. [Users, groups & permissions](README.md#users-groups--permissions) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `permission:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.)
- [ ] Add i18n support.
- [x] Add i18n support. (Catalogs are TS modules per locale — `src/i18n/locales/<tag>.ts` for the host, `plugins/<id>/i18n/<tag>.ts` for a plugin, looked up plugin-first then core; en-US + sv-SE ship. A request is served by `?locale=sv-SE``Accept-Language``en-US`, exact on a full tag but a lone language takes the first regional catalog; no cookie — when the URL asked, the host carries `?locale` onto the links it renders and `ctx.localeHref()` does it for a plugin's. `ctx.t(key, vars)` plus `t`/`locale`/`locales`/`localeHref`/`dir` merged into every view (any include depth); `{{var}}` interpolation, plurals via `Intl.PluralRules`, an unknown key renders as itself — which is what makes a nav label either a key or plain text. Every catalog is checked against its set's en-US at boot (keys, kind, plural categories) and a mismatch stops startup. Kratos' own flow text is mapped by its numeric id (only ids verified against the live stack; its generic trait-label id is deliberately unmapped, field labels key on the input name instead). Zero-JS language picker in the shell + the auth/consent pages, `<html lang dir>` from the locale. Core, both example plugins and their views translated; unit tests + `e2e-tests/language.spec.ts` in the visual gate; documented in README → Languages, decisions in AGENTS.md.)
- [x] Settle the identity-vs-user vocabulary. (Plainpages says **user** everywhere — Keto namespace `User`, subjects `user:<kratos-id>`, `ctx.user`. Ory calls the record an "identity", but its own docs say it uses that term interchangeably with "users"/"accounts", so this is house style rather than a renamed concept, and "user" is the word readers know (Nielsen heuristic #2). README → Auth carries one note recording the mapping; the only place Ory's spelling survives is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors the Kratos wire shape. Recorded in AGENTS.md.)
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule (`utils`, `helpers`, `misc`) applied to namespaces. Raised 2026-08-03; a design question, not a naming one.
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer.
+9 -6
View File
@@ -1,16 +1,19 @@
<!doctype html>
<html lang="en">
<%#
403 error page: standalone (no app shell, no menu) so it renders even when the shell's data
is what failed. Locals: t/locale/dir come from the host with every render.
%><!doctype html>
<html lang="<%= locale %>" dir="<%= dir %>">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %></title>
<title><%= t("error.403.docTitle") %></title>
<link rel="stylesheet" href="/public/css/styles.css" />
</head>
<body>
<main>
<h1>Access denied</h1>
<p>You don't have permission to view that (403).</p>
<p><a href="/">Back home</a></p>
<h1><%= t("error.403.title") %></h1>
<p><%= t("error.403.body") %></p>
<p><a href="<%= localeHref("/") %>"><%= t("error.backHome") %></a></p>
</main>
</body>
</html>
+9 -6
View File
@@ -1,16 +1,19 @@
<!doctype html>
<html lang="en">
<%#
404 error page: standalone (no app shell, no menu) so it renders even when the shell's data
is what failed. Locals: t/locale/dir come from the host with every render.
%><!doctype html>
<html lang="<%= locale %>" dir="<%= dir %>">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %></title>
<title><%= t("error.404.docTitle") %></title>
<link rel="stylesheet" href="/public/css/styles.css" />
</head>
<body>
<main>
<h1>Page not found</h1>
<p>We couldn't find that page (404).</p>
<p><a href="/">Back home</a></p>
<h1><%= t("error.404.title") %></h1>
<p><%= t("error.404.body") %></p>
<p><a href="<%= localeHref("/") %>"><%= t("error.backHome") %></a></p>
</main>
</body>
</html>
+9 -6
View File
@@ -1,16 +1,19 @@
<!doctype html>
<html lang="en">
<%#
500 error page: standalone (no app shell, no menu) so it renders even when the shell's data
is what failed. Locals: t/locale/dir come from the host with every render.
%><!doctype html>
<html lang="<%= locale %>" dir="<%= dir %>">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %></title>
<title><%= t("error.500.docTitle") %></title>
<link rel="stylesheet" href="/public/css/styles.css" />
</head>
<body>
<main>
<h1>Something went wrong</h1>
<p>An unexpected error occurred on our end (500).</p>
<p><a href="/">Back home</a></p>
<h1><%= t("error.500.title") %></h1>
<p><%= t("error.500.body") %></p>
<p><a href="<%= localeHref("/") %>"><%= t("error.backHome") %></a></p>
</main>
</body>
</html>
+9 -6
View File
@@ -1,16 +1,19 @@
<!doctype html>
<html lang="en">
<%#
503 error page: standalone (no app shell, no menu) so it renders even when the shell's data
is what failed. Locals: t/locale/dir come from the host with every render.
%><!doctype html>
<html lang="<%= locale %>" dir="<%= dir %>">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %></title>
<title><%= t("error.503.docTitle") %></title>
<link rel="stylesheet" href="/public/css/styles.css" />
</head>
<body>
<main>
<h1>Sign-in is temporarily unavailable</h1>
<p>We can't reach the identity service right now (503). Please try again in a moment.</p>
<p><a href="/login">Try again</a></p>
<h1><%= t("error.503.title") %></h1>
<p><%= t("error.503.body") %></p>
<p><a href="<%= localeHref("/login") %>"><%= t("error.tryAgain") %></a></p>
</main>
</body>
</html>
+10 -7
View File
@@ -1,17 +1,20 @@
<!doctype html>
<html lang="en">
<%#
Kratos' self-service error sink (?id=<uuid>): a themed dead end with a way back into sign-in.
Standalone, like the other error pages. Locals: id? · t/locale/dir from the host.
%><!doctype html>
<html lang="<%= locale %>" dir="<%= dir %>">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %></title>
<title><%= t("error.flow.docTitle") %></title>
<link rel="stylesheet" href="/public/css/styles.css" />
</head>
<body>
<main>
<h1>Something went wrong</h1>
<p>We couldn't complete that sign-in step. It may have expired or been opened twice — please try again.</p>
<p><a href="/login">Back to sign in</a></p>
<% if (locals.id) { %><p><small>Reference: <%= id %></small></p><% } %>
<h1><%= t("error.flow.title") %></h1>
<p><%= t("error.flow.body") %></p>
<p><a href="<%= localeHref("/login") %>"><%= t("error.backToSignIn") %></a></p>
<% if (locals.id) { %><p><small><%= t("error.reference", { id }) %></small></p><% } %>
</main>
</body>
</html>
+5 -9
View File
@@ -8,21 +8,17 @@
const body = `
<div class="form-page">
<section class="form-card">
<h2 class="card-title">Starter dashboard</h2>
<p>This is the built-in <code>/dashboard</code> — the gated home shown to a signed-in user.
It's a placeholder so a fresh clone has something here; it holds no real data.</p>
<p>Replace it from a plugin: export a <code>dashboard</code> handler from your plugin's
manifest and it owns this page, rendered against your own views with the native app shell
(the same menu you see now) via <code>ctx.chrome</code>.</p>
<h2 class="card-title">${t("dashboard.starter.title")}</h2>
<p>${t("dashboard.starter.intro")}</p>
<p>${t("dashboard.starter.replace")}</p>
<pre class="code-block"><code>export default definePlugin({
apiVersion: "1.0.0",
// view names plugins/&lt;id&gt;/views/&lt;view&gt;.ejs, rendered in this same shell
dashboard: (ctx) =&gt; ({ view: "dashboard", data: { /* … */ } }),
});</code></pre>
<p>See the plugin contract in <code>docs/plugin-contract.md</code> (the landing-pages section)
and the bundled <code>plugins/scheduling/</code> reference.</p>
<p>${t("dashboard.starter.reference")}</p>
<div class="form-actions">
<a class="btn btn-primary" href="/scheduling"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-grid"/></svg>Browse the example plugin</a>
<a class="btn btn-primary" href="${localeHref("/scheduling")}"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-grid"/></svg>${t("dashboard.starter.browse")}</a>
</div>
</section>
</div>`;
+6 -5
View File
@@ -6,11 +6,11 @@
const brand = locals.brand || "Plainpages";
const body = include("partials/consent-body", { account: consent.account, challenge: consent.challenge, csrfField, csrfToken, scopes: consent.scopes });
%><!doctype html>
<html lang="en">
<html lang="<%= locale %>" dir="<%= dir %>">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Authorize <%= consent.client %></title>
<title><%= t("consent.title", { client: consent.client }) %></title>
<link rel="stylesheet" href="/public/css/styles.css" />
<link rel="stylesheet" href="/public/css/auth.css" />
<link rel="icon" href="/public/favicon.svg" />
@@ -20,6 +20,7 @@
<main class="auth-stage">
<div class="auth">
<div class="auth-brand">
<%- include("partials/locale-switch", { up: false }) %>
<span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span>
<span class="brand-name"><%= brand %></span>
</div>
@@ -27,13 +28,13 @@
action: "/oauth2/consent",
body,
method: "post",
sub: `${consent.client} wants access to your account.`,
title: `Authorize ${consent.client}`,
sub: t("consent.sub", { client: consent.client }),
title: t("consent.title", { client: consent.client }),
}) %>
<% if (consent.account) { %>
<form class="auth-alt" method="post" action="/logout">
<input type="hidden" name="<%= csrfField %>" value="<%= csrfToken %>">
Not you? <button type="submit">Sign out</button>
<%= t("consent.notYou") %> <button type="submit"><%= t("shell.signOut") %></button>
</form>
<% } %>
</div>
+2 -2
View File
@@ -20,9 +20,9 @@
<form class="auth-card" method="<%= method %>"<% if (locals.action) { %> action="<%= locals.action %>"<% } %>>
<div class="auth-head"><% if (back) { %><a class="auth-back" href="<%= back.href %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"/></svg><%= back.label %></a><% } %><h1><%= locals.title %></h1><% if (locals.sub) { %><p class="auth-sub"><%= locals.sub %></p><% } %></div>
<% if (providers.length) { -%>
<div class="sso" aria-label="<%= sso.label || "Single sign-on options" %>">
<div class="sso" aria-label="<%= sso.label || t("auth.sso.label") %>">
<ul class="sso-list"><% providers.forEach((p) => { %><li><% if (p.href) { %><a class="sso-btn" href="<%= p.href %>"><% } else { %><button type="<%= p.name ? "submit" : "button" %>" class="sso-btn"<% if (p.name) { %> name="<%= p.name %>" value="<%= p.value %>" formnovalidate<% } %>><% } %><span class="sso-logo" aria-hidden="true"><% if (p.icon) { %><svg class="ico ico-sm"><use href="#<%= p.icon %>"/></svg><% } else { %><%= p.logo %><% } %></span><span class="sso-label"><%= p.label %></span><% if (p.href) { %></a><% } else { %></button><% } %></li><% }) %></ul>
<div class="auth-divider"><%= sso.divider || "or" %></div>
<div class="auth-divider"><%= sso.divider || t("auth.sso.divider") %></div>
</div>
<% } -%>
<div class="auth-form"><%- locals.body || "" %></div>
+4 -4
View File
@@ -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) { -%>
<p class="auth-sub">Signed in as <strong><%= account %></strong></p>
<p class="auth-sub"><%= t("consent.signedInAs") %> <strong><%= account %></strong></p>
<% } -%>
<input type="hidden" name="<%= csrfField %>" value="<%= csrfToken %>">
<input type="hidden" name="consent_challenge" value="<%= challenge %>">
@@ -17,5 +17,5 @@
<% }) -%>
</ul>
<% } -%>
<button type="submit" class="btn btn-block btn-primary" name="decision" value="allow">Allow</button>
<button type="submit" class="btn btn-block" name="decision" value="deny">Deny</button>
<button type="submit" class="btn btn-block btn-primary" name="decision" value="allow"><%= t("consent.allow") %></button>
<button type="submit" class="btn btn-block" name="decision" value="deny"><%= t("consent.deny") %></button>
+5 -5
View File
@@ -14,7 +14,7 @@
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
-%>
<div class="table-wrap">
<table class="table">
@@ -24,7 +24,7 @@
<thead>
<tr>
<% if (selectable) { -%>
<th class="col-check" scope="col"><input type="checkbox" aria-label="Select all rows"></th>
<th class="col-check" scope="col"><input type="checkbox" aria-label="<%= t("table.selectAll") %>"></th>
<% } -%>
<% columns.forEach((col) => { -%>
<% if (col.sortable) { -%>
@@ -34,7 +34,7 @@
<% } -%>
<% }) -%>
<% if (withActions) { -%>
<th class="col-actions" scope="col"><span class="sr-only">Actions</span></th>
<th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th>
<% } -%>
</tr>
</thead>
@@ -45,7 +45,7 @@
<% rows.forEach((row) => { -%>
<tr>
<% if (selectable) { -%>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select <%= row.name || "row" %>"></td>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="<%= t("table.select", { name: row.name || t("table.row") }) %>"></td>
<% } -%>
<% (row.cells || []).forEach((cell) => { -%>
<% if (typeof cell === "string") { -%>
@@ -64,7 +64,7 @@
<% }) -%>
<% if (withActions) { -%>
<% if ((row.actions || []).length) { -%>
<td class="col-actions"><details class="menu kebab"><summary aria-label="Row actions for <%= row.name || "row" %>"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary><div class="menu-pop"><% row.actions.forEach((a) => { -%>
<td class="col-actions"><details class="menu kebab"><summary aria-label="<%= t("table.rowActions", { name: row.name || t("table.row") }) %>"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary><div class="menu-pop"><% row.actions.forEach((a) => { -%>
<% if (a.separatorBefore) { %><div class="menu-sep"></div><% } %><% if (a.href) { %><a class="menu-item<% if (a.danger) { %> danger<% } %>" href="<%= a.href %>"><% if (a.icon) { %><svg class="ico"><use href="#<%= a.icon %>"/></svg><% } %><%= a.label %></a><% } else { %><button class="menu-item<% if (a.danger) { %> danger<% } %>" type="button"><% if (a.icon) { %><svg class="ico"><use href="#<%= a.icon %>"/></svg><% } %><%= a.label %></button><% } %><% }) -%>
</div></details></td>
<% } else { -%>
+1 -1
View File
@@ -21,7 +21,7 @@
-%>
<div class="field<% if (error) { %> has-error<% } %>">
<% if (link || optional) { -%>
<div class="field-top"><label for="<%= id %>"><%= locals.label %></label><% if (link) { %><a class="field-link" href="<%= link.href %>"><%= link.label %></a><% } else { %><span class="optional">Optional</span><% } %></div>
<div class="field-top"><label for="<%= id %>"><%= locals.label %></label><% if (link) { %><a class="field-link" href="<%= link.href %>"><%= link.label %></a><% } else { %><span class="optional"><%= t("field.optional") %></span><% } %></div>
<% } else { -%>
<label for="<%= id %>"><%= locals.label %></label>
<% } -%>
+6 -6
View File
@@ -10,11 +10,11 @@
daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} }
%><%
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);
-%>
<form class="filters" method="get"<% if (action) { %> action="<%= action %>"<% } %> aria-label="<%= label %>">
@@ -22,7 +22,7 @@
<div class="filter-row">
<% row.forEach((c) => { -%>
<% if (c.type === "search") { -%>
<label class="search"><span class="sr-only"><%= c.label || "Search" %></span><svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"/></svg><input type="search" name="<%= c.name %>" placeholder="<%= c.placeholder || "" %>"<% if (c.value) { %> value="<%= c.value %>"<% } %>></label>
<label class="search"><span class="sr-only"><%= c.label || t("filter.search") %></span><svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"/></svg><input type="search" name="<%= c.name %>" placeholder="<%= c.placeholder || "" %>"<% if (c.value) { %> value="<%= c.value %>"<% } %>></label>
<% } else if (c.type === "segmented") { -%>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || c.name %></legend><div class="segmented"><% c.options.forEach((o) => { %><label><input type="radio" name="<%= c.name %>" value="<%= o.value %>"<% if (eq(c.value, o.value)) { %> checked<% } %>><span><%= o.label %></span><% if (o.count != null) { %><span class="seg-count"><%= o.count %></span><% } %></label><% }) %></div></fieldset>
<% } else if (c.type === "select") { -%>
@@ -30,7 +30,7 @@
<% } else if (c.type === "chips") { -%>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || c.name %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || c.name %></span><div class="chips"><% (c.options).forEach((o) => { const on = (c.value || []).map(String).includes(String(o.value)); %><label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="<%= c.name %>" value="<%= o.value %>"<% if (on) { %> checked<% } %>><%= o.label %></label><% }) %></div></fieldset>
<% } else if (c.type === "daterange") { -%>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || "Date range" %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || "Date range" %></span><div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"/></svg><label class="sr-only" for="f-<%= c.from.name %>"><%= c.from.label || "From" %></label><input type="date" id="f-<%= c.from.name %>" name="<%= c.from.name %>"<% if (c.from.value) { %> value="<%= c.from.value %>"<% } %>><span class="to" aria-hidden="true">to</span><label class="sr-only" for="f-<%= c.to.name %>"><%= c.to.label || "To" %></label><input type="date" id="f-<%= c.to.name %>" name="<%= c.to.name %>"<% if (c.to.value) { %> value="<%= c.to.value %>"<% } %>></div></fieldset>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || t("filter.dateRange") %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || t("filter.dateRange") %></span><div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"/></svg><label class="sr-only" for="f-<%= c.from.name %>"><%= c.from.label || t("filter.from") %></label><input type="date" id="f-<%= c.from.name %>" name="<%= c.from.name %>"<% if (c.from.value) { %> value="<%= c.from.value %>"<% } %>><span class="to" aria-hidden="true"><%= t("filter.toSeparator") %></span><label class="sr-only" for="f-<%= c.to.name %>"><%= c.to.label || t("filter.to") %></label><input type="date" id="f-<%= c.to.name %>" name="<%= c.to.name %>"<% if (c.to.value) { %> value="<%= c.to.value %>"<% } %>></div></fieldset>
<% } else if (c.type === "spacer") { -%>
<div class="spacer"></div>
<% } -%>
@@ -39,11 +39,11 @@
<% }) -%>
<div class="filter-row filter-foot">
<% if (pills.length) { -%>
<div class="active-pills" aria-label="Applied filters"><span class="filter-legend">Applied</span><% pills.forEach((p) => { %><span class="pill"><b><%= p.label %>:</b> <%= p.value %> <a class="pill-x" href="<%= p.remove %>" aria-label="Remove <%= p.label %> filter"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span><% }) %><a class="pill-clear" href="<%= clearHref %>">Clear all</a></div>
<div class="active-pills" aria-label="<%= t("filter.appliedFilters") %>"><span class="filter-legend"><%= t("filter.applied") %></span><% pills.forEach((p) => { %><span class="pill"><b><%= p.label %>:</b> <%= p.value %> <a class="pill-x" href="<%= p.remove %>" aria-label="<%= t("filter.remove", { label: p.label }) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span><% }) %><a class="pill-clear" href="<%= clearHref %>"><%= t("filter.clearAll") %></a></div>
<% } -%>
<div class="spacer"></div>
<div class="filter-actions">
<button type="reset" class="btn">Reset</button>
<button type="reset" class="btn"><%= t("filter.reset") %></button>
<button type="submit" class="btn btn-primary"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"/></svg><%= applyLabel %></button>
</div>
</div>
+1 -1
View File
@@ -13,7 +13,7 @@
<%- include("field", field) %>
<% }) -%>
<% if (flow.recoverHref) { -%>
<p class="auth-aside"><a href="<%= flow.recoverHref %>">Forgot password?</a></p>
<p class="auth-aside"><a href="<%= flow.recoverHref %>"><%= t("auth.forgotPassword") %></a></p>
<% } -%>
<% flow.buttons.forEach((b, i) => { -%>
<button type="submit" class="btn btn-block<%= i === 0 ? " btn-primary" : "" %>"<% if (b.name) { %> name="<%= b.name %>"<% } %><% if (b.value != null) { %> value="<%= b.value %>"<% } %>><%= b.label %></button>
+4 -4
View File
@@ -3,11 +3,11 @@
Rendered into the app-shell content. Locals: brand (name), signedIn (bool).
%><div class="shell-auth">
<div class="landing">
<h1 class="landing-title">Operational web apps, without the boilerplate.</h1>
<p class="landing-lead"><%= 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.</p>
<h1 class="landing-title"><%= t("landing.title") %></h1>
<p class="landing-lead"><%= t("landing.lead", { brand }) %></p>
<div class="landing-actions">
<% if (signedIn) { %><a class="btn btn-primary" href="/dashboard">Go to your dashboard</a>
<% } else { %><a class="btn btn-primary" href="/login">Log in</a><a class="btn" href="/registration">Create account</a><% } %>
<% if (signedIn) { %><a class="btn btn-primary" href="<%= localeHref("/dashboard") %>"><%= t("landing.dashboard") %></a>
<% } else { %><a class="btn btn-primary" href="<%= localeHref("/login") %>"><%= t("landing.signIn") %></a><a class="btn" href="<%= localeHref("/registration") %>"><%= t("landing.register") %></a><% } %>
</div>
</div>
</div>
+14
View File
@@ -0,0 +1,14 @@
<%#
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 }))],
}) %>
<% } -%>
+2 -2
View File
@@ -8,7 +8,7 @@
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? ⇒ <a>, danger? } (default: menu-item button)
Item ∈ { head } · { sep } · { label, icon?, href? ⇒ <a>, hreflang?, current?, danger? } (default: menu-item button)
· { group: { legend?, name, control?(="checkbox"|"radio"), options:{value,label,checked?}[] } }
%><%
const t = locals.trigger || {};
@@ -28,7 +28,7 @@
<% } else if (it.group) { const g = it.group; -%>
<fieldset class="menu-field"><% if (g.legend) { %><legend class="menu-head"><%= g.legend %></legend><% } %><% g.options.forEach((o) => { %><label class="menu-check"><input type="<%= g.control || "checkbox" %>" name="<%= g.name %>" value="<%= o.value %>"<%= o.checked ? " checked" : "" %>><%= o.label %></label><% }) %></fieldset>
<% } else if (it.href) { -%>
<a class="menu-item<%= it.danger ? " danger" : "" %>" href="<%= it.href %>"><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></a>
<a class="menu-item<%= it.danger ? " danger" : "" %>" href="<%= it.href %>"<% if (it.hreflang) { %> hreflang="<%= it.hreflang %>" lang="<%= it.hreflang %>"<% } %><% if (it.current) { %> aria-current="true"<% } %>><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></a>
<% } else { -%>
<button class="menu-item<%= it.danger ? " danger" : "" %>" type="button"><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></button>
<% } -%>
+1 -1
View File
@@ -13,7 +13,7 @@
<div class="nav-row">
<% if (header) { -%>
<details class="nav-disc"<%= node.open ? " open" : "" %>>
<summary class="nav-tog" aria-label="Toggle <%= node.label %>"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
<summary class="nav-tog" aria-label="<%= t("shell.toggleSection", { label: node.label }) %>"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<% } else { -%>
<span class="nav-spacer" aria-hidden="true"></span>
+9 -9
View File
@@ -9,7 +9,7 @@
prev?, next? { href? } page step; omit href ⇒ disabled
pages? { label, href?, current?, ellipsis? }[]
%><%
const label = locals.label || "Pagination";
const label = locals.label || t("pagination.label");
const summary = locals.summary;
const rows = locals.rows;
const prev = locals.prev;
@@ -19,25 +19,25 @@
-%>
<footer class="pager">
<% if (summary) { -%>
<span><%= summary.from %><%= summary.to %> of <b><%= summary.total %></b></span>
<span><%= summary.from %><%= summary.to %> <%= t("pagination.of") %> <b><%= summary.total %></b></span>
<% } -%>
<% if (rows) { -%>
<form class="pager-rows" method="get"<% if (rows.action) { %> action="<%= rows.action %>"<% } %>>
<% (rows.hidden || []).forEach((h) => { -%>
<input type="hidden" name="<%= h.name %>" value="<%= h.value %>">
<% }) -%>
<label for="pager-rows"><%= rows.label || "Rows" %></label>
<span class="select"><select id="pager-rows" name="<%= rows.name %>"><% (rows.options || []).forEach((o) => { const v = o && o.value != null ? o.value : o; const t = o && o.label != null ? o.label : o; %><option value="<%= v %>"<% if (eq(rows.value, v)) { %> selected<% } %>><%= t %></option><% }) %></select></span>
<button class="page-btn" type="submit"><%= rows.submitLabel || "Go" %></button>
<label for="pager-rows"><%= rows.label || t("pagination.rows") %></label>
<span class="select"><select id="pager-rows" name="<%= rows.name %>"><% (rows.options || []).forEach((o) => { const v = o && o.value != null ? o.value : o; const text = o && o.label != null ? o.label : o; %><option value="<%= v %>"<% if (eq(rows.value, v)) { %> selected<% } %>><%= text %></option><% }) %></select></span>
<button class="page-btn" type="submit"><%= rows.submitLabel || t("pagination.go") %></button>
</form>
<% } -%>
<div class="spacer"></div>
<nav class="page-nums" aria-label="<%= label %>">
<% if (prev) { -%>
<% if (prev.href) { -%>
<a class="page-btn" href="<%= prev.href %>" aria-label="Previous page"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></a>
<a class="page-btn" href="<%= prev.href %>" aria-label="<%= t("pagination.previous") %>"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></a>
<% } else { -%>
<button class="page-btn" type="button" disabled aria-label="Previous page"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></button>
<button class="page-btn" type="button" disabled aria-label="<%= t("pagination.previous") %>"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></button>
<% } -%>
<% } -%>
<% pages.forEach((p) => { -%>
@@ -51,9 +51,9 @@
<% }) -%>
<% if (next) { -%>
<% if (next.href) { -%>
<a class="page-btn" href="<%= next.href %>" aria-label="Next page"><svg class="ico ico-sm"><use href="#i-chev"/></svg></a>
<a class="page-btn" href="<%= next.href %>" aria-label="<%= t("pagination.next") %>"><svg class="ico ico-sm"><use href="#i-chev"/></svg></a>
<% } else { -%>
<button class="page-btn" type="button" disabled aria-label="Next page"><svg class="ico ico-sm"><use href="#i-chev"/></svg></button>
<button class="page-btn" type="button" disabled aria-label="<%= t("pagination.next") %>"><svg class="ico ico-sm"><use href="#i-chev"/></svg></button>
<% } -%>
<% } -%>
</nav>
+17 -14
View File
@@ -9,7 +9,8 @@
(the <title> tag; defaults to title or the brand), `brand` ({ name, logo?, sub? }), `theme`
(theme-switch default), `user`, `breadcrumbs`, `csrfToken` (the Sign-out form's hidden field),
`signInHref` (anonymous "Sign in" target; default /login). `menu` (default true) — set false to
drop the sidebar and render a focused single-column page.
drop the sidebar and render a focused single-column page. `t`, `locale`, `dir` and `localeSwitch`
come from the host with every render.
%><%
const brand = locals.brand || { name: "Plainpages" };
const title = locals.title || ""; // topbar heading; empty ⇒ no topbar <h1> (the body owns it)
@@ -23,7 +24,7 @@
const body = locals.body || "";
const styles = locals.styles || []; // extra per-page stylesheet hrefs (e.g. a plugin's own CSS)
%><!doctype html>
<html lang="en">
<html lang="<%= locale %>" dir="<%= dir %>">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -33,7 +34,7 @@
<% }) %><link rel="icon" href="/public/favicon.svg" />
</head>
<body>
<a class="skip-link" href="#main-content">Skip to content</a>
<a class="skip-link" href="#main-content"><%= t("shell.skipToContent") %></a>
<%- include("icons") %>
<% if (menu) { %>
<!-- nav-toggle drives the mobile overlay (pure CSS) -->
@@ -42,14 +43,14 @@
<div class="app<%= menu ? "" : " app-bare" %>">
<% if (menu) { %>
<aside class="sidebar" aria-label="Primary">
<aside class="sidebar" aria-label="<%= t("shell.sidebar") %>">
<div class="brand">
<% if (brand.logo) { %><img class="brand-logo" src="<%= brand.logo %>" alt="" /><% } else { %><span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span><% } %>
<span class="brand-name"><%= brand.name %></span>
<% if (brand.sub) { %><span class="brand-sub"><%= brand.sub %></span><% } %>
</div>
<nav class="nav" aria-label="Main navigation"><%- nav %></nav>
<nav class="nav" aria-label="<%= t("shell.mainNav") %>"><%- nav %></nav>
<div class="side-footer">
<%- include("theme-switch", { value: locals.theme }) %>
@@ -66,12 +67,12 @@
</span>
</summary>
<div class="menu-pop left up" style="min-width:220px">
<div class="menu-head">Signed in as <%= user.name %></div>
<button class="menu-item" type="button"><svg class="ico"><use href="#i-user" /></svg>Profile</button>
<div class="menu-head"><%= t("shell.signedInAs", { name: user.name }) %></div>
<button class="menu-item" type="button"><svg class="ico"><use href="#i-user" /></svg><%= t("shell.profile") %></button>
<%# Sign out is a state change → a POST form (not a GET link), CSRF-guarded by app.ts %>
<form class="menu-item-form" method="post" action="/logout">
<input type="hidden" name="_csrf" value="<%= locals.csrfToken || '' %>" />
<button class="menu-item danger" type="submit"><svg class="ico"><use href="#i-logout" /></svg>Sign out</button>
<button class="menu-item danger" type="submit"><svg class="ico"><use href="#i-logout" /></svg><%= t("shell.signOut") %></button>
</form>
</div>
</details>
@@ -79,28 +80,30 @@
<%# anonymous (a public page in the shell): no session to end — offer a way in instead.
signInHref carries this page as return_to (chrome.signInHref); falls back to bare /login.
Suppressed on the auth pages themselves (hideSignIn) — a Sign-in there only loops back. %>
<a class="btn btn-primary" href="<%= locals.signInHref || '/login' %>" style="flex:1 1 auto"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-user" /></svg>Sign in</a>
<a class="btn btn-primary" href="<%= locals.signInHref || '/login' %>" style="flex:1 1 auto"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-user" /></svg><%= t("shell.signIn") %></a>
<% } %>
<%- include("locale-switch") %>
<%- include("menu", {
up: true,
trigger: { class: "btn icon-btn", label: "Settings", html: '<svg class="ico"><use href="#i-gear"/></svg>' },
items: [{ head: "Settings" }, { label: "Preferences", icon: "i-gear" }],
trigger: { class: "btn icon-btn", label: t("shell.settings"), html: '<svg class="ico"><use href="#i-gear"/></svg>' },
items: [{ head: t("shell.settings") }, { label: t("shell.preferences"), icon: "i-gear" }],
}) %>
</div>
</div>
</aside>
<!-- scrim closes the mobile menu (label toggles the checkbox) -->
<label class="scrim" for="nav-toggle" aria-label="Close menu"></label>
<label class="scrim" for="nav-toggle" aria-label="<%= t("shell.closeMenu") %>"></label>
<% } %>
<main class="content" id="main-content">
<header class="topbar">
<% if (menu) { %><label class="btn icon-btn hamburger" for="nav-toggle" aria-label="Open menu"><svg class="ico"><use href="#i-menu" /></svg></label><% } %>
<% if (menu) { %><label class="btn icon-btn hamburger" for="nav-toggle" aria-label="<%= t("shell.openMenu") %>"><svg class="ico"><use href="#i-menu" /></svg></label><% } %>
<% if (title) { %><h1 class="page-title"><%= title %></h1><% } %>
<% if (breadcrumbs.length) { %>
<nav class="crumbs" aria-label="Breadcrumb">
<nav class="crumbs" aria-label="<%= t("shell.breadcrumb") %>">
<% breadcrumbs.forEach((c, i) => { %><% if (i) { %><span class="sep">/</span><% } %><% if (c.href) { %><a href="<%= c.href %>"><%= c.label %></a><% } else { %><span><%= c.label %></span><% } %><% }) %>
</nav>
<% } %>
+4 -4
View File
@@ -5,10 +5,10 @@
rendered checked) · label?.
%><%
const value = locals.value || "auto";
const label = locals.label || "Color theme";
const label = locals.label || t("theme.label");
-%>
<div class="theme-switch" role="radiogroup" aria-label="<%= label %>">
<label><input type="radio" name="theme" id="theme-light"<%= value === "light" ? " checked" : "" %>><span>Light</span></label>
<label><input type="radio" name="theme" id="theme-auto"<%= value === "auto" ? " checked" : "" %>><span>Auto</span></label>
<label><input type="radio" name="theme" id="theme-dark"<%= value === "dark" ? " checked" : "" %>><span>Dark</span></label>
<label><input type="radio" name="theme" id="theme-light"<%= value === "light" ? " checked" : "" %>><span><%= t("theme.light") %></span></label>
<label><input type="radio" name="theme" id="theme-auto"<%= value === "auto" ? " checked" : "" %>><span><%= t("theme.auto") %></span></label>
<label><input type="radio" name="theme" id="theme-dark"<%= value === "dark" ? " checked" : "" %>><span><%= t("theme.dark") %></span></label>
</div>