Add i18n support: per-locale catalogs, URL-driven locale, translated core and examples #44
@@ -13,3 +13,7 @@ e2e-tests/artifacts/
|
||||
# config/ is a drop-in mount point for your menu/branding override — keep it empty (see examples/config/ for the template)
|
||||
/config/*
|
||||
!/config/.gitkeep
|
||||
|
||||
# locales/ is a drop-in mount point for extra (or replacement) language catalogs — keep it empty
|
||||
/locales/*
|
||||
!/locales/.gitkeep
|
||||
|
||||
@@ -52,7 +52,8 @@ Intentional, reasoned choices — an architecture review should honor them, not
|
||||
them. Revisit only if the stated reason stops holding.
|
||||
|
||||
- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/`
|
||||
(session-JWT hot path, guards, and the Ory REST clients), `plugin-host/`
|
||||
(session-JWT hot path, guards, and the Ory REST clients), `i18n/` (locale resolution + the
|
||||
catalogs, `locales/` holding the data), `plugin-host/`
|
||||
(discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel + `system.ts`, the
|
||||
`ctx.system` capability surface), and `ui/` (design-system view-models + menu/chrome);
|
||||
`server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests
|
||||
@@ -99,6 +100,67 @@ them. Revisit only if the stated reason stops holding.
|
||||
README → Auth records the mapping so nobody has to rediscover it. The single exception is the
|
||||
`Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape and keeps Ory's
|
||||
name — don't rename that one.
|
||||
- **The locale lives in the URL, never in a cookie.** `?locale=sv-SE` → `Accept-Language` → `en-US`,
|
||||
and when the URL asked for one the host carries it onto the links it renders (`ctx.localeHref`).
|
||||
A cookie would make a page's language invisible in its address and unshareable; the cost is that a
|
||||
plugin must wrap its own hrefs. Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a
|
||||
lone language from `Accept-Language` takes the first regional catalog for it. Decided 2026-08-03.
|
||||
- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
|
||||
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` says that locale needs —
|
||||
and a mismatch stops startup, same fail-loud contract as a bad manifest. A plugin may ship fewer
|
||||
locales than the host (its strings fall back to `en-US` per key), never one the host lacks.
|
||||
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell (breadcrumbs),
|
||||
`pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every
|
||||
href they render in `localeHref`; the nav and the sign-in link are wrapped upstream in `chrome.ts`;
|
||||
and the two GET forms
|
||||
(filter bar, rows-per-page) carry it as a hidden `locale` input, since a GET submit replaces the
|
||||
whole query string and no href wrapper can reach it. Putting the obligation on each call site was
|
||||
tried first and missed five of eight sites inside one commit — including the admin screens.
|
||||
`ctx.localeHref` remains for hrefs a plugin's own markup emits (the admin example's delete links).
|
||||
**A form's `action` counts as a link** — a POST replaces the URL as completely as a GET submit, so
|
||||
the sign-out, consent and auth-card forms carry it too; without that, picking a language and then
|
||||
saving anything drops back to `Accept-Language`. The one round-trip that cannot carry it is the
|
||||
Kratos sign-in POST, whose action is an absolute off-site URL.
|
||||
Decided 2026-08-03 after an architecture review; a second pass then found breadcrumbs still raw,
|
||||
so: when a link renders from the core chrome, it is the chrome's job to carry the locale.
|
||||
- **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set (`list-query.ts`),
|
||||
so a localized list page doesn't hand a plugin a phantom `locale` filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`,
|
||||
`localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data`
|
||||
so a collision loses the key instead of breaking the shell.
|
||||
- **The language picker is on every page, POST-rendered ones included.** Maintainer's call
|
||||
2026-08-04, overriding an earlier decision to hide it there. The problem it was hiding is real: a
|
||||
POST-rendered URL frequently answers no GET (`POST /admin/users/:id/recovery`), so a link back to
|
||||
it dead-ends on a 405. The host therefore resolves the picker's target (`app.ts` → `switchBase`):
|
||||
this path when it answers GET, else the same-origin Referer, else `/`. Accepted cost: switching
|
||||
language on such a page leaves that POST's own result behind (a re-rendered form's input, or a
|
||||
one-time recovery code). Valid while the picker is expected on literally every page — if that ever
|
||||
softens, hiding it after a POST is the simpler answer.
|
||||
- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
|
||||
`dashboard`) and an `onRequest` short-circuit dispatch a plugin's handler, so they build the
|
||||
context with `contextFor(pluginId)` exactly as a plugin route does — otherwise `ctx.t` is the core
|
||||
translator and the plugin's own keys render as bare keys on the pages it owns. Found by review
|
||||
2026-08-03 after all three paths shipped with the host's context.
|
||||
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`** — `locales/<tag>.ts`
|
||||
for the core and `locales/plugins/<id>/<tag>.ts` for an installed plugin, each adding a language or
|
||||
replacing that tag's catalog wholesale. Adding a language must never require forking the image or a
|
||||
vendored plugin folder. The SHIPPED `en-US` (core's, or the plugin's own) stays the parity baseline
|
||||
even when the mount replaces it, so a mounted catalog is checked rather than trusted (one compared
|
||||
only against itself would boot green with the whole UI rendering keys), and each half is reported
|
||||
under the folder it actually lives in.
|
||||
- **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` from the
|
||||
locale's script because that is free and correct, but the stylesheet keeps physical `left`/`right`
|
||||
properties — a genuine RTL locale needs those moved to logical ones first. Don't convert the CSS or
|
||||
file findings about it on spec. Maintainer's call 2026-08-04; valid while no deployment needs an
|
||||
RTL language. A catalog there
|
||||
for a new tag adds a language; one for a tag the image ships replaces that catalog wholesale, held
|
||||
to the same parity check. Adding a language must not require forking the image.
|
||||
- **An unknown translation key renders as itself.** That single rule is what lets a nav label,
|
||||
branding, or a menu `rename` be either a key or plain text without a second field or a migration.
|
||||
Don't "fix" it into a loud failure: a manifest with plain labels must keep working.
|
||||
- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value,
|
||||
so nothing is double-escaped; a message carrying markup uses `<%- %>`, and then its `{{vars}}` are
|
||||
escaped at the call site (see `views/partials/pagination.ejs`). Don't move escaping into `t()` —
|
||||
every other value in a view would then be the odd one out.
|
||||
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
||||
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`:
|
||||
concurrent jobs can race (one job's logout can 401 another's push — recover by re-running),
|
||||
@@ -178,6 +240,11 @@ Same test before adding a row to a table or the file map — a clause, not a par
|
||||
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
|
||||
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
|
||||
`node:24.16.0-alpine3.24`).
|
||||
- **`HOST_API_VERSION` is frozen at 1.0.0 until the first external install**, even for additive
|
||||
contract changes (i18n added four `RequestContext` fields and several barrel exports without a
|
||||
minor bump). Valid while nothing is installed against it: with no third-party plugin in the wild,
|
||||
a version bump can only produce noise. The promotion trigger is the first external plugin — from
|
||||
then on, follow the versioning table in README → Contract versioning as written. Decided 2026-08-03.
|
||||
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version the
|
||||
plugin was built against — bumped by hand on rebuild, **never** the host's
|
||||
`HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the
|
||||
@@ -188,8 +255,18 @@ Same test before adding a row to a table or the file map — a clause, not a par
|
||||
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
|
||||
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
|
||||
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
|
||||
- **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking
|
||||
canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin
|
||||
routing, 405/404 and error mapping. The pure parts are already extracted and separately tested; what
|
||||
remains is orchestration. Planned split along those seams; don't grow it further without taking one
|
||||
out. Raised by the architecture review 2026-08-03, deliberately not done inside the i18n change.
|
||||
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
|
||||
agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
|
||||
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
|
||||
`src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own
|
||||
`i18n/`. Operator/developer-facing text — boot errors, log messages, guard messages — stays English.
|
||||
A pure view-model builder takes an optional `t` defaulting to its own English, so a unit test reads
|
||||
in words; handlers pass `ctx.t`.
|
||||
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
|
||||
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
|
||||
and not "ids=x,y".
|
||||
|
||||
@@ -89,6 +89,7 @@ From here, render real pages against the app shell and fetch upstream data — s
|
||||
- [The menu system](#the-menu-system)
|
||||
- [Building blocks](#building-blocks)
|
||||
- [Interactivity: zero-JS spine](#interactivity-zero-js-spine)
|
||||
- [Languages (i18n)](#languages-i18n)
|
||||
- [Configuration](#configuration)
|
||||
- [canonical host](#canonical-host-one-public-url)
|
||||
- [what you must supply](#what-you-must-supply-the-only-manual-prep)
|
||||
@@ -131,6 +132,25 @@ hand-roll auth for the tenth time. It's not a no-code tool and doesn't hide its
|
||||
parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--access)) reads as
|
||||
obvious rather than surprising, you're the audience.
|
||||
|
||||
**Who *they* build for.** The people who end up in front of a Plainpages app are not the
|
||||
audience above, and three of them shape the design more than any feature request does:
|
||||
|
||||
- **The end user** — anyone using the product you assemble from Plainpages + your plugins.
|
||||
They never hear the word "plugin": to them the menu, the screens and the sign-in are one app,
|
||||
which is why the shell, the auth pages and every plugin share one design system, one menu and
|
||||
one language.
|
||||
- **The power user** — lives in the app all day. Ctrl-clicks a row to open it in a new tab,
|
||||
bookmarks a filtered-and-sorted list to come back to on Monday, sends that URL to a colleague,
|
||||
and edits the query string by hand when it's faster. This is why list state and the chosen
|
||||
language live **in the URL** and why every navigation is a real `<a href>`: middle-click, "open
|
||||
in new tab", back, and bookmark all have to work without a second thought.
|
||||
- **The non-technical user** — clicks a button twice when nothing happens fast enough, never
|
||||
touches the tab key, doesn't distinguish a link from a button, and won't recognise an error
|
||||
code. This is why destructive actions go through a confirm page instead of an inline
|
||||
`?confirm=1`, why a form's labels are clickable and its errors sit next to the field they
|
||||
belong to, and why a page must never depend on keyboard-only affordances. A double-clicked
|
||||
submit is a real event, not a misuse.
|
||||
|
||||
**Included vs. what you add.**
|
||||
|
||||
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
|
||||
@@ -356,14 +376,16 @@ plugins/things/ # the plugin folder — its name is the id AND the moun
|
||||
things.ejs # your view files; a handler picks one with { view: "things" }
|
||||
public/ # fixed name, optional — static assets, served at /public/things/
|
||||
things.css # your asset files
|
||||
i18n/ # fixed name, optional — this plugin's own catalogs (see Languages)
|
||||
en-US.ts # the baseline; sv-SE.ts et al are written against its type
|
||||
handlers.ts # your code, any names/layout — host never looks here; plugin.ts imports it
|
||||
service.ts # e.g. route handlers, upstream calls, domain helpers — design as you wish
|
||||
```
|
||||
|
||||
**Only `plugin.ts` is required.** The host looks for exactly that filename and its
|
||||
default-exported manifest. `views/` and `public/` are the two fixed folder *names* it resolves
|
||||
against — used only if the plugin renders views or serves assets — but the files inside are
|
||||
yours to name. Everything else (handlers, upstream clients, their filenames and folder layout)
|
||||
default-exported manifest. `views/`, `public/` and `i18n/` are the fixed folder *names* it resolves
|
||||
against — used only if the plugin renders views, serves assets or ships translations — but the files
|
||||
inside are yours to name (a catalog is named for its locale). Everything else (handlers, upstream clients, their filenames and folder layout)
|
||||
the host never sees; `plugin.ts` simply imports it. The `handlers.ts`/`service.ts` split above is
|
||||
just an example — name and arrange your modules however you like, or keep a routes-only plugin to a
|
||||
single `plugin.ts`.
|
||||
@@ -434,7 +456,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder
|
||||
| `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). |
|
||||
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. |
|
||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
|
||||
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
|
||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||
| `hooks` | no | See [Hooks](#hooks). |
|
||||
@@ -569,6 +591,10 @@ interface RequestContext {
|
||||
user: User | null; // { id, email, permissions } from the verified session JWT, or null
|
||||
log: Log; // request-scoped logger, in this request's trace
|
||||
params: Record<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 +626,9 @@ login/registration/front pages), so the menu looks identical signed in or out
|
||||
A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the
|
||||
sidebar, single column); everything else still renders.
|
||||
|
||||
**`ctx.t`** translates in the request's language, and the same block (`t`, `locale`, `locales`,
|
||||
`localeHref`, `dir`) is merged into every view's data — see [Languages](#languages-i18n).
|
||||
|
||||
**`ctx.log`** is a structured, request-scoped logger ([`@larvit/log`](https://www.npmjs.com/package/@larvit/log))
|
||||
already in this request's trace: `ctx.log.info("…", { key: "value" })` (also `warn`/`error`/`debug`,
|
||||
metadata values are string/number/boolean), and **`ctx.log.fetch(url, init?)`** — a drop-in `fetch`
|
||||
@@ -894,6 +923,132 @@ Plugins that genuinely need it — live dashboards, bulk actions, client-side va
|
||||
may **opt into progressive enhancement** (htmx, Alpine, or vanilla JS) on top of working
|
||||
server-rendered HTML. The baseline never depends on it.
|
||||
|
||||
## Languages (i18n)
|
||||
|
||||
Every string the host renders comes from a **catalog**: one TypeScript module per locale, named
|
||||
for the locale it holds. The core ships `en-US` and `sv-SE`; a deployment adds a language by
|
||||
dropping another file next to them.
|
||||
|
||||
```
|
||||
src/i18n/locales/en-US.ts the baseline — every other locale is checked against it
|
||||
src/i18n/locales/sv-SE.ts
|
||||
locales/ drop-in mount root: your own catalogs, ships empty (like plugins/ and config/)
|
||||
locales/plugins/<id>/sv-SE.ts the same, for a plugin's words — so adding a language never forks a plugin
|
||||
plugins/<id>/i18n/en-US.ts a plugin's own words, looked up before the host's
|
||||
plugins/<id>/i18n/sv-SE.ts
|
||||
```
|
||||
|
||||
`locales/` is the operator's, mounted like `plugins/` and `config/` — a file there for a new tag
|
||||
**adds** a language, one for a tag the image already ships **replaces** that catalog wholesale (and
|
||||
is held to the same parity check, so a partial replacement fails the boot instead of leaving half
|
||||
the app in English). `locales/plugins/<id>/<tag>.ts` does the same for an installed plugin's words,
|
||||
checked against *that plugin's* `en-US` — so translating a vendored plugin, or fixing its wording,
|
||||
never means forking its folder:
|
||||
|
||||
```yaml
|
||||
# compose.override.yml
|
||||
services:
|
||||
web:
|
||||
volumes:
|
||||
- ./locales:/app/locales:ro
|
||||
```
|
||||
|
||||
**Which language a request gets:** `?locale=sv-SE` wins, else `Accept-Language`, else `en-US`.
|
||||
Matching is exact on a full tag — `?locale=sv-FI` with only `sv-SE` installed matches nothing and
|
||||
falls through to `Accept-Language` (and from there to `en-US`), rather than being served a
|
||||
neighbouring region — but a lone language (`sv`, as browsers send) resolves to the first regional
|
||||
catalog for it. There is **no locale cookie**: the URL is the only place a choice
|
||||
is stored, so a link is shareable and a page is what its address says it is. When the URL asked
|
||||
for a language, the host carries `?locale=` onto every link *it* renders (menu, sign-in, its own
|
||||
redirects) and `ctx.localeHref(href)` does the same for a plugin's links. The picker in the
|
||||
sidebar footer (and on the auth pages) lists every installed locale, each a plain link to the
|
||||
same page in that language; it renders whenever more than one locale is installed — **on every
|
||||
page**. After a POST the current URL may answer no GET at all (`POST /admin/users/:id/recovery`
|
||||
renders a page and has no GET sibling), so the host points the picker at the nearest page that does:
|
||||
this path when it answers GET, else the page the form was submitted from, else `/`. Switching
|
||||
language there therefore leaves the POST's own result behind — a re-rendered form's input, or a
|
||||
one-time code — which is the accepted cost of having the picker everywhere.
|
||||
|
||||
**Writing a catalog.** `en-US.ts` exports the object and its type; every other locale is written
|
||||
against that type, so a missing or misspelled key is a type error before the app ever boots. For a
|
||||
language of your own: copy `src/i18n/locales/en-US.ts` into `locales/<tag>.ts`, type it
|
||||
`CoreMessages` (from `#plugin-api`), and translate. The `as PluralMessage` cast below is required —
|
||||
without it the inferred type pins the plural forms to English's two, and a locale that selects more
|
||||
(Polish, Arabic) becomes unwritable:
|
||||
|
||||
```ts
|
||||
// plugins/shop/i18n/en-US.ts
|
||||
import type { PluralMessage } from "#plugin-api";
|
||||
|
||||
const messages = {
|
||||
"shop.title": "Shop",
|
||||
"shop.greeting": "Hello, {{name}}!",
|
||||
"shop.orders": { one: "{{count}} order", other: "{{count}} orders" } as PluralMessage,
|
||||
};
|
||||
export type ShopMessages = typeof messages;
|
||||
export default messages;
|
||||
|
||||
// plugins/shop/i18n/sv-SE.ts
|
||||
import type { ShopMessages } from "./en-US.ts";
|
||||
const messages: ShopMessages = { "shop.title": "Butik", /* … */ };
|
||||
export default messages;
|
||||
```
|
||||
|
||||
At boot every catalog is checked against its set's `en-US`; a missing key, an unknown key, or a
|
||||
plural message that doesn't cover the categories its locale actually selects (`Intl.PluralRules`)
|
||||
**stops startup** with the full list — a half-translated deploy never reaches a visitor. A plugin
|
||||
may translate *fewer* locales than the host (its strings then render in `en-US` on that page), but
|
||||
never one the host doesn't have.
|
||||
|
||||
**Using it.** `ctx.t(key, vars)` in a handler; in a view `t(...)` is already there, along with
|
||||
`locale`, `locales`, `localeHref()` and `dir` — the host merges them into every render, at any
|
||||
include depth:
|
||||
|
||||
```ts
|
||||
// handler
|
||||
return { data: { title: ctx.t("shop.title"), lead: ctx.t("shop.greeting", { name }) }, view: "shop" };
|
||||
|
||||
// a pure view model built outside a request (its unit test) defaults to the plugin's own English:
|
||||
import { englishTranslator, type Translate } from "#plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
const EN: Translate = englishTranslator(enUS); // your catalog, then the host's
|
||||
```
|
||||
```html
|
||||
<!-- 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 its `{{vars}}` must then be
|
||||
escaped at the call site, since nothing escapes them there (`pagination.ejs` is the worked example).
|
||||
- **Dates and numbers are `Intl`'s job**, not the catalog's: `new Intl.DateTimeFormat(ctx.locale)`.
|
||||
- **The core building blocks carry the locale for you** — every href they render (menu, breadcrumbs,
|
||||
pagination, sort headers, row actions, the auth card's links) goes through `localeHref`, and their
|
||||
GET forms carry it as a hidden field, since a GET submit replaces the whole query string.
|
||||
`ctx.localeHref` is for hrefs and form actions your own markup emits (a POST replaces the URL just
|
||||
as a GET submit does), and `localeParam` (a view local: the tag, or null) for your own GET forms.
|
||||
`locale` is reserved: `parseListQuery` never returns it as a filter. Responses carry
|
||||
`Vary: Accept-Language`, so a cache in front of the app keys on the language too.
|
||||
- **Reuse the core words.** Generic UI verbs live in the core catalog — `common.add/cancel/delete/
|
||||
edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through
|
||||
to them. Keep your catalog for your domain words, so N plugins don't re-translate "Cancel" N times.
|
||||
- **These view locals are reserved:** `t`, `locale`, `locales`, `localeHref`, `localeParam`,
|
||||
`localeSwitch`, `dir`. They are merged after your `data`, so a key of yours with one of those names
|
||||
is ignored rather than breaking the shell.
|
||||
|
||||
**Kratos writes the auth flow's own text** (field labels, validation errors) and tags each string
|
||||
with a stable numeric id; a `kratos.<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 +1422,13 @@ docker compose run --rm --no-deps web npm test # node --test (units)
|
||||
E2E runs in the official Playwright image (browsers preinstalled) against the live `web`
|
||||
service — no Node/browsers on the host. There are five suites:
|
||||
|
||||
**Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots
|
||||
the live pages and asserts the rendered design system — the app shell, theme switch, mobile
|
||||
off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and
|
||||
**Visual + design system** (`visual.spec.ts`, `language.spec.ts`) — Ory-free, so it stays fast. It
|
||||
screenshots the live pages and asserts the rendered design system — the app shell, theme switch,
|
||||
mobile off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and
|
||||
plugin permission-gating — the last exercised by bind-mounting the reference example
|
||||
(`examples/plugins/scheduling/`) onto `/app/plugins/scheduling`.
|
||||
(`examples/plugins/scheduling/`) onto `/app/plugins/scheduling`. `language.spec.ts` drives
|
||||
[language switching](#languages-i18n) in the browser: the picker, the choice surviving a click into
|
||||
the plugin's own pages, an `Accept-Language`-only visitor, and the fallback for an uninstalled locale.
|
||||
|
||||
```bash
|
||||
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --build --rm e2e # run the suite
|
||||
@@ -1652,6 +1809,16 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
||||
hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD
|
||||
fetch-timeout.ts withTimeout(): bound every outbound Ory call — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients
|
||||
|
||||
i18n/ Translation: which language a request gets, and the words for it
|
||||
catalog.ts Catalog/Message types + checkCatalog(): the boot-time parity rules a locale is held to (keys, kinds, plural categories)
|
||||
locale.ts resolveLocale()/matchLocale()/parseAcceptLanguage() (?locale → Accept-Language → en-US) + localeHref(), textDirection(), localeLabel()
|
||||
translate.ts createTranslator(): key + vars → text — the catalog chain, {{var}} interpolation, Intl.PluralRules selection; an unknown key renders as itself
|
||||
load.ts loadI18n(): import src/i18n/locales/*.ts + plugins/<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)
|
||||
@@ -1671,9 +1838,10 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
||||
list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
|
||||
paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
|
||||
|
||||
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Permissions/Clients + confirm bodies)
|
||||
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, language picker, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Permissions/Clients + confirm bodies)
|
||||
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
||||
config/ Drop-in mount point for the central menu override + branding (config/menu.ts). Ships empty (.gitkeep, git-ignored otherwise) — mount your own or copy the template from examples/config/; defaults apply when absent
|
||||
locales/ Drop-in mount point for extra (or replacement) language catalogs — a <locale>.ts here adds a language for the core, or replaces the shipped catalog for that tag wholesale; plugins/<id>/<locale>.ts does the same for an installed plugin. Ships empty (.gitkeep, git-ignored otherwise); see Languages
|
||||
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — permission/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
|
||||
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the example plugins onto /app/plugins/scheduling and /app/plugins/admin
|
||||
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Permissions/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,19 @@ async function loginPassword(page: Page): Promise<void> {
|
||||
await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL); // waits through the redirect chain
|
||||
}
|
||||
|
||||
// The themed Kratos page in another language: our own chrome, Kratos' own strings mapped by id, and
|
||||
// the card's own links keeping the choice (they are rendered by the flow body, not by the menu).
|
||||
test("the login page speaks the visitor's language, links included", async ({ browser }) => {
|
||||
const page = await (await browser.newContext()).newPage();
|
||||
await page.goto("/login?locale=sv-SE");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
await expect(page.getByRole("heading", { name: "Logga in" })).toBeVisible();
|
||||
await expect(page.getByLabel("Lösenord", { exact: true })).toBeVisible(); // Kratos' own field, labelled via auth.field.password
|
||||
await expect(page.getByRole("link", { name: "Glömt lösenordet?" })).toHaveAttribute("href", /locale=sv-SE/);
|
||||
await expect(page.getByRole("link", { name: "Skapa ett" })).toHaveAttribute("href", /locale=sv-SE/);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test.describe.serial("authenticated admin journey", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
@@ -36,6 +49,57 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
});
|
||||
test.afterAll(async () => { await page.context().close(); });
|
||||
|
||||
// The list screens rebuild their query from the list state (sort/page/filter), so they are where
|
||||
// a chosen language used to get dropped — the core building blocks carry it now.
|
||||
test("a sorted, paged admin list keeps the visitor's language", async () => {
|
||||
await page.goto("/admin/users?locale=sv-SE");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: /E-postadress/ }).click(); // a sort header
|
||||
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
|
||||
await page.getByRole("button", { name: "Använd filter" }).click(); // the filter bar's GET form
|
||||
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
|
||||
await page.getByRole("button", { name: "Visa" }).click(); // the rows-per-page GET form
|
||||
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
|
||||
// The breadcrumb is the chrome's way back up — it is rendered by the shell, not by the screen.
|
||||
await page.getByRole("navigation", { name: "Sidsökväg" }).getByRole("link").first().click();
|
||||
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
});
|
||||
|
||||
// A POST that re-renders a page: the write must keep the language, and the picker — which is on
|
||||
// every page — must point somewhere that answers GET rather than at the POST-only URL.
|
||||
test("a write keeps the visitor's language, and the picker still works on the POST-rendered page", async () => {
|
||||
await page.goto("/admin/users?locale=sv-SE");
|
||||
await page.getByRole("link", { name: "Ny användare" }).click();
|
||||
await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`);
|
||||
await page.getByRole("button", { name: "Skapa användare" }).click();
|
||||
await expect(page).toHaveURL(/locale=sv-SE/); // the POST → redirect → GET keeps it
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
|
||||
// Open the new user's edit page the way the CRUD test does — the row's Edit link carries the id.
|
||||
const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` });
|
||||
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
||||
await page.goto(`${editHref}`);
|
||||
await expect(page.locator('summary[aria-label="Språk"]')).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route
|
||||
await expect(page.getByText("Återställningskod skapad")).toBeVisible();
|
||||
|
||||
// The picker is here too, and following it lands on a real page in the other language.
|
||||
await page.locator('summary[aria-label="Språk"]').click();
|
||||
await page.getByRole("link", { name: /English/i }).click();
|
||||
expect(page.url()).toContain("locale=en-US");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
|
||||
await expect(page.getByRole("heading", { name: "Edit user" })).toBeVisible(); // not a 405
|
||||
});
|
||||
|
||||
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
|
||||
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
|
||||
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createPrivateKey, sign } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Language switching in a real browser, Ory-free (the visual stack). Proves the whole path a
|
||||
// visitor takes: pick a language, read the page in it, and stay in it while clicking around —
|
||||
// including into a plugin, whose words come from its own catalog (plugins/scheduling/i18n/).
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
|
||||
const SESSION_COOKIE = "plainpages_jwt";
|
||||
const SHOTS = "artifacts/screenshots";
|
||||
|
||||
// Same trick as visual.spec.ts: sign a session JWT with the committed dev tokenizer key so the
|
||||
// gated pages render without standing up Ory.
|
||||
function devSession(permissions: string[] = []): string {
|
||||
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
|
||||
const key = createPrivateKey({ format: "jwk", key: jwk });
|
||||
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "lang-demo" })}`;
|
||||
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
|
||||
}
|
||||
|
||||
test("the switcher changes language, and the choice survives clicking through the app", async ({ page, context }) => {
|
||||
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession(["scheduling:read"]) }]);
|
||||
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
|
||||
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
|
||||
|
||||
// The picker sits in the sidebar footer beside the theme switch; each entry is a plain link to
|
||||
// this same page in that language (zero-JS).
|
||||
await page.locator('summary[aria-label="Language"]').click();
|
||||
await page.getByRole("link", { name: /svenska/i }).click();
|
||||
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||
await expect(page.getByRole("heading", { name: "Startpanel" })).toBeVisible(); // the starter dashboard, in Swedish
|
||||
await expect(page.getByRole("link", { name: "Översikt", exact: true })).toBeVisible(); // the menu too
|
||||
await mkdir(SHOTS, { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: `${SHOTS}/live-05-swedish.png` });
|
||||
|
||||
// Clicking a menu item keeps Swedish — the host carries the choice onto the links it renders,
|
||||
// and the plugin's own page is translated from its own catalog. The section's own label comes
|
||||
// from the plugin's catalog too, so opening it proves the nav fragment was translated.
|
||||
await page.locator('summary[aria-label="Visa eller dölj Schemaläggning"]').click();
|
||||
await page.getByRole("link", { name: "Pass", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/scheduling\/shifts\?locale=sv-SE/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
await expect(page.getByRole("heading", { name: "Pass" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Sök" })).toBeVisible(); // the core filter bar, in Swedish
|
||||
|
||||
// The filter bar is a GET form: submitting it replaces the whole query string, so the choice
|
||||
// survives only because the form carries it as a hidden field.
|
||||
await page.getByRole("button", { name: "Sök" }).click();
|
||||
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
|
||||
// …and back to English the same way.
|
||||
await page.locator('summary[aria-label="Språk"]').click();
|
||||
await page.getByRole("link", { name: /English/i }).click();
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
|
||||
await expect(page.getByRole("heading", { name: "Shifts" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("a browser that asks for Swedish gets it without touching the URL", async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale: "sv" }); // a browser set to Swedish, no region
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${BASE_URL}/`);
|
||||
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
const signIn = page.locator("#main-content").getByRole("link", { name: "Logga in" });
|
||||
await expect(signIn).toBeVisible();
|
||||
// Nothing was chosen in the URL, so the links stay plain — the browser asks again on the next hit.
|
||||
await expect(signIn).toHaveAttribute("href", "/login");
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("an uninstalled language falls back to English rather than failing", async ({ page }) => {
|
||||
const response = await page.goto("/?locale=sv-FI"); // sv-SE is installed; sv-FI is not
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
return {
|
||||
applyLabel: "Apply",
|
||||
applyLabel: t("filter.apply"),
|
||||
clearHref: ADMIN_CLIENTS_BASE,
|
||||
label: "Filter clients",
|
||||
label: t("admin.clients.filter"),
|
||||
pills,
|
||||
rows: [[
|
||||
{ label: "Search clients", name: "q", placeholder: "Search name or client ID…", type: "search", value: state.q },
|
||||
{ label: t("admin.clients.searchLabel"), name: "q", placeholder: t("admin.clients.searchPlaceholder"), type: "search", value: state.q },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
function listPagination(state: ListState, page: ReturnType<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("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
@@ -175,18 +177,20 @@ function listPagination(state: ListState, page: ReturnType<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("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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -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("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
return {
|
||||
applyLabel: "Apply",
|
||||
applyLabel: t("filter.apply"),
|
||||
clearHref: ADMIN_GROUPS_BASE,
|
||||
label: "Filter groups",
|
||||
label: t("admin.groups.filter"),
|
||||
pills,
|
||||
rows: [[
|
||||
{ label: "Search groups", name: "q", placeholder: "Search group name…", type: "search", value: state.q },
|
||||
{ label: t("admin.groups.searchLabel"), name: "q", placeholder: t("admin.groups.searchPlaceholder"), type: "search", value: state.q },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
function listPagination(state: ListState, page: ReturnType<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("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
@@ -197,14 +199,16 @@ export function buildGroupFormModel(opts: {
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
memberOptions: MemberOption[];
|
||||
t?: Translate;
|
||||
values?: { member?: string; name?: string };
|
||||
}) {
|
||||
const t = opts.t ?? ADMIN_EN;
|
||||
const nameField: FieldConfig = {
|
||||
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-layers",
|
||||
id: "name", label: "Group name", name: "name", required: true, value: opts.values?.name ?? "",
|
||||
autocomplete: "off", hint: t("admin.groups.field.nameHint"), icon: "i-layers",
|
||||
id: "name", label: t("admin.groups.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
|
||||
};
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: "New" }],
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: t("common.new") }],
|
||||
error: opts.error,
|
||||
form: {
|
||||
action: ADMIN_GROUPS_BASE,
|
||||
@@ -213,9 +217,9 @@ export function buildGroupFormModel(opts: {
|
||||
memberOptions: opts.memberOptions,
|
||||
nameField,
|
||||
selectedMember: opts.values?.member ?? "",
|
||||
submitLabel: "Create group",
|
||||
submitLabel: t("admin.groups.create"),
|
||||
},
|
||||
title: "New group",
|
||||
title: t("admin.groups.new"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -225,7 +229,9 @@ export function buildGroupDetailModel(opts: {
|
||||
error?: string;
|
||||
group: { name: string };
|
||||
members: MemberView[];
|
||||
t?: Translate;
|
||||
}) {
|
||||
const t = opts.t ?? ADMIN_EN;
|
||||
const name = opts.group.name;
|
||||
const base = detailHref(name);
|
||||
const taken = new Set(opts.members.map((m) => m.subject));
|
||||
@@ -233,7 +239,7 @@ export function buildGroupDetailModel(opts: {
|
||||
const options = opts.candidates.filter((c) => c.value !== self && !taken.has(c.value));
|
||||
return {
|
||||
add: { action: `${base}/members`, options },
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: name }],
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: name }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
delete: { action: `${base}/delete` },
|
||||
error: opts.error,
|
||||
@@ -288,7 +294,7 @@ function withGroups(inner: (deps: GroupsDeps) => Promise<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("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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -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("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
return {
|
||||
applyLabel: "Apply",
|
||||
applyLabel: t("filter.apply"),
|
||||
clearHref: ADMIN_PERMISSIONS_BASE,
|
||||
label: "Filter permissions",
|
||||
label: t("admin.permissions.filter"),
|
||||
pills,
|
||||
rows: [[
|
||||
{ label: "Search permissions", name: "q", placeholder: "Search permission name…", type: "search", value: state.q },
|
||||
{ label: t("admin.permissions.searchLabel"), name: "q", placeholder: t("admin.permissions.searchPlaceholder"), type: "search", value: state.q },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
function listPagination(state: ListState, page: ReturnType<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("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
@@ -182,14 +184,16 @@ export function buildPermissionFormModel(opts: {
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
memberOptions: MemberOption[];
|
||||
t?: Translate;
|
||||
values?: { member?: string; name?: string };
|
||||
}) {
|
||||
const t = opts.t ?? ADMIN_EN;
|
||||
const nameField: FieldConfig = {
|
||||
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield",
|
||||
id: "name", label: "Permission name", name: "name", required: true, value: opts.values?.name ?? "",
|
||||
autocomplete: "off", hint: t("admin.permissions.field.nameHint"), icon: "i-shield",
|
||||
id: "name", label: t("admin.permissions.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
|
||||
};
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: "New" }],
|
||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: t("common.new") }],
|
||||
error: opts.error,
|
||||
form: {
|
||||
action: ADMIN_PERMISSIONS_BASE,
|
||||
@@ -198,9 +202,9 @@ export function buildPermissionFormModel(opts: {
|
||||
memberOptions: opts.memberOptions,
|
||||
nameField,
|
||||
selectedMember: opts.values?.member ?? "",
|
||||
submitLabel: "Create permission",
|
||||
submitLabel: t("admin.permissions.create"),
|
||||
},
|
||||
title: "New permission",
|
||||
title: t("admin.permissions.new"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,14 +215,16 @@ export function buildPermissionDetailModel(opts: {
|
||||
error?: string;
|
||||
members: MemberView[];
|
||||
permission: { name: string };
|
||||
t?: Translate;
|
||||
}) {
|
||||
const t = opts.t ?? ADMIN_EN;
|
||||
const name = opts.permission.name;
|
||||
const base = detailHref(name);
|
||||
const taken = new Set(opts.members.map((m) => m.subject));
|
||||
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the permission itself
|
||||
return {
|
||||
add: { action: `${base}/members`, options },
|
||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: name }],
|
||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: name }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
delete: { action: `${base}/delete` },
|
||||
effective: opts.effective,
|
||||
@@ -263,7 +269,7 @@ function withRoles(inner: (deps: RolesDeps) => Promise<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("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) };
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
|
||||
// everything imports the host only through the #plugin-api barrel.
|
||||
|
||||
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api";
|
||||
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
|
||||
// This plugin's English (its catalog, then the host's — the screens reuse core words like Cancel and
|
||||
// Search), for a view model built outside a request: its unit tests. At runtime the handlers pass
|
||||
// ctx.t, which reads this catalog in the visitor's locale first, then the host's.
|
||||
export const ADMIN_EN: Translate = englishTranslator(enUS);
|
||||
|
||||
export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section
|
||||
export const ADMIN_USERS_BASE = "/admin/users";
|
||||
@@ -18,14 +24,14 @@ export type AdminScreen = "clients" | "groups" | "permissions" | "users";
|
||||
// non-admin), and current-marks the active item — so there is no `current`/`open` state here.
|
||||
export const ADMIN_NAV: NavNode = {
|
||||
children: [
|
||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
|
||||
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
|
||||
{ href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "Permissions" },
|
||||
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
|
||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users" },
|
||||
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups" },
|
||||
{ href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "admin.nav.permissions" },
|
||||
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients" },
|
||||
],
|
||||
icon: "i-shield",
|
||||
id: "admin",
|
||||
label: "Admin",
|
||||
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
|
||||
permission: ADMIN_PERMISSION,
|
||||
};
|
||||
|
||||
@@ -49,13 +55,13 @@ export async function guardedForm(ctx: RequestContext): Promise<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
|
||||
|
||||
@@ -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("common.edit") }],
|
||||
cells: [
|
||||
{ user: { initials: u.initials, name: u.name } },
|
||||
u.email,
|
||||
{ badge: { label: cap(u.state), tone: STATE_TONE[u.state] ?? "info" } },
|
||||
{ badge: { label: t(`admin.users.status.${u.state}`), tone: STATE_TONE[u.state] ?? "info" } },
|
||||
],
|
||||
name: u.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function listFilterBar(state: ListState, total: number) {
|
||||
function listFilterBar(state: ListState, total: number, t: Translate) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
if (state.status !== "all") pills.push({ label: "Status", remove: listHref(state, { page: 1, status: "all" }), value: cap(state.status) });
|
||||
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
if (state.status !== "all") pills.push({ label: t("admin.users.status.label"), remove: listHref(state, { page: 1, status: "all" }), value: t(`admin.users.status.${state.status}`) });
|
||||
return {
|
||||
applyLabel: "Apply filters",
|
||||
applyLabel: t("filter.apply"), // an untranslated core key still resolves: the host catalog is the fallback
|
||||
clearHref: ADMIN_USERS_BASE,
|
||||
label: "Filter users",
|
||||
label: t("admin.users.filter"),
|
||||
pills,
|
||||
rows: [[
|
||||
{ label: "Search users", name: "q", placeholder: "Search name or email…", type: "search", value: state.q },
|
||||
{ legend: "Status", name: "status", options: [
|
||||
{ count: total, label: "All", value: "all" },
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Inactive", value: "inactive" },
|
||||
{ label: t("admin.users.searchLabel"), name: "q", placeholder: t("admin.users.searchPlaceholder"), type: "search", value: state.q },
|
||||
{ legend: t("admin.users.status.label"), name: "status", options: [
|
||||
{ count: total, label: t("admin.users.status.all"), value: "all" },
|
||||
{ label: t("admin.users.status.active"), value: "active" },
|
||||
{ label: t("admin.users.status.inactive"), value: "inactive" },
|
||||
], type: "segmented", value: state.status },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
function listPagination(state: ListState, page: ReturnType<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("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
@@ -220,8 +220,10 @@ export function buildUserFormModel(opts: {
|
||||
error?: string;
|
||||
identity?: Identity | null;
|
||||
recovery?: RecoveryCode;
|
||||
t?: Translate;
|
||||
values?: Partial<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("common.edit") : t("common.new") }],
|
||||
edit: editing ? {
|
||||
deleteAction: `${idPath}/delete`,
|
||||
id: view!.id,
|
||||
nextLabel: view!.state === "inactive" ? "Reactivate" : "Deactivate",
|
||||
nextLabel: view!.state === "inactive" ? t("admin.users.reactivate") : t("admin.users.deactivate"),
|
||||
recoveryAction: `${idPath}/recovery`,
|
||||
state: view!.state,
|
||||
stateAction: `${idPath}/state`,
|
||||
} : undefined,
|
||||
error: opts.error,
|
||||
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? "Save changes" : "Create user" },
|
||||
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") },
|
||||
recovery: opts.recovery,
|
||||
title: editing ? "Edit user" : "New user",
|
||||
title: editing ? t("admin.users.edit") : t("admin.users.new"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,7 +276,7 @@ function withUser(inner: (deps: UsersDeps) => Promise<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("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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// The admin plugin's own catalog — the baseline its other locales are written against. Its keys
|
||||
// are looked up before the host's, so this plugin owns its words without prefixing them.
|
||||
|
||||
const messages = {
|
||||
"admin.capability.hydra": "Hydra OAuth2 admin",
|
||||
"admin.capability.keto": "Keto and Kratos identity admin",
|
||||
"admin.capability.kratos": "Kratos identity admin",
|
||||
|
||||
"admin.clients.column.id": "Client ID",
|
||||
"admin.clients.column.name": "Name",
|
||||
"admin.clients.column.type": "Type",
|
||||
"admin.clients.confidential": "Confidential",
|
||||
"admin.clients.consent.firstParty": "First-party (auto-granted)",
|
||||
"admin.clients.consent.label": "Consent",
|
||||
"admin.clients.consent.screen": "Shows the consent screen",
|
||||
"admin.clients.created": "Client registered",
|
||||
"admin.clients.createdNotice": "Client registered.",
|
||||
"admin.clients.delete": "Delete client",
|
||||
"admin.clients.deleteMessage": "Delete client {{name}}? Apps using it can no longer sign in through Plainpages.",
|
||||
"admin.clients.error.rejected": "Hydra rejected the client — check the redirect URIs and scopes.",
|
||||
"admin.clients.field.name": "Name",
|
||||
"admin.clients.field.redirectUris": "Redirect URIs",
|
||||
"admin.clients.field.redirectUrisHint": "One per line — where the app is sent back after sign-in.",
|
||||
"admin.clients.field.scopes": "Scopes",
|
||||
"admin.clients.field.scopesHint": "Space-separated scopes the client may request.",
|
||||
"admin.clients.field.typeHint":
|
||||
"Browser and mobile apps can't keep a secret — choose Public. Server-side apps that can store one — leave it Confidential.",
|
||||
"admin.clients.filter": "Filter clients",
|
||||
"admin.clients.pagination": "Clients pagination",
|
||||
"admin.clients.public": "Public",
|
||||
"admin.clients.publicPkce": "Public (PKCE)",
|
||||
"admin.clients.register": "Register",
|
||||
"admin.clients.registerClient": "Register client",
|
||||
"admin.clients.registerTitle": "Register client",
|
||||
"admin.clients.rereg": "To change a client, delete and re-register — this issues a new client ID and secret. The secret is shown only once, at registration.",
|
||||
"admin.clients.searchLabel": "Search clients",
|
||||
"admin.clients.searchPlaceholder": "Search name or client ID…",
|
||||
"admin.clients.secret": "Client secret",
|
||||
"admin.clients.secretHint": "Copy these now — the secret can't be shown again. Store them where the app reads its credentials.",
|
||||
"admin.clients.title": "OAuth2 clients",
|
||||
"admin.clients.validation.name": "Enter a name for the client.",
|
||||
"admin.clients.validation.redirectUri": "\"{{uri}}\" is not a valid redirect URI — use an absolute URL like https://app.example.com/callback.",
|
||||
"admin.clients.validation.redirectUris": "Add at least one redirect URI.",
|
||||
|
||||
"admin.common.chooseMember": "Choose a user or group…",
|
||||
"admin.common.group": "Group",
|
||||
"admin.common.member": "Member",
|
||||
"admin.common.type": "Type",
|
||||
"admin.common.user": "User",
|
||||
|
||||
"admin.groups.actions": "Group actions",
|
||||
"admin.groups.addMember": "Add a member",
|
||||
"admin.groups.allMembers": "All users and groups are already members.",
|
||||
"admin.groups.column.members": "Members",
|
||||
"admin.groups.column.name": "Group",
|
||||
"admin.groups.create": "Create group",
|
||||
"admin.groups.delete": "Delete group",
|
||||
"admin.groups.deleteMessage": "Delete group {{name}}? This removes the group and all its memberships.",
|
||||
"admin.groups.field.name": "Group name",
|
||||
"admin.groups.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
|
||||
"admin.groups.filter": "Filter groups",
|
||||
"admin.groups.firstMember": "First member",
|
||||
"admin.groups.firstMemberHint": "A group exists once it has a member; add more after creating it.",
|
||||
"admin.groups.members": "Members",
|
||||
"admin.groups.membersOf": "Members of {{name}}",
|
||||
"admin.groups.new": "New group",
|
||||
"admin.groups.noMembers": "No members yet.",
|
||||
"admin.groups.pagination": "Groups pagination",
|
||||
"admin.groups.searchLabel": "Search groups",
|
||||
"admin.groups.searchPlaceholder": "Search group name…",
|
||||
"admin.groups.title": "Groups",
|
||||
"admin.groups.validation.member": "Pick a member to add as the group's first member.",
|
||||
"admin.groups.validation.name": "Group names use lowercase letters, digits, dashes and underscores.",
|
||||
|
||||
"admin.nav.clients": "OAuth2 clients",
|
||||
"admin.nav.groups": "Groups",
|
||||
"admin.nav.permissions": "Permissions",
|
||||
"admin.nav.section": "Admin",
|
||||
"admin.nav.users": "Users",
|
||||
|
||||
"admin.notFound.message": "That item doesn't exist.",
|
||||
"admin.notFound.title": "Not found",
|
||||
|
||||
"admin.permissions.actions": "Permission actions",
|
||||
"admin.permissions.allAssigned": "All users and groups already have this permission.",
|
||||
"admin.permissions.assign": "Assign the permission",
|
||||
"admin.permissions.assignAction": "Assign",
|
||||
"admin.permissions.assignTo": "Assign to",
|
||||
"admin.permissions.assignedTo": "Assigned to",
|
||||
"admin.permissions.column.members": "Members",
|
||||
"admin.permissions.column.name": "Permission",
|
||||
"admin.permissions.create": "Create permission",
|
||||
"admin.permissions.delete": "Delete permission",
|
||||
"admin.permissions.deleteMessage": "Delete permission {{name}}? This revokes it from everyone it's assigned to.",
|
||||
"admin.permissions.error.adminUndeletable": "The admin permission can't be deleted — it would remove all admin access.",
|
||||
"admin.permissions.error.selfRevoke": "You can't revoke your own admin access.",
|
||||
"admin.permissions.effective": "Effective access",
|
||||
"admin.permissions.effectiveHint": "Everyone who holds this permission — directly or through a group (resolved by Keto).",
|
||||
"admin.permissions.field.name": "Permission name",
|
||||
"admin.permissions.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
|
||||
"admin.permissions.filter": "Filter permissions",
|
||||
"admin.permissions.new": "New permission",
|
||||
"admin.permissions.noEffective": "No users hold this permission yet.",
|
||||
"admin.permissions.noMembers": "Not assigned to anyone yet.",
|
||||
"admin.permissions.pagination": "Permissions pagination",
|
||||
"admin.permissions.revoke": "Revoke",
|
||||
"admin.permissions.searchLabel": "Search permissions",
|
||||
"admin.permissions.searchPlaceholder": "Search permission name…",
|
||||
"admin.permissions.title": "Permissions",
|
||||
"admin.permissions.validation.member": "Pick a user or group to assign the permission to.",
|
||||
"admin.permissions.validation.name": "Permission names use lowercase letters, digits, dashes and underscores.",
|
||||
|
||||
"admin.unavailable.message": "{{what}} is not configured on this deployment.",
|
||||
"admin.unavailable.title": "Admin unavailable",
|
||||
|
||||
"admin.users.actions": "Account actions",
|
||||
"admin.users.column.email": "Email",
|
||||
"admin.users.column.name": "Name",
|
||||
"admin.users.column.status": "Status",
|
||||
"admin.users.confirm": "Confirm action",
|
||||
"admin.users.create": "Create user",
|
||||
"admin.users.deactivate": "Deactivate",
|
||||
"admin.users.delete": "Delete user",
|
||||
"admin.users.deleteMessage": "Delete {{email}}? This permanently removes the account and can't be undone.",
|
||||
"admin.users.edit": "Edit user",
|
||||
"admin.users.error.create": "Could not create the user — check the email and try again.",
|
||||
"admin.users.error.duplicate": "A user with that email already exists.",
|
||||
"admin.users.error.save": "Could not save changes — check the fields and try again.",
|
||||
"admin.users.error.selfDeactivate": "You can't deactivate your own account.",
|
||||
"admin.users.error.selfDelete": "You can't delete your own account.",
|
||||
"admin.users.field.email": "Email",
|
||||
"admin.users.field.emailHint": "The login identifier — can't be changed here.",
|
||||
"admin.users.field.first": "First name",
|
||||
"admin.users.field.last": "Last name",
|
||||
"admin.users.field.password": "Password",
|
||||
"admin.users.field.passwordHint": "Optional — leave blank to have the user set one via a recovery code.",
|
||||
"admin.users.filter": "Filter users",
|
||||
"admin.users.new": "New user",
|
||||
"admin.users.pagination": "Users pagination",
|
||||
"admin.users.reactivate": "Reactivate",
|
||||
"admin.users.recovery.body": "Give it to the user — they enter it to set a new password (generate a fresh one if it has expired):",
|
||||
"admin.users.recovery.link": "the password-reset screen",
|
||||
"admin.users.recovery.generate": "Generate recovery code",
|
||||
"admin.users.recovery.title": "Recovery code generated",
|
||||
"admin.users.save": "Save changes",
|
||||
"admin.users.searchLabel": "Search users",
|
||||
"admin.users.searchPlaceholder": "Search name or email…",
|
||||
"admin.users.status.active": "Active",
|
||||
"admin.users.status.all": "All",
|
||||
"admin.users.status.inactive": "Inactive",
|
||||
"admin.users.status.label": "Status",
|
||||
"admin.users.title": "Users",
|
||||
};
|
||||
|
||||
export type AdminMessages = typeof messages;
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { AdminMessages } from "./en-US.ts";
|
||||
|
||||
const messages: AdminMessages = {
|
||||
"admin.capability.hydra": "Hydra OAuth2-administration",
|
||||
"admin.capability.keto": "Keto- och Kratos-identitetsadministration",
|
||||
"admin.capability.kratos": "Kratos identitetsadministration",
|
||||
|
||||
"admin.clients.column.id": "Klient-ID",
|
||||
"admin.clients.column.name": "Namn",
|
||||
"admin.clients.column.type": "Typ",
|
||||
"admin.clients.confidential": "Konfidentiell",
|
||||
"admin.clients.consent.firstParty": "Förstapart (godkänns automatiskt)",
|
||||
"admin.clients.consent.label": "Godkännande",
|
||||
"admin.clients.consent.screen": "Visar godkännandesidan",
|
||||
"admin.clients.created": "Klienten är registrerad",
|
||||
"admin.clients.createdNotice": "Klienten är registrerad.",
|
||||
"admin.clients.delete": "Radera klient",
|
||||
"admin.clients.deleteMessage": "Ta bort klienten {{name}}? Appar som använder den kan inte längre logga in via Plainpages.",
|
||||
"admin.clients.error.rejected": "Hydra nekade klienten — kontrollera omdirigerings-URI:erna och scopen.",
|
||||
"admin.clients.field.name": "Namn",
|
||||
"admin.clients.field.redirectUris": "Omdirigerings-URI:er",
|
||||
"admin.clients.field.redirectUrisHint": "En per rad — dit appen skickas tillbaka efter inloggning.",
|
||||
"admin.clients.field.scopes": "Scope",
|
||||
"admin.clients.field.scopesHint": "Mellanslagsseparerade scope som klienten får begära.",
|
||||
"admin.clients.field.typeHint":
|
||||
"Webbläsar- och mobilappar kan inte hålla en hemlighet — välj Publik. Serverappar som kan lagra en — låt stå som Konfidentiell.",
|
||||
"admin.clients.filter": "Filtrera klienter",
|
||||
"admin.clients.pagination": "Sidnavigering för klienter",
|
||||
"admin.clients.public": "Publik",
|
||||
"admin.clients.publicPkce": "Publik (PKCE)",
|
||||
"admin.clients.register": "Registrera",
|
||||
"admin.clients.registerClient": "Registrera klient",
|
||||
"admin.clients.registerTitle": "Registrera klient",
|
||||
"admin.clients.rereg":
|
||||
"För att ändra en klient: ta bort den och registrera på nytt — det ger ett nytt klient-ID och en ny hemlighet. Hemligheten visas bara en gång, vid registreringen.",
|
||||
"admin.clients.searchLabel": "Sök klienter",
|
||||
"admin.clients.searchPlaceholder": "Sök på namn eller klient-ID…",
|
||||
"admin.clients.secret": "Klienthemlighet",
|
||||
"admin.clients.secretHint": "Kopiera nu — hemligheten kan inte visas igen. Spara uppgifterna där appen läser dem.",
|
||||
"admin.clients.title": "OAuth2-klienter",
|
||||
"admin.clients.validation.name": "Ange ett namn för klienten.",
|
||||
"admin.clients.validation.redirectUri": "\"{{uri}}\" är inte en giltig omdirigerings-URI — använd en absolut URL som https://app.example.com/callback.",
|
||||
"admin.clients.validation.redirectUris": "Lägg till minst en omdirigerings-URI.",
|
||||
|
||||
"admin.common.chooseMember": "Välj en användare eller grupp…",
|
||||
"admin.common.group": "Grupp",
|
||||
"admin.common.member": "Medlem",
|
||||
"admin.common.type": "Typ",
|
||||
"admin.common.user": "Användare",
|
||||
|
||||
"admin.groups.actions": "Gruppåtgärder",
|
||||
"admin.groups.addMember": "Lägg till en medlem",
|
||||
"admin.groups.allMembers": "Alla användare och grupper är redan medlemmar.",
|
||||
"admin.groups.column.members": "Medlemmar",
|
||||
"admin.groups.column.name": "Grupp",
|
||||
"admin.groups.create": "Skapa grupp",
|
||||
"admin.groups.delete": "Radera grupp",
|
||||
"admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det tar bort gruppen och alla dess medlemskap.",
|
||||
"admin.groups.field.name": "Gruppnamn",
|
||||
"admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
|
||||
"admin.groups.filter": "Filtrera grupper",
|
||||
"admin.groups.firstMember": "Första medlem",
|
||||
"admin.groups.firstMemberHint": "En grupp finns så snart den har en medlem; lägg till fler efteråt.",
|
||||
"admin.groups.members": "Medlemmar",
|
||||
"admin.groups.membersOf": "Medlemmar i {{name}}",
|
||||
"admin.groups.new": "Ny grupp",
|
||||
"admin.groups.noMembers": "Inga medlemmar ännu.",
|
||||
"admin.groups.pagination": "Sidnavigering för grupper",
|
||||
"admin.groups.searchLabel": "Sök grupper",
|
||||
"admin.groups.searchPlaceholder": "Sök på gruppnamn…",
|
||||
"admin.groups.title": "Grupper",
|
||||
"admin.groups.validation.member": "Välj en medlem som gruppens första medlem.",
|
||||
"admin.groups.validation.name": "Gruppnamn använder små bokstäver, siffror, bindestreck och understreck.",
|
||||
|
||||
"admin.nav.clients": "OAuth2-klienter",
|
||||
"admin.nav.groups": "Grupper",
|
||||
"admin.nav.permissions": "Behörigheter",
|
||||
"admin.nav.section": "Administration",
|
||||
"admin.nav.users": "Användare",
|
||||
|
||||
"admin.notFound.message": "Objektet finns inte.",
|
||||
"admin.notFound.title": "Hittades inte",
|
||||
|
||||
"admin.permissions.actions": "Behörighetsåtgärder",
|
||||
"admin.permissions.allAssigned": "Alla användare och grupper har redan den här behörigheten.",
|
||||
"admin.permissions.assign": "Tilldela behörigheten",
|
||||
"admin.permissions.assignAction": "Tilldela",
|
||||
"admin.permissions.assignTo": "Tilldela till",
|
||||
"admin.permissions.assignedTo": "Tilldelad till",
|
||||
"admin.permissions.column.members": "Medlemmar",
|
||||
"admin.permissions.column.name": "Behörighet",
|
||||
"admin.permissions.create": "Skapa behörighet",
|
||||
"admin.permissions.delete": "Radera behörighet",
|
||||
"admin.permissions.deleteMessage": "Ta bort behörigheten {{name}}? Den återkallas från alla den är tilldelad till.",
|
||||
"admin.permissions.error.adminUndeletable": "Behörigheten admin kan inte tas bort — det skulle ta bort all administratörsåtkomst.",
|
||||
"admin.permissions.error.selfRevoke": "Du kan inte återkalla din egen administratörsåtkomst.",
|
||||
"admin.permissions.effective": "Faktisk åtkomst",
|
||||
"admin.permissions.effectiveHint": "Alla som har behörigheten — direkt eller via en grupp (uppslaget av Keto).",
|
||||
"admin.permissions.field.name": "Behörighetens namn",
|
||||
"admin.permissions.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
|
||||
"admin.permissions.filter": "Filtrera behörigheter",
|
||||
"admin.permissions.new": "Ny behörighet",
|
||||
"admin.permissions.noEffective": "Ingen användare har den här behörigheten ännu.",
|
||||
"admin.permissions.noMembers": "Inte tilldelad till någon ännu.",
|
||||
"admin.permissions.pagination": "Sidnavigering för behörigheter",
|
||||
"admin.permissions.revoke": "Återkalla",
|
||||
"admin.permissions.searchLabel": "Sök behörigheter",
|
||||
"admin.permissions.searchPlaceholder": "Sök på behörighetens namn…",
|
||||
"admin.permissions.title": "Behörigheter",
|
||||
"admin.permissions.validation.member": "Välj en användare eller grupp att tilldela behörigheten till.",
|
||||
"admin.permissions.validation.name": "Behörighetsnamn använder små bokstäver, siffror, bindestreck och understreck.",
|
||||
|
||||
"admin.unavailable.message": "{{what}} är inte konfigurerat i den här installationen.",
|
||||
"admin.unavailable.title": "Administrationen är otillgänglig",
|
||||
|
||||
"admin.users.actions": "Kontoåtgärder",
|
||||
"admin.users.column.email": "E-postadress",
|
||||
"admin.users.column.name": "Namn",
|
||||
"admin.users.column.status": "Status",
|
||||
"admin.users.confirm": "Bekräfta åtgärden",
|
||||
"admin.users.create": "Skapa användare",
|
||||
"admin.users.deactivate": "Inaktivera",
|
||||
"admin.users.delete": "Radera användare",
|
||||
"admin.users.deleteMessage": "Ta bort {{email}}? Kontot tas bort permanent och det går inte att ångra.",
|
||||
"admin.users.edit": "Redigera användare",
|
||||
"admin.users.error.create": "Användaren kunde inte skapas — kontrollera e-postadressen och försök igen.",
|
||||
"admin.users.error.duplicate": "Det finns redan en användare med den e-postadressen.",
|
||||
"admin.users.error.save": "Ändringarna kunde inte sparas — kontrollera fälten och försök igen.",
|
||||
"admin.users.error.selfDeactivate": "Du kan inte inaktivera ditt eget konto.",
|
||||
"admin.users.error.selfDelete": "Du kan inte ta bort ditt eget konto.",
|
||||
"admin.users.field.email": "E-postadress",
|
||||
"admin.users.field.emailHint": "Inloggningsidentiteten — den kan inte ändras här.",
|
||||
"admin.users.field.first": "Förnamn",
|
||||
"admin.users.field.last": "Efternamn",
|
||||
"admin.users.field.password": "Lösenord",
|
||||
"admin.users.field.passwordHint": "Frivilligt — lämna tomt så får användaren sätta det själv via en återställningskod.",
|
||||
"admin.users.filter": "Filtrera användare",
|
||||
"admin.users.new": "Ny användare",
|
||||
"admin.users.pagination": "Sidnavigering för användare",
|
||||
"admin.users.reactivate": "Aktivera igen",
|
||||
"admin.users.recovery.body": "Ge den till användaren — koden anges för att sätta ett nytt lösenord (skapa en ny om den hunnit gå ut):",
|
||||
"admin.users.recovery.link": "sidan för lösenordsåterställning",
|
||||
"admin.users.recovery.generate": "Skapa återställningskod",
|
||||
"admin.users.recovery.title": "Återställningskod skapad",
|
||||
"admin.users.save": "Spara ändringar",
|
||||
"admin.users.searchLabel": "Sök användare",
|
||||
"admin.users.searchPlaceholder": "Sök på namn eller e-postadress…",
|
||||
"admin.users.status.active": "Aktiv",
|
||||
"admin.users.status.all": "Alla",
|
||||
"admin.users.status.inactive": "Inaktiv",
|
||||
"admin.users.status.label": "Status",
|
||||
"admin.users.title": "Användare",
|
||||
};
|
||||
|
||||
export default messages;
|
||||
@@ -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,
|
||||
|
||||
@@ -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="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -10,20 +10,20 @@
|
||||
<% if (locals.error) { -%>
|
||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<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("common.cancel") %></a>
|
||||
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
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>
|
||||
<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>
|
||||
<a class="btn" href="<%= localeHref(locals.cancelHref) %>"><%= t("common.cancel") %></a>
|
||||
<form method="post" action="<%= localeHref(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>
|
||||
</div>
|
||||
|
||||
@@ -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("table.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="<%= localeHref(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("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="<%= localeHref(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("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="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
<% if (locals.error) { -%>
|
||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<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("common.cancel") %></a>
|
||||
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -19,20 +19,20 @@
|
||||
<%- 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("table.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="<%= localeHref(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">
|
||||
<h2 class="card-title" id="effective-h">Effective access</h2>
|
||||
<p class="field-hint">Everyone who holds this permission — directly or through a group (resolved by Keto).</p>
|
||||
<h2 class="card-title" id="effective-h"><%= t("admin.permissions.effective") %></h2>
|
||||
<p class="field-hint"><%= t("admin.permissions.effectiveHint") %></p>
|
||||
<% if (effective.length) { -%>
|
||||
<ul class="plain-list">
|
||||
<% effective.forEach((u) => { -%>
|
||||
@@ -40,18 +40,18 @@
|
||||
<% }) -%>
|
||||
</ul>
|
||||
<% } else { -%>
|
||||
<p class="cell-muted">No users hold this permission yet.</p>
|
||||
<p class="cell-muted"><%= t("admin.permissions.noEffective") %></p>
|
||||
<% } -%>
|
||||
</section>
|
||||
<section class="form-card" aria-labelledby="add-h">
|
||||
<h2 class="card-title" id="add-h">Assign the permission</h2>
|
||||
<h2 class="card-title" id="add-h"><%= t("admin.permissions.assign") %></h2>
|
||||
<% if (add.options.length) { -%>
|
||||
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Assign</button></form>
|
||||
<form class="inline-form" method="post" action="<%= localeHref(add.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("admin.permissions.assignAction") %></button></form>
|
||||
<% } else { -%>
|
||||
<p class="cell-muted">All users and groups already have this permission.</p>
|
||||
<p class="cell-muted"><%= t("admin.permissions.allAssigned") %></p>
|
||||
<% } -%>
|
||||
</section>
|
||||
<section class="form-card admin-actions" aria-label="Permission actions">
|
||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete permission</a>
|
||||
<section class="form-card admin-actions" aria-label="<%= t("admin.permissions.actions") %>">
|
||||
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.permissions.delete") %></a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
<% if (locals.error) { -%>
|
||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<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("common.cancel") %></a>
|
||||
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -14,23 +14,23 @@
|
||||
<%- 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") %> <a href="<%= localeHref("/recovery") %>"><%= t("admin.users.recovery.link") %></a></span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
<% form.fields.forEach((field) => { -%>
|
||||
<%- include("partials/field", field) %>
|
||||
<% }) -%>
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
||||
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("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>
|
||||
<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>
|
||||
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
|
||||
<form method="post" action="<%= localeHref(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="<%= localeHref(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="<%= localeHref(edit.deleteAction) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a>
|
||||
</section>
|
||||
<% } -%>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// This plugin's own catalog, and the baseline its other locales are written against. Keys are
|
||||
// looked up here first and fall back to the host's, so a plugin owns its words without prefixing
|
||||
// them, and `shifts.count` shows the plural form (host: README → Translating).
|
||||
|
||||
import type { PluralMessage } from "#plugin-api";
|
||||
|
||||
const messages = {
|
||||
"scheduling.field.assignee": "Assignee",
|
||||
"scheduling.field.end": "End",
|
||||
"scheduling.field.start": "Start",
|
||||
"scheduling.field.title": "Shift title",
|
||||
"scheduling.filter.label": "Filter shifts",
|
||||
"scheduling.filter.searchLabel": "Search shifts",
|
||||
"scheduling.filter.searchPlaceholder": "Search title or assignee…",
|
||||
"scheduling.form.submit": "Create shift",
|
||||
"scheduling.nav.overview": "Overview",
|
||||
"scheduling.nav.section": "Scheduling",
|
||||
"scheduling.nav.shifts": "Shifts",
|
||||
"scheduling.new.title": "New shift",
|
||||
"scheduling.overview.lead":
|
||||
"Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <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;
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { SchedulingMessages } from "./en-US.ts";
|
||||
|
||||
const messages: SchedulingMessages = {
|
||||
"scheduling.field.assignee": "Tilldelad",
|
||||
"scheduling.field.end": "Slut",
|
||||
"scheduling.field.start": "Start",
|
||||
"scheduling.field.title": "Passets namn",
|
||||
"scheduling.filter.label": "Filtrera pass",
|
||||
"scheduling.filter.searchLabel": "Sök pass",
|
||||
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
|
||||
"scheduling.form.submit": "Skapa pass",
|
||||
"scheduling.nav.overview": "Översikt",
|
||||
"scheduling.nav.section": "Schemaläggning",
|
||||
"scheduling.nav.shifts": "Pass",
|
||||
"scheduling.new.title": "Nytt pass",
|
||||
"scheduling.overview.lead":
|
||||
"Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <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;
|
||||
@@ -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>`.
|
||||
|
||||
@@ -4,20 +4,23 @@ import { Readable } from "node:stream";
|
||||
import test from "node:test";
|
||||
// Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
||||
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
||||
import { GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api";
|
||||
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
import {
|
||||
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
||||
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
||||
} from "./shifts.ts";
|
||||
|
||||
const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them
|
||||
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
||||
|
||||
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||
return {
|
||||
chrome: CHROME, user: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||
permissions: opts.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
chrome: CHROME, user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
||||
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
|
||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
||||
|
||||
// One import from the host's #plugin-api barrel — the stable author surface (see README.md → Building plugins).
|
||||
import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, tracedFetch } from "#plugin-api";
|
||||
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "#plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
|
||||
// The plugin's own English (its catalog, then the host's), for a view model built outside a request:
|
||||
// its unit tests. At runtime a handler passes ctx.t, which reads this catalog in the visitor's
|
||||
// locale first, then the host's.
|
||||
const EN: Translate = englishTranslator(enUS);
|
||||
|
||||
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||
@@ -87,58 +93,63 @@ function toShift(raw: unknown): Shift {
|
||||
|
||||
// ---- view models (pure; the EJS views read these) -----------------------------------
|
||||
|
||||
export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[] }) {
|
||||
export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[]; t?: Translate }) {
|
||||
const t = opts.t ?? EN;
|
||||
return {
|
||||
breadcrumbs: [{ label: "Shifts" }], // SHIFTS_PATH is the list itself; the form links back to it as "Shifts"
|
||||
breadcrumbs: [{ label: t("scheduling.shifts.title") }], // SHIFTS_PATH is the list itself; the form links back to it
|
||||
canWrite: opts.canWrite,
|
||||
chrome: opts.chrome,
|
||||
// A plural message: one catalog key, the right form per locale and count (Intl.PluralRules).
|
||||
count: t("scheduling.shifts.count", { count: opts.shifts.length }),
|
||||
...(opts.error ? { error: opts.error } : {}),
|
||||
filterBar: {
|
||||
applyLabel: "Search",
|
||||
applyLabel: t("filter.search"),
|
||||
clearHref: SHIFTS_PATH,
|
||||
label: "Filter shifts",
|
||||
pills: opts.q ? [{ label: "Search", remove: SHIFTS_PATH, value: opts.q }] : [],
|
||||
label: t("scheduling.filter.label"),
|
||||
pills: opts.q ? [{ label: t("filter.search"), remove: SHIFTS_PATH, value: opts.q }] : [],
|
||||
rows: [[
|
||||
{ label: "Search shifts", name: "q", placeholder: "Search title or assignee…", type: "search", value: opts.q },
|
||||
{ label: t("scheduling.filter.searchLabel"), name: "q", placeholder: t("scheduling.filter.searchPlaceholder"), type: "search", value: opts.q },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
},
|
||||
newHref: `${SHIFTS_PATH}/new`,
|
||||
table: {
|
||||
caption: "Shifts",
|
||||
columns: [{ label: "Shift" }, { label: "Assignee" }, { label: "Start" }, { label: "End" }],
|
||||
caption: t("scheduling.shifts.title"),
|
||||
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.assignee") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
|
||||
rows: opts.shifts.map((s) => ({
|
||||
cells: [{ rowHeader: { text: s.title } }, s.assignee, s.start, s.end],
|
||||
name: s.title,
|
||||
})),
|
||||
},
|
||||
title: "Shifts",
|
||||
title: t("scheduling.shifts.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFormModel(opts: { chrome: PageChrome; errors?: Record<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("common.cancel"),
|
||||
fields: [
|
||||
field({ icon: "i-cal", id: "title", label: "Shift title", value: v.title ?? "" }),
|
||||
field({ icon: "i-user", id: "assignee", label: "Assignee", value: v.assignee ?? "" }),
|
||||
field({ id: "start", label: "Start", type: "datetime-local", value: v.start ?? "" }),
|
||||
field({ id: "end", label: "End", type: "datetime-local", value: v.end ?? "" }),
|
||||
field({ icon: "i-cal", id: "title", label: t("scheduling.field.title"), value: v.title ?? "" }),
|
||||
field({ icon: "i-user", id: "assignee", label: t("scheduling.field.assignee"), value: v.assignee ?? "" }),
|
||||
field({ id: "start", label: t("scheduling.field.start"), type: "datetime-local", value: v.start ?? "" }),
|
||||
field({ id: "end", label: t("scheduling.field.end"), type: "datetime-local", value: v.end ?? "" }),
|
||||
],
|
||||
submitLabel: "Create shift",
|
||||
submitLabel: t("scheduling.form.submit"),
|
||||
},
|
||||
title: "New shift",
|
||||
title: t("scheduling.new.title"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,10 +166,10 @@ export function readInput(form: URLSearchParams): ShiftInput {
|
||||
|
||||
// Required-field validation → { field: message } or null. Kept deliberately small; the upstream
|
||||
// owns the real domain rules (overlap, capacity, …) and rejects with a 4xx the handler surfaces.
|
||||
export function validate(input: ShiftInput): Record<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 +184,16 @@ export function listShifts(upstream: ShiftsUpstream): RouteHandler {
|
||||
shifts = await upstream.list();
|
||||
} catch (err) {
|
||||
ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); // plugin logging via ctx.log
|
||||
error = "Couldn't reach the scheduling service — try again shortly.";
|
||||
error = ctx.t("scheduling.upstream.list");
|
||||
}
|
||||
const needle = q.toLowerCase();
|
||||
const rows = needle ? shifts.filter((s) => s.title.toLowerCase().includes(needle) || s.assignee.toLowerCase().includes(needle)) : shifts;
|
||||
return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows }), view: "shifts" };
|
||||
return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows, t: ctx.t }), view: "shifts" };
|
||||
};
|
||||
}
|
||||
|
||||
export function newShiftForm(): RouteHandler {
|
||||
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome }), view: "shift-new" });
|
||||
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" });
|
||||
}
|
||||
|
||||
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
|
||||
@@ -191,7 +202,14 @@ export function newShiftForm(): RouteHandler {
|
||||
// else a prompt to sign in. ctx.user may be null here, so read the permission via can() (zero I/O).
|
||||
export function overview(): RouteHandler {
|
||||
return (ctx) => ({
|
||||
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
|
||||
data: {
|
||||
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
|
||||
canRead: can(ctx, READ),
|
||||
chrome: ctx.chrome,
|
||||
shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links
|
||||
signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`),
|
||||
title: ctx.t("scheduling.overview.title"),
|
||||
},
|
||||
view: "overview",
|
||||
});
|
||||
}
|
||||
@@ -202,13 +220,13 @@ export function createShift(upstream: ShiftsUpstream): RouteHandler {
|
||||
// A write is a first-party form, so guard it with the host's double-submit token (ctx.verifyCsrf).
|
||||
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token");
|
||||
const input = readInput(form);
|
||||
const errors = validate(input);
|
||||
if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, values: input }), status: 400, view: "shift-new" };
|
||||
const errors = validate(input, ctx.t);
|
||||
if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, t: ctx.t, values: input }), status: 400, view: "shift-new" };
|
||||
try {
|
||||
await upstream.create(input);
|
||||
} catch (err) {
|
||||
ctx.log.warn("scheduling shift create failed (upstream)", { error: String(err) });
|
||||
return { data: buildFormModel({ chrome: ctx.chrome, formError: "Couldn't save the shift — the scheduling service is unavailable.", values: input }), status: 502, view: "shift-new" };
|
||||
return { data: buildFormModel({ chrome: ctx.chrome, formError: ctx.t("scheduling.upstream.create"), t: ctx.t, values: input }), status: 502, view: "shift-new" };
|
||||
}
|
||||
ctx.log.info("scheduling shift created", { assignee: input.assignee, title: input.title });
|
||||
return { redirect: SHIFTS_PATH }; // POST-redirect-GET
|
||||
|
||||
@@ -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;
|
||||
-%>
|
||||
@@ -9,13 +9,13 @@
|
||||
<% if (locals.formError) { -%>
|
||||
<%- include("partials/alert", { text: locals.formError, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
<% form.fields.forEach((field) => { -%>
|
||||
<%- 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,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,
|
||||
|
||||
@@ -500,6 +500,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
}
|
||||
.menu-item-form { display: contents; } /* form wraps the Sign-out button without changing layout */
|
||||
.menu-item:hover { background: var(--surface-2); }
|
||||
.menu-item[aria-current] { font-weight: 600; color: var(--text); } /* the language you are in */
|
||||
.menu-item.danger { color: var(--neg); }
|
||||
.menu-item .ico { color: var(--text-faint); }
|
||||
.menu-item.danger .ico { color: var(--neg); }
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
+19
-12
@@ -7,6 +7,7 @@ import { readFormBody } from "../http/body.ts";
|
||||
import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { CSRF_FIELD } from "./csrf.ts";
|
||||
import { chosenLocale } from "../i18n/locale.ts";
|
||||
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
|
||||
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
@@ -29,7 +30,7 @@ export interface AuthRouteDeps {
|
||||
}
|
||||
|
||||
const TEXT_PLAIN = { "content-type": "text/plain; charset=utf-8" };
|
||||
const FORBIDDEN: RouteResult = { data: { title: "Forbidden" }, status: 403, view: "403" };
|
||||
const FORBIDDEN: RouteResult = { status: 403, view: "403" };
|
||||
|
||||
// Scheme + host for a self-referencing absolute URL (Kratos/Hydra return targets). Host reflects
|
||||
// what the browser used (so it matches the allow-lists); scheme follows SECURE_COOKIES. A spoofed
|
||||
@@ -60,10 +61,16 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
|
||||
// as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com".
|
||||
const raw = ctx.url.searchParams.get("return_to");
|
||||
const local = localPath(raw);
|
||||
const chosen = chosenLocale(ctx);
|
||||
let returnTo: string | undefined;
|
||||
if (local) {
|
||||
if (local || chosen) {
|
||||
// The flow's return target is the host's, not Kratos' — so the language the visitor picked
|
||||
// on the sign-in page survives the round-trip through Kratos and lands on the page after
|
||||
// it. Without this the most-travelled path in the product (pick Swedish → sign in) drops
|
||||
// straight back to Accept-Language.
|
||||
const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`);
|
||||
complete.searchParams.set("return_to", local);
|
||||
if (local) complete.searchParams.set("return_to", ctx.localeHref(local));
|
||||
if (chosen) complete.searchParams.set("locale", chosen);
|
||||
returnTo = complete.toString();
|
||||
} else if (raw) returnTo = raw;
|
||||
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
|
||||
@@ -87,14 +94,14 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
|
||||
// documented, so render an honest 503 rather than the catch-all "error on our end" 500.
|
||||
if (!(err instanceof KratosError) || err.status >= 500) {
|
||||
ctx.log.warn("auth flow failed (Ory unreachable?)", { error: String(err), path: pathname });
|
||||
return { data: { title: "Sign-in unavailable" }, status: 503, view: "503" };
|
||||
return { status: 503, view: "503" };
|
||||
}
|
||||
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
|
||||
}
|
||||
// Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
|
||||
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
|
||||
csrf.setCookie();
|
||||
return { data: { chrome: ctx.chrome, flow: buildFlowView(flow, flowType) }, view: "auth" };
|
||||
return { data: { chrome: ctx.chrome, flow: buildFlowView(flow, flowType, ctx.t) }, view: "auth" };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,7 +121,7 @@ function oauthLogin(deps: { hydra: HydraAdmin; kratos: KratosPublic }, secureCoo
|
||||
// A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login, re-used URL) is
|
||||
// user-reachable: tell them to restart rather than 500. A 5xx (Hydra down) rethrows → 500.
|
||||
if (err instanceof HydraError && err.status < 500) {
|
||||
return { headers: TEXT_PLAIN, html: "This sign-in request has expired. Please start again from the application you were signing in to.", status: 400 };
|
||||
return { headers: TEXT_PLAIN, html: ctx.t("oauth.loginExpired"), status: 400 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -122,9 +129,9 @@ function oauthLogin(deps: { hydra: HydraAdmin; kratos: KratosPublic }, secureCoo
|
||||
}
|
||||
|
||||
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500 (as /oauth2/login).
|
||||
function consentError(err: unknown): RouteResult {
|
||||
function consentError(err: unknown, ctx: RequestContext): RouteResult {
|
||||
if (err instanceof HydraError && err.status < 500) {
|
||||
return { headers: TEXT_PLAIN, html: "This authorization request has expired. Please start again from the application you were signing in to.", status: 400 };
|
||||
return { headers: TEXT_PLAIN, html: ctx.t("oauth.consentExpired"), status: 400 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -143,7 +150,7 @@ function consentScreen(deps: { hydra: HydraAdmin; kratos: KratosPublic }, brand:
|
||||
csrf.setCookie();
|
||||
return { data: { brand, consent: view, csrfField: CSRF_FIELD, csrfToken: csrf.token }, view: "oauth-consent" };
|
||||
} catch (err) {
|
||||
return consentError(err);
|
||||
return consentError(err, ctx);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -164,7 +171,7 @@ function consentDecision(deps: { hydra: HydraAdmin; kratos: KratosPublic }): Bui
|
||||
: await rejectConsent(deps, challenge);
|
||||
return { redirect };
|
||||
} catch (err) {
|
||||
return consentError(err);
|
||||
return consentError(err, ctx);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -184,7 +191,7 @@ function oauthLogout(hydra: HydraAdmin): BuiltinRoute["handler"] {
|
||||
} catch (err) {
|
||||
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500.
|
||||
if (err instanceof HydraError && err.status < 500) {
|
||||
return { headers: TEXT_PLAIN, html: "This logout request has expired. Please start again from the application you were signing out of.", status: 400 };
|
||||
return { headers: TEXT_PLAIN, html: ctx.t("oauth.logoutExpired"), status: 400 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -229,7 +236,7 @@ function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["han
|
||||
// canonical-host redirect prevents the common cause (a lost cross-host CSRF cookie); this is the
|
||||
// honest fallback for any genuine flow error. The id is shown only for support reference.
|
||||
const errorSink = (ctx: RequestContext): RouteResult =>
|
||||
({ data: { id: ctx.url.searchParams.get("id"), title: "Sign-in problem" }, view: "error" });
|
||||
({ data: { id: ctx.url.searchParams.get("id") }, view: "error" });
|
||||
|
||||
export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }: AuthRouteDeps): BuiltinRoute[] {
|
||||
const routes: BuiltinRoute[] = [];
|
||||
|
||||
+143
-5
@@ -22,6 +22,9 @@ import { SESSION_COOKIE } from "../auth/login.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
|
||||
import adminManifest from "../../examples/plugins/admin/plugin.ts";
|
||||
import { createI18n } from "../i18n/runtime.ts";
|
||||
import type { MenuConfig } from "../ui/menu-config.ts";
|
||||
import { loadI18n } from "../i18n/load.ts";
|
||||
|
||||
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
||||
// The admin screens ship as a drop-in example plugin; the HTTP-level admin tests mount it via
|
||||
@@ -369,8 +372,8 @@ test("/error renders a themed sign-in error page (Kratos' flow error sink), not
|
||||
|
||||
test("renders the 500 HTML page when a handler throws", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-views-"));
|
||||
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // the dashboard view
|
||||
cpSync(join(viewsDir, "500.ejs"), join(dir, "500.ejs"));
|
||||
cpSync(viewsDir, dir, { recursive: true }); // the real views: 500.ejs includes the language picker
|
||||
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir });
|
||||
try {
|
||||
await new Promise<void>((resolve) => app.listen(0, resolve));
|
||||
@@ -822,7 +825,7 @@ test("renders a fetched flow as the themed auth page: fields post straight to Kr
|
||||
assert.match(html, /<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 +862,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 +1348,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 +1400,136 @@ 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/);
|
||||
});
|
||||
|
||||
test("a plugin that owns a landing page, or short-circuits a hook, translates from its own catalog", async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-i18n-plugin-"));
|
||||
mkdirSync(join(dir, "demo", "i18n"), { recursive: true });
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
writeFileSync(join(dir, "demo", "i18n", "en-US.ts"), 'const m = { "demo.hello": "Hello from the plugin" };\nexport default m;\n');
|
||||
|
||||
// Every plugin-owned render path: the public landing, the gated dashboard, and a hook short-circuit.
|
||||
const demo: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
dashboard: (ctx) => ({ html: ctx.t("demo.hello") }),
|
||||
home: (ctx) => ({ html: ctx.t("demo.hello") }),
|
||||
hooks: { onRequest: (ctx) => (ctx.url.pathname === "/hooked" ? { html: ctx.t("demo.hello") } : undefined) },
|
||||
id: "demo",
|
||||
};
|
||||
const i18n = createI18n(await loadI18n({ pluginIds: ["demo"], pluginsDir: dir }));
|
||||
const app = createApp({ i18n, jwks: staticJwks([ecJwk]), plugins: [demo], pluginsDir: dir });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const cookie = `${SESSION_COOKIE}=${mintJwt({ email: "a@b", exp: nowSec + 600, permissions: [], sub: "u1" })}`;
|
||||
|
||||
assert.equal(await (await fetch(`${url}/`)).text(), "Hello from the plugin");
|
||||
assert.equal(await (await fetch(`${url}/hooked`)).text(), "Hello from the plugin");
|
||||
assert.equal(await (await fetch(`${url}/dashboard`, { headers: { cookie } })).text(), "Hello from the plugin");
|
||||
});
|
||||
|
||||
test("an error page renders without composing the menu — it exists for when the shell's data is what failed", async (t) => {
|
||||
// The chrome getter is lazy on purpose; a render that reads no chrome must not trigger it, or a
|
||||
// broken menu takes the error pages down with it.
|
||||
let built = 0;
|
||||
const menu: MenuConfig = { branding: { get name() { built++; return "Plainpages"; } }, override: {} };
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), menu });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
const res = await fetch(`${url}/no-such-page`);
|
||||
assert.equal(res.status, 404);
|
||||
assert.match(await res.text(), /Page not found/);
|
||||
assert.equal(built, 0);
|
||||
});
|
||||
|
||||
test("a POST-rendered page still offers the language picker, pointed at a page that answers GET", async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-post-lang-"));
|
||||
mkdirSync(join(dir, "demo", "views"), { recursive: true });
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
// The view renders the picker exactly as the shell does.
|
||||
writeFileSync(join(dir, "demo", "views", "page.ejs"), `<%- include("partials/locale-switch") %>`);
|
||||
const demo: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
id: "demo",
|
||||
routes: [
|
||||
{ handler: () => ({ view: "page" }), method: "GET", path: "/thing" },
|
||||
{ handler: () => ({ view: "page" }), method: "POST", path: "/thing" },
|
||||
{ handler: () => ({ view: "page" }), method: "POST", path: "/thing/act" }, // POST-only: no GET sibling
|
||||
],
|
||||
};
|
||||
const app = createApp({ i18n: createI18n(await loadI18n()), plugins: [demo], pluginsDir: dir });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const post = (path: string, headers: Record<string, string> = {}) => fetch(url + path, { headers, method: "POST" });
|
||||
|
||||
// A POST whose path also answers GET → the picker points at that page.
|
||||
assert.match(await (await post("/demo/thing?locale=sv-SE")).text(), /href="\/demo\/thing\?locale=en-US"/);
|
||||
// A POST-only path → the page the form was submitted from, so the link can't dead-end on a 405.
|
||||
const fromForm = await post("/demo/thing/act?locale=sv-SE", { referer: `${url}/demo/thing?locale=sv-SE` });
|
||||
assert.match(await fromForm.text(), /href="\/demo\/thing\?locale=en-US"/);
|
||||
// …and with no referer to fall back on, the front page.
|
||||
assert.match(await (await post("/demo/thing/act?locale=sv-SE")).text(), /href="\/\?locale=en-US"/);
|
||||
});
|
||||
|
||||
+125
-26
@@ -3,7 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts";
|
||||
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type RequestCsrf } from "./builtin-routes.ts";
|
||||
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
||||
import { buildContext, type RequestContext, type User } from "./context.ts";
|
||||
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
|
||||
@@ -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, type I18nRequest } 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";
|
||||
@@ -26,6 +30,7 @@ import type { SystemCapabilities } from "../plugin-host/system.ts";
|
||||
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||
import { securityHeaders } from "./security-headers.ts";
|
||||
import { localPath } from "./safe-url.ts";
|
||||
import { routePublic, serveStatic } from "./static.ts";
|
||||
import { renderPluginView } from "../plugin-host/view-resolver.ts";
|
||||
|
||||
@@ -40,6 +45,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 +77,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 +116,37 @@ 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/localeParam) 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.
|
||||
// They are merged LAST: these names are reserved (README → Building plugins), and a handler that
|
||||
// happens to use one loses that key rather than breaking the shell that renders around it.
|
||||
// Where the language picker on this page should point. Normally the page itself; after a POST
|
||||
// that URL may answer no GET (POST /admin/users/:id/delete has no GET sibling), so fall back to
|
||||
// the page the form was submitted from, then to the front page — the picker is on every page, so
|
||||
// every one of its links has to land somewhere real.
|
||||
const switchBase = (req: IncomingMessage, url: URL): string => {
|
||||
const method = (req.method ?? "GET").toUpperCase();
|
||||
if (method === "GET" || method === "HEAD") return `${url.pathname}${url.search}`;
|
||||
const answersGet = matchRoute(plugins, "GET", url.pathname) !== null
|
||||
|| matchBuiltinRoute(builtinRoutes, "GET", url.pathname) !== undefined;
|
||||
return answersGet ? url.pathname : (sameOriginPath(req) ?? "/");
|
||||
};
|
||||
|
||||
// Named field by field on purpose: spreading the context would trigger its lazy `chrome` getter,
|
||||
// composing the menu for every render — including the standalone error pages, which exist to
|
||||
// render when the shell's own data is what failed.
|
||||
const localsOf = (ctx: RequestContext): I18nRequest => ({
|
||||
locale: ctx.locale,
|
||||
localeHref: ctx.localeHref,
|
||||
locales: ctx.locales,
|
||||
switchBase: switchBase(ctx.req, ctx.url),
|
||||
t: ctx.t,
|
||||
url: ctx.url,
|
||||
});
|
||||
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
||||
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
||||
|
||||
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
@@ -116,12 +156,14 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for
|
||||
// any form it ships). Else the built-in intro page with prominent sign-in / register links
|
||||
// (`user` picks "go to dashboard" vs sign-in; the shell's Sign-out form needs the CSRF cookie).
|
||||
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => {
|
||||
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
||||
csrf.setCookie();
|
||||
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));
|
||||
// The plugin owns this page, so it runs on its own context — its catalog first, then core.
|
||||
const pluginCtx = contextFor(homePlugin.id);
|
||||
const result = (await homePlugin.home(pluginCtx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, contextFor, result);
|
||||
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, homePlugin.id), pluginCtx.localeHref);
|
||||
return null;
|
||||
}
|
||||
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
|
||||
@@ -131,17 +173,18 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// in, remembering /dashboard as return_to. A plugin may fully own it via `dashboard` — its
|
||||
// handler renders against its own views, same path as a plugin route. Else the built-in
|
||||
// mock-data People list with the one global menu (ctx.chrome.nav) + branding from config/menu.ts.
|
||||
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => {
|
||||
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
||||
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
|
||||
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
||||
csrf.setCookie();
|
||||
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));
|
||||
const pluginCtx = contextFor(dashboardPlugin.id); // as serveHome: the owner's own translator
|
||||
const result = (await dashboardPlugin.dashboard(pluginCtx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, contextFor, result);
|
||||
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.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 +199,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, { ...data, ...ENGLISH_LOCALS });
|
||||
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).
|
||||
@@ -172,6 +219,12 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rendered pages content-negotiate on Accept-Language, so a cache in front of us must key on
|
||||
// it — otherwise the first visitor's language is served to everyone. Set after the static
|
||||
// branch above: an asset is the same bytes in every language, and a Vary there would fragment
|
||||
// its cache entry per raw header string.
|
||||
res.setHeader("vary", "accept-language");
|
||||
|
||||
// Canonical host (APP_URL): a visitor who reached us on a different host (localhost vs
|
||||
// 127.0.0.1, a secondary domain) is sent to the configured origin, path + query preserved, so
|
||||
// the browser, the themed forms, and the cross-origin Kratos POST all share one cookie host —
|
||||
@@ -186,6 +239,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,19 +283,33 @@ 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 }));
|
||||
|
||||
// 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 } : {}) });
|
||||
// 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 the built-in routes. A plugin-owned render
|
||||
// (a landing slot, a hook short-circuit, a plugin route) gets `contextFor(id)` instead, so its
|
||||
// own catalog is what `ctx.t` reads.
|
||||
const ctx = buildContext(req, res, { chrome, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
||||
buildContext(req, res, { chrome, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
||||
renderPage = viewsFor(ctx);
|
||||
|
||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||
if (anyRequestHooks) {
|
||||
const short = await runRequestHooks(plugins, ctx);
|
||||
const short = await runRequestHooks(plugins, contextFor);
|
||||
if (short) {
|
||||
// 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(short.ctx, short.plugin.id), carryLocale);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -245,19 +319,21 @@ 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 = contextFor(match.plugin.id, match.params);
|
||||
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));
|
||||
// The responding plugin observes its own route, params and all; the others get a plain
|
||||
// context for their own id (never another plugin's params).
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, (id) => (id === match.plugin.id ? routeCtx : contextFor(id)), result);
|
||||
await sendResult(res, result, pluginViewsFor(routeCtx, match.plugin.id), carryLocale);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -266,7 +342,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, contextFor), viewsFor(ctx), carryLocale);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -276,21 +352,28 @@ 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" }));
|
||||
try {
|
||||
return void sendHtml(res, err.status, await renderPage("403", {}));
|
||||
} catch (renderErr) {
|
||||
// Same last resort as the 500 branch below: a throw here would leave the socket open
|
||||
// (this catch is the one that would have handled it), so end the response ourselves.
|
||||
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
|
||||
return void res.writeHead(err.status, { "content-type": "text/plain; charset=utf-8" }).end("Forbidden");
|
||||
}
|
||||
}
|
||||
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");
|
||||
@@ -333,14 +416,30 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
});
|
||||
}
|
||||
|
||||
// The Referer as a host-relative path, when it is one of ours — the page a form was submitted
|
||||
// from. Anything off-origin or malformed is discarded rather than trusted into a link.
|
||||
function sameOriginPath(req: IncomingMessage): string | null {
|
||||
const referer = req.headers.referer;
|
||||
if (typeof referer !== "string") return null;
|
||||
try {
|
||||
const url = new URL(referer);
|
||||
if (req.headers.host !== undefined && url.host !== req.headers.host) return null;
|
||||
return localPath(`${url.pathname}${url.search}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type ViewRenderer = (view: string, data: Record<string, unknown>) => Promise<string>;
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -14,10 +14,15 @@ export interface RequestCsrf {
|
||||
token: string;
|
||||
}
|
||||
|
||||
// A context scoped to a plugin: same request, but `t` reads that plugin's catalog first. The
|
||||
// landing slots run a plugin's handler, so they must hand it one of these rather than the host's
|
||||
// own context — otherwise the plugin's keys render as bare keys on the pages it owns.
|
||||
export type PluginContextFactory = (pluginId: string) => RequestContext;
|
||||
|
||||
export interface BuiltinRoute {
|
||||
// Returns a RouteResult, or null when the handler wrote to ctx.res itself
|
||||
// (the landing slots dispatch a plugin's own result against that plugin's views).
|
||||
handler: (ctx: RequestContext, csrf: RequestCsrf) => Promise<RouteResult | null> | RouteResult | null;
|
||||
handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null;
|
||||
method: "GET" | "POST"; // a GET route also answers HEAD, like plugin routes
|
||||
path: string; // exact pathname
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -20,6 +23,16 @@ export interface RequestContext {
|
||||
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
|
||||
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
|
||||
chrome: PageChrome;
|
||||
// The locale this request is served in, e.g. "sv-SE" — also what <html lang> says.
|
||||
locale: string;
|
||||
// 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;
|
||||
// Every installed locale, sorted. With `localeLabel` (from #plugin-api) it is what a plugin needs
|
||||
// to build its own language picker; the host's own picker is already in the shell.
|
||||
locales: string[];
|
||||
// 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.
|
||||
@@ -32,6 +45,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 +62,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 +90,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 +100,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
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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: "{{count}} pass", other: "{{count}} pass" } });
|
||||
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": "{{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: "{{count}} pass", other: "{{count}} pass" } });
|
||||
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: "{{count}} směna", other: "{{count}} směn" } });
|
||||
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: "{{count}} pass", one: "{{count}} pass", other: "{{count}} pass" } });
|
||||
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);
|
||||
});
|
||||
|
||||
test("a translation must interpolate exactly what the baseline does", () => {
|
||||
const withVars: Catalog = { hi: "Hi {{name}}, you have {{n}} left" };
|
||||
const check = (catalog: Catalog): string[] => checkCatalog({ baseline: withVars, baselineLocale: "en-US", catalog, locale: "sv-SE" });
|
||||
|
||||
assert.deepEqual(check({ hi: "Hej {{name}}, du har {{n}} kvar" }), []);
|
||||
assert.match(check({ hi: "Hej, du har {{n}} kvar" })[0] ?? "", /"hi" never uses \{\{name\}\}/); // dropped ⇒ a blank on screen
|
||||
assert.match(check({ hi: "Hej {{namn}}, du har {{n}} kvar" })[0] ?? "", /never uses \{\{name\}\}/); // misspelled ⇒ both problems
|
||||
assert.match(check({ hi: "Hej {{name}} {{n}} {{extra}}" })[0] ?? "", /uses \{\{extra\}\}/); // never supplied ⇒ renders raw
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// 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"]);
|
||||
const PLACEHOLDER = /\{\{(\w+)\}\}/g;
|
||||
|
||||
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;
|
||||
}
|
||||
for (const problem of placeholderProblems(key, expected, actual, baselineLocale)) problems.push(problem);
|
||||
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 (!Object.hasOwn(baseline, key)) problems.push(`unknown key "${key}" — add it to ${baselineLocale} first`);
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
// A translation must interpolate exactly what the baseline does: a dropped {{name}} renders
|
||||
// "Signed in as ", a misspelled one renders the placeholder itself — the half-translated class this
|
||||
// check exists to stop, and neither is visible from the key set alone.
|
||||
function placeholderProblems(key: string, expected: Message, actual: Message, baselineLocale: string): string[] {
|
||||
const wanted = placeholders(expected);
|
||||
const got = placeholders(actual);
|
||||
const missing = [...wanted].filter((name) => !got.has(name));
|
||||
const unknown = [...got].filter((name) => !wanted.has(name));
|
||||
return [
|
||||
...(missing.length ? [`"${key}" never uses ${missing.map((n) => `{{${n}}}`).join(", ")}, which ${baselineLocale} does`] : []),
|
||||
...(unknown.length ? [`"${key}" uses ${unknown.map((n) => `{{${n}}}`).join(", ")}, which ${baselineLocale} does not supply`] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function placeholders(message: Message): Set<string> {
|
||||
const names = new Set<string>();
|
||||
for (const text of typeof message === "string" ? [message] : Object.values(message)) {
|
||||
for (const match of (text ?? "").matchAll(PLACEHOLDER)) names.add(match[1] as string);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// 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"];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 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 { type Catalog, 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 });
|
||||
|
||||
// A plugin's own English: its catalog first, the host's behind it — the same chain the host builds
|
||||
// per request, minus the locale. A plugin uses it as the default for a view model built outside a
|
||||
// request (its unit tests), so the generic words it reuses from core still read as words.
|
||||
export function englishTranslator(catalog: Catalog): Translate {
|
||||
return createTranslator({ catalogs: [catalog, enUS], locale: DEFAULT_LOCALE });
|
||||
}
|
||||
|
||||
export const ENGLISH_I18N: I18n = createI18n({
|
||||
available: [DEFAULT_LOCALE],
|
||||
core: new Map([[DEFAULT_LOCALE, enUS]]),
|
||||
plugins: new Map(),
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
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 mounted locales/ adds a language, and replaces a shipped one wholesale", async () => {
|
||||
const { localesDir, pluginsDir } = await fixture({
|
||||
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
|
||||
"locales/sv-SE.ts": catalog(`{ hello: "Hej" }`),
|
||||
"mounted/nb-NO.ts": catalog(`{ hello: "Hei" }`),
|
||||
"mounted/sv-SE.ts": catalog(`{ hello: "Tjena" }`),
|
||||
});
|
||||
const loaded = await loadI18n({ localesDir, mountedLocalesDir: join(localesDir, "..", "mounted"), pluginsDir });
|
||||
assert.deepEqual(loaded.available, ["en-US", "nb-NO", "sv-SE"]);
|
||||
assert.deepEqual(loaded.core.get("sv-SE"), { hello: "Tjena" }); // the operator's file wins outright
|
||||
});
|
||||
|
||||
test("a mounted catalog is held to the same baseline as a shipped one", async () => {
|
||||
const { localesDir, pluginsDir } = await fixture({
|
||||
"locales/en-US.ts": catalog(`{ hello: "Hello", bye: "Bye" }`),
|
||||
"mounted/nb-NO.ts": catalog(`{ hello: "Hei" }`), // no `bye` ⇒ half the app would be English
|
||||
});
|
||||
await assert.rejects(loadI18n({ localesDir, mountedLocalesDir: join(localesDir, "..", "mounted"), pluginsDir }), /nb-NO.*missing key "bye"/s);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test("an operator adds a language for a plugin without forking it, and may replace one it ships", 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.title": "Butik" }`),
|
||||
"mounted/plugins/shop/sv-SE.ts": catalog(`{ "shop.title": "Affär" }`), // replaces the plugin's
|
||||
"mounted/plugins/shop/nb-NO.ts": catalog(`{ "shop.title": "Butikk" }`), // …and adds one
|
||||
"mounted/nb-NO.ts": catalog(`{ hello: "Hei" }`), // the core side of the same language
|
||||
});
|
||||
const loaded = await loadI18n({ localesDir, mountedLocalesDir: join(localesDir, "..", "mounted"), pluginIds: ["shop"], pluginsDir });
|
||||
|
||||
assert.deepEqual(loaded.available, ["en-US", "nb-NO", "sv-SE"]);
|
||||
assert.deepEqual(loaded.plugins.get("shop")?.get("sv-SE"), { "shop.title": "Affär" });
|
||||
assert.deepEqual(loaded.plugins.get("shop")?.get("nb-NO"), { "shop.title": "Butikk" });
|
||||
assert.deepEqual(loaded.plugins.get("shop")?.get("en-US"), { "shop.title": "Shop" }); // untouched
|
||||
});
|
||||
|
||||
test("an operator's plugin catalog is held to the plugin's own baseline, and named by where it lives", 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", "shop.new": "New" }`),
|
||||
"mounted/plugins/shop/sv-SE.ts": catalog(`{ "shop.title": "Butik" }`), // shop.new missing
|
||||
});
|
||||
await assert.rejects(
|
||||
loadI18n({ localesDir, mountedLocalesDir: join(localesDir, "..", "mounted"), pluginIds: ["shop"], pluginsDir }),
|
||||
/locales\/plugins\/shop sv-SE: missing key "shop.new"/, // the folder the operator actually edited
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// 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. The operator's
|
||||
// `locales/` mount extends both sides — `locales/<tag>.ts` for the core, `locales/plugins/<id>/<tag>.ts`
|
||||
// for a plugin — so adding a language never means forking the image or a vendored plugin.
|
||||
|
||||
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";
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
// The shipped catalogs, and the drop-in mount root an operator adds their own to — a folder there
|
||||
// is a whole locale: a new tag adds a language, an existing one replaces the shipped catalog for it
|
||||
// (and is held to the same parity check, so a partial replacement fails the boot rather than
|
||||
// leaving half the app in English). Mirrors plugins/ and config/; ships empty.
|
||||
export const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales");
|
||||
export const MOUNTED_LOCALES_DIR = join(rootDir, "locales");
|
||||
|
||||
// A catalog file is named for the full locale it holds — sv-SE.ts, never sv.ts — with the script
|
||||
// subtag when the language needs one (sr-Latn-RS). Anything else in the folder is a mistake worth
|
||||
// stopping for.
|
||||
const LOCALE_FILE = /^([a-z]{2,3}(?:-[A-Z][a-z]{3})?-(?:[A-Z]{2}|[0-9]{3}))\.ts$/;
|
||||
|
||||
export interface LoadI18nOptions {
|
||||
localesDir?: string;
|
||||
logger?: Pick<Console, "warn">; // warn-level diagnostics (a plugin missing an installed locale); defaults to console
|
||||
mountedLocalesDir?: 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 mountedDir = options.mountedLocalesDir ?? MOUNTED_LOCALES_DIR;
|
||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||
const logger = options.logger ?? console;
|
||||
const errors: string[] = [];
|
||||
|
||||
const shipped = await readSet(localesDir, "core", errors);
|
||||
// The SHIPPED en-US stays the baseline even when the mount replaces it — otherwise a mounted
|
||||
// en-US would only ever be compared against itself, and a one-key rewording would boot green with
|
||||
// the whole UI rendering bare keys.
|
||||
const baseline = shipped.get(DEFAULT_LOCALE);
|
||||
if (!baseline) errors.push(`core: no ${DEFAULT_LOCALE}.ts — it is the baseline every other locale is checked against`);
|
||||
const core = new Map(shipped);
|
||||
const mounted = await readSet(mountedDir, "locales", errors);
|
||||
for (const [locale, catalog] of mounted) core.set(locale, catalog);
|
||||
// Checked under the folder they actually live in: telling an operator "core de-DE: missing key …"
|
||||
// sends them to src/i18n/locales/, which holds no de-DE.ts at all.
|
||||
checkSet(new Map([...core].filter(([locale]) => !mounted.has(locale))), "core", baseline, errors);
|
||||
checkSet(mounted, "locales", baseline, errors);
|
||||
const available = [...core.keys()].sort();
|
||||
|
||||
const plugins = new Map<string, Map<string, Catalog>>();
|
||||
for (const id of options.pluginIds ?? []) {
|
||||
// A plugin's own catalogs, and the operator's for it. Adding a language must not require forking
|
||||
// a vendored plugin folder, so `locales/plugins/<id>/` extends and overrides the same way
|
||||
// `locales/` does for the core: a new tag adds it, a tag the plugin ships replaces it wholesale.
|
||||
const own = await readSet(join(pluginsDir, id, "i18n"), `plugins/${id}`, errors);
|
||||
const mine = await readSet(join(mountedDir, "plugins", id), `locales/plugins/${id}`, errors);
|
||||
if (own.size === 0 && mine.size === 0) continue;
|
||||
const set = new Map([...own, ...mine]);
|
||||
// The plugin's own en-US is the baseline; an operator who supplies the only one is translating a
|
||||
// plugin that ships no words of its own, which is nothing this can check.
|
||||
const pluginBaseline = own.get(DEFAULT_LOCALE) ?? mine.get(DEFAULT_LOCALE);
|
||||
if (!pluginBaseline) errors.push(`plugins/${id}: no ${DEFAULT_LOCALE}.ts — a plugin's own baseline, which its other locales are checked against`);
|
||||
for (const [locale, from] of [...[...own.keys()].map((l) => [l, `plugins/${id}`] as const), ...[...mine.keys()].map((l) => [l, `locales/plugins/${id}`] as const)]) {
|
||||
if (!available.includes(locale)) errors.push(`${from}: ${locale} is not installed — add locales/${locale}.ts first`);
|
||||
}
|
||||
checkSet(new Map([...own].filter(([locale]) => !mine.has(locale))), `plugins/${id}`, pluginBaseline, errors);
|
||||
checkSet(mine, `locales/plugins/${id}`, pluginBaseline, errors);
|
||||
// Legitimate — the plugin's strings fall back to en-US on that page — but an operator who
|
||||
// installed a locale should hear about the gap at deploy time, not see English islands later.
|
||||
const gaps = available.filter((locale) => !set.has(locale));
|
||||
if (gaps.length) logger.warn(`[i18n] plugins/${id}: no ${gaps.join(", ")} — those strings render in ${DEFAULT_LOCALE} (add locales/plugins/${id}/<locale>.ts)`);
|
||||
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 (sv-SE.ts, es-419.ts, sr-Latn-RS.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, baseline: Catalog | undefined, errors: string[]): void {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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"), "");
|
||||
// The building blocks document href as optional (an unlinked page item, a header with no sort
|
||||
// target) — an absent one must not throw, or the page breaks only for visitors who chose a language.
|
||||
assert.equal(localeHref(undefined as unknown as string, "sv-SE"), undefined);
|
||||
assert.equal(localeHref(null as unknown as string, "sv-SE"), null);
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// 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 {
|
||||
// An absent href is a shape the building blocks document as optional (a non-linked page item, a
|
||||
// header with no sort target) — it must not throw here, or a page renders for every visitor
|
||||
// except the ones who chose a language.
|
||||
if (locale === null || !href || href.startsWith("//")) return href;
|
||||
// A query-only href ("?" — the filter bar's documented "clear" target) keeps that shape; anything
|
||||
// else must be host-relative, or it is someone else's URL to state a language for.
|
||||
const queryOnly = href.startsWith("?");
|
||||
if (!queryOnly && !href.startsWith("/")) return href;
|
||||
const url = new URL(href, "http://localhost/");
|
||||
url.searchParams.set("locale", locale);
|
||||
return queryOnly ? `${url.search}${url.hash}` : `${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>();
|
||||
|
||||
// The locale this request explicitly asked for, or null. `localeHref` is a no-op unless one was
|
||||
// chosen, so asking the function that decides keeps callers from re-deriving the rule.
|
||||
export function chosenLocale(ctx: { locale: string; localeHref: (href: string) => string }): string | null {
|
||||
return ctx.localeHref("/") === "/" ? null : ctx.locale;
|
||||
}
|
||||
|
||||
interface TextInfoLocale {
|
||||
getTextInfo?: () => { direction?: string };
|
||||
textInfo?: { direction?: string };
|
||||
}
|
||||
|
||||
// The document direction for <html dir>, from the locale's script. It states the direction — the
|
||||
// shipped stylesheet still uses physical left/right properties, so an RTL locale also needs those
|
||||
// moved to logical ones before it lays out correctly.
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// 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",
|
||||
|
||||
// Generic UI verbs every screen needs. A plugin reuses these (the lookup falls through to core)
|
||||
// and keeps its own catalog for its domain words — see README → Languages.
|
||||
"common.add": "Add",
|
||||
"common.cancel": "Cancel",
|
||||
"common.delete": "Delete",
|
||||
"common.edit": "Edit",
|
||||
"common.new": "New",
|
||||
"common.remove": "Remove",
|
||||
|
||||
"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>README.md</code> (Building plugins → the landing pages) and the <code>examples/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",
|
||||
"locale.leavesPage": "Switching leaves this page",
|
||||
|
||||
"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.summary": "{{from}}–{{to}} of <b>{{total}}</b>",
|
||||
"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;
|
||||
@@ -0,0 +1,163 @@
|
||||
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",
|
||||
|
||||
"common.add": "Lägg till",
|
||||
"common.cancel": "Avbryt",
|
||||
"common.delete": "Radera",
|
||||
"common.edit": "Redigera",
|
||||
"common.new": "Ny",
|
||||
"common.remove": "Ta bort",
|
||||
|
||||
"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>README.md</code> (Building plugins → startsidorna) och referensen <code>examples/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": "Översikt",
|
||||
|
||||
"error.403.body": "Du har inte behörighet att se det här (403).",
|
||||
"error.403.docTitle": "Förbjudet",
|
||||
"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 allt grundarbete.",
|
||||
|
||||
"locale.label": "Språk",
|
||||
"locale.leavesPage": "Byter du språk lämnar du den här sidan",
|
||||
|
||||
"nav.dashboard": "Översikt",
|
||||
|
||||
"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.summary": "{{from}}–{{to}} av <b>{{total}}</b>",
|
||||
"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;
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// The loaded catalogs as the host uses them per request: resolve the locale, then hand out a
|
||||
// translator for it. A plugin's translator looks in its own catalogs first, so a plugin may shadow
|
||||
// any core string; both fall back to en-US per key, so an untranslated corner is English, never blank.
|
||||
// Translators are memoised — catalogs are immutable after boot, so there is one per locale+plugin.
|
||||
|
||||
import { DEFAULT_LOCALE, type Catalog } from "./catalog.ts";
|
||||
import type { LoadedI18n } from "./load.ts";
|
||||
import { resolveLocale, type ResolvedLocale } from "./locale.ts";
|
||||
import { createTranslator, type Translate } from "./translate.ts";
|
||||
|
||||
export interface ResolveRequest {
|
||||
acceptLanguage?: string | undefined;
|
||||
param?: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface I18n {
|
||||
available: string[];
|
||||
resolve(request: ResolveRequest): ResolvedLocale;
|
||||
translator(locale: string, pluginId?: string): Translate;
|
||||
}
|
||||
|
||||
export function createI18n(loaded: LoadedI18n): I18n {
|
||||
const memo = new Map<string, Translate>();
|
||||
return {
|
||||
available: loaded.available,
|
||||
resolve: ({ acceptLanguage, param }) => resolveLocale({ acceptLanguage, available: loaded.available, param }),
|
||||
translator: (locale, pluginId) => {
|
||||
const key = `${locale} ${pluginId ?? ""}`;
|
||||
let translate = memo.get(key);
|
||||
if (!translate) {
|
||||
translate = createTranslator({ catalogs: chain(loaded, locale, pluginId), locale });
|
||||
memo.set(key, translate);
|
||||
}
|
||||
return translate;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function chain(loaded: LoadedI18n, locale: string, pluginId: string | undefined): Catalog[] {
|
||||
const plugin = pluginId === undefined ? undefined : loaded.plugins.get(pluginId);
|
||||
const catalogs = [plugin?.get(locale), plugin?.get(DEFAULT_LOCALE), loaded.core.get(locale), loaded.core.get(DEFAULT_LOCALE)];
|
||||
return [...new Set(catalogs.filter((catalog): catalog is Catalog => catalog !== undefined))];
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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) {
|
||||
// Own keys only: `t("toString")` must fall through to the key itself like any other unknown
|
||||
// one, not pick up Object.prototype.
|
||||
if (!Object.hasOwn(catalog, key)) continue;
|
||||
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) => {
|
||||
if (!Object.hasOwn(vars, name)) return whole;
|
||||
const value = vars[name];
|
||||
return value === undefined ? whole : String(value);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { ENGLISH } from "./english.ts";
|
||||
import { localeHref } from "./locale.ts";
|
||||
import { i18nLocals, type I18nRequest } from "./view-locals.ts";
|
||||
|
||||
const request = (overrides: Partial<I18nRequest> = {}): I18nRequest => ({
|
||||
locale: "sv-SE",
|
||||
localeHref: (href) => href,
|
||||
locales: ["en-US", "sv-SE"],
|
||||
switchBase: "/admin/users?q=ada",
|
||||
t: ENGLISH,
|
||||
url: new URL("http://localhost/admin/users?q=ada"),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("localeSwitch offers this same page in every installed locale, marking the current one", () => {
|
||||
const locals = i18nLocals(request());
|
||||
assert.deepEqual(locals.localeSwitch.map((c) => c.href), ["/admin/users?q=ada&locale=en-US", "/admin/users?q=ada&locale=sv-SE"]);
|
||||
assert.deepEqual(locals.localeSwitch.map((c) => c.current), [false, true]);
|
||||
assert.match(locals.localeSwitch[1]?.label ?? "", /svenska/i); // named in its own language
|
||||
});
|
||||
|
||||
test("localeParam is the tag only when the URL asked — it is what the GET forms carry", () => {
|
||||
// The probe asks the very function that decides, so the two can't drift apart.
|
||||
assert.equal(i18nLocals(request()).localeParam, null); // identity localeHref ⇒ nothing was chosen
|
||||
assert.equal(i18nLocals(request({ localeHref: (href) => localeHref(href, "sv-SE") })).localeParam, "sv-SE");
|
||||
});
|
||||
|
||||
test("dir follows the locale's script", () => {
|
||||
assert.equal(i18nLocals(request()).dir, "ltr");
|
||||
assert.equal(i18nLocals(request({ locale: "ar-EG" })).dir, "rtl");
|
||||
});
|
||||
|
||||
test("the picker points wherever the host says — after a POST that is the nearest page answering GET", () => {
|
||||
// The picker is on every page; on a POST-rendered one its own URL may answer no GET, so the host
|
||||
// resolves the target (app.ts → switchBase) and this just renders it.
|
||||
const locals = i18nLocals(request({ switchBase: "/admin/users/u1" }));
|
||||
assert.deepEqual(locals.localeSwitch.map((c) => c.href), ["/admin/users/u1?locale=en-US", "/admin/users/u1?locale=sv-SE"]);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// 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 { chosenLocale, 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";
|
||||
// True when switching language cannot stay on this page (its URL answers no GET, so the picker
|
||||
// points elsewhere) — the page then says so, because what it leaves behind may be a one-time
|
||||
// secret that cannot be shown again.
|
||||
leavesPage: boolean;
|
||||
locale: string;
|
||||
localeHref: (href: string) => string;
|
||||
// The locale to carry as a hidden field, or null when the visitor never asked for one. A GET form
|
||||
// replaces the whole query string, so a link-carrying wrapper can't reach it — the form must.
|
||||
localeParam: string | null;
|
||||
localeSwitch: LocaleChoice[];
|
||||
locales: string[];
|
||||
t: Translate;
|
||||
}
|
||||
|
||||
// Just the request fields a render needs, so this module stays a leaf of src/i18n/ rather than
|
||||
// depending on the HTTP layer that calls it.
|
||||
export interface I18nRequest {
|
||||
locale: string;
|
||||
localeHref: (href: string) => string;
|
||||
locales: string[];
|
||||
// Where the language picker points — "this page", except after a POST, whose URL may answer no
|
||||
// GET at all; the host then resolves the nearest page that does (app.ts → switchBase).
|
||||
switchBase: string;
|
||||
t: Translate;
|
||||
url: URL;
|
||||
}
|
||||
|
||||
// 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",
|
||||
leavesPage: false,
|
||||
locale: DEFAULT_LOCALE,
|
||||
localeHref: (href) => href,
|
||||
localeParam: null,
|
||||
localeSwitch: [],
|
||||
locales: [DEFAULT_LOCALE],
|
||||
t: ENGLISH,
|
||||
};
|
||||
|
||||
export function i18nLocals(ctx: I18nRequest): I18nLocals {
|
||||
const carried = chosenLocale(ctx);
|
||||
return {
|
||||
dir: textDirection(ctx.locale),
|
||||
leavesPage: ctx.switchBase !== `${ctx.url.pathname}${ctx.url.search}`,
|
||||
locale: ctx.locale,
|
||||
localeHref: (href) => ctx.localeHref(href),
|
||||
localeParam: carried,
|
||||
localeSwitch: ctx.locales.map((tag) => ({ current: tag === ctx.locale, href: localeHref(ctx.switchBase, tag), label: localeLabel(tag), tag })),
|
||||
locales: ctx.locales,
|
||||
t: ctx.t,
|
||||
};
|
||||
}
|
||||
@@ -24,27 +24,32 @@ test("runBootHooks runs each onBoot in order, skips plugins without one, and a t
|
||||
|
||||
test("runRequestHooks short-circuits on the first RouteResult (with its plugin); later hooks skipped", async () => {
|
||||
const calls: string[] = [];
|
||||
const scoped: string[] = []; // each hook is handed a context built for its own plugin
|
||||
const contextFor = (pluginId: string) => { scoped.push(pluginId); return ctx; };
|
||||
const short = await runRequestHooks([
|
||||
plugin("a", { onRequest: () => void calls.push("a") }), // returns void → continue
|
||||
plugin("b", { onRequest: () => { calls.push("b"); return { html: "stop" }; } }),
|
||||
plugin("c", { onRequest: () => void calls.push("c") }), // never reached
|
||||
], ctx);
|
||||
], contextFor);
|
||||
|
||||
assert.deepEqual(short?.result, { html: "stop" });
|
||||
assert.equal(short?.plugin.id, "b"); // the owning plugin (so a `view` result resolves correctly)
|
||||
assert.equal(short?.ctx, ctx); // …and the context it ran on, for rendering its view
|
||||
assert.deepEqual(calls, ["a", "b"]);
|
||||
assert.deepEqual(scoped, ["a", "b"]); // a plugin without the hook never builds a context
|
||||
|
||||
// No hook short-circuits → null (proceed with normal routing).
|
||||
assert.equal(await runRequestHooks([plugin("a", { onRequest: () => {} })], ctx), null);
|
||||
assert.equal(await runRequestHooks([plugin("a", { onRequest: () => {} })], contextFor), null);
|
||||
});
|
||||
|
||||
test("runResponseHooks runs every onResponse as an observer with the result; a throw fails", async () => {
|
||||
const seen: unknown[] = [];
|
||||
const contextFor = () => ctx; // each observer gets a context scoped to its own plugin
|
||||
await runResponseHooks([
|
||||
plugin("a", { onResponse: (_c, r) => void seen.push(r) }),
|
||||
plugin("b", {}), // no onResponse → skipped
|
||||
], ctx, { html: "ok" });
|
||||
], contextFor, { html: "ok" });
|
||||
assert.deepEqual(seen, [{ html: "ok" }]);
|
||||
|
||||
await assert.rejects(runResponseHooks([plugin("x", { onResponse: () => { throw new Error("boom"); } })], ctx, null), /boom/);
|
||||
await assert.rejects(runResponseHooks([plugin("x", { onResponse: () => { throw new Error("boom"); } })], contextFor, null), /boom/);
|
||||
});
|
||||
|
||||
@@ -13,17 +13,30 @@ export async function runBootHooks(plugins: Plugin[]): Promise<void> {
|
||||
|
||||
// Before route matching. The first hook to return a RouteResult short-circuits the request — its
|
||||
// result becomes the response and later hooks + the route handler are skipped. Returns that result
|
||||
// with its owning plugin (so a `view` result resolves against that plugin's views), or null to proceed.
|
||||
export async function runRequestHooks(plugins: Plugin[], ctx: RequestContext): Promise<{ plugin: Plugin; result: RouteResult } | null> {
|
||||
// with its owning plugin (so a `view` result resolves against that plugin's views), or null to
|
||||
// proceed. Each hook gets a context scoped to its own plugin, so `ctx.t` reads that plugin's catalog.
|
||||
export async function runRequestHooks(
|
||||
plugins: Plugin[],
|
||||
contextFor: (pluginId: string) => RequestContext,
|
||||
): Promise<{ ctx: RequestContext; plugin: Plugin; result: RouteResult } | null> {
|
||||
for (const plugin of plugins) {
|
||||
const result = await plugin.hooks?.onRequest?.(ctx);
|
||||
if (result != null) return { plugin, result };
|
||||
if (!plugin.hooks?.onRequest) continue;
|
||||
const ctx = contextFor(plugin.id);
|
||||
const result = await plugin.hooks.onRequest(ctx);
|
||||
if (result != null) return { ctx, plugin, result };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// After a route handler produces its result. Observers only — the return value is ignored, so a
|
||||
// hook cannot change the response; a throw fails the request.
|
||||
export async function runResponseHooks(plugins: Plugin[], ctx: RequestContext, result: RouteResult | null): Promise<void> {
|
||||
for (const plugin of plugins) await plugin.hooks?.onResponse?.(ctx, result);
|
||||
// hook cannot change the response; a throw fails the request. Each observer gets a context scoped to
|
||||
// its own plugin, like onRequest, so `ctx.t` is never another plugin's translator.
|
||||
export async function runResponseHooks(
|
||||
plugins: Plugin[],
|
||||
contextFor: (pluginId: string) => RequestContext,
|
||||
result: RouteResult | null,
|
||||
): Promise<void> {
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.hooks?.onResponse) await plugin.hooks.onResponse(contextFor(plugin.id), result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,21 @@ 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";
|
||||
// `englishTranslator(yourCatalog)` chains your catalog in front of the host's English — the default
|
||||
// for a view model built outside a request, so core words you reuse still read as words in a test.
|
||||
export { englishTranslator } from "../i18n/english.ts";
|
||||
// `localeLabel(tag)` names a locale in its own language ("svenska (Sverige)") — what ctx.locales
|
||||
// needs to become a language picker of your own.
|
||||
export { localeLabel } from "../i18n/locale.ts";
|
||||
export type { Translate, TranslateVars } from "../i18n/translate.ts";
|
||||
export type { Catalog, PluralMessage } from "../i18n/catalog.ts";
|
||||
// The shape of the core catalog — what an operator's own locales/<tag>.ts is written against, so a
|
||||
// missing key is a type error in the editor rather than a wall of boot errors.
|
||||
export type { CoreMessages } from "../i18n/locales/en-US.ts";
|
||||
export { parseListQuery } from "../ui/list-query.ts";
|
||||
export { paginate } from "../ui/paginate.ts";
|
||||
export type { PageModel } from "../ui/paginate.ts";
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -36,6 +38,12 @@ const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.rev
|
||||
|
||||
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
|
||||
log.info("plugins discovered", { count: plugins.length, ids: plugins.map((p) => p.id).join(", ") });
|
||||
// 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. Loaded
|
||||
// before the boot hooks, so a catalog mismatch aborts before a plugin's onBoot has any side effect.
|
||||
const i18n = createI18n(await loadI18n({ logger: log, pluginIds: plugins.map((p) => p.id) }));
|
||||
log.info("locales loaded", { locales: i18n.available.join(", ") });
|
||||
|
||||
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
|
||||
|
||||
const server = createApp({
|
||||
@@ -47,6 +55,7 @@ const server = createApp({
|
||||
csrfSecret: config.csrfSecret,
|
||||
...(denylist ? { denylist } : {}),
|
||||
hydra,
|
||||
i18n,
|
||||
jwks,
|
||||
keto,
|
||||
kratos,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+43
-12
@@ -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";
|
||||
import { branding, 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";
|
||||
const { theme, ...brand } = branding(opts.menu, t);
|
||||
return {
|
||||
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: b.sub } : {}) },
|
||||
brand,
|
||||
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",
|
||||
...(b.theme != null ? { theme: b.theme } : {}),
|
||||
user: shellUser(opts.user),
|
||||
nav: carryLocaleInto(nav, carryLocale),
|
||||
signInHref: carryLocale(returnTo),
|
||||
...(theme != null ? { theme } : {}),
|
||||
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
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+10
-1
@@ -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 () => {
|
||||
@@ -51,3 +52,11 @@ test("field defaults to a bare text input, escapes a string error, and never thr
|
||||
assert.match(stringErr, /<span><b>Required<\/b>\.<\/span>/); // string error is escaped
|
||||
assert.match(stringErr, /aria-describedby="x-err"/);
|
||||
});
|
||||
|
||||
test("an inline field link carries the visitor's language", async () => {
|
||||
const html = await ejs.renderFile(field, {
|
||||
...ENGLISH_LOCALS, localeHref: (href: string) => `${href}?locale=sv-SE`,
|
||||
id: "password", label: "Password", link: { href: "/recovery", label: "Forgot password?" }, name: "password",
|
||||
});
|
||||
assert.match(html, /href="\/recovery\?locale=sv-SE"/);
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -51,3 +51,8 @@ test("parseListQuery honours custom reserved names and page-size bounds", () =>
|
||||
assert.equal(parseListQuery("?n=999", { maxPageSize: 50, pageSizeParam: "n" }).pageSize, 50);
|
||||
assert.deepEqual(parseListQuery("?q=hi", { qParam: "search" }).filters, { q: ["hi"] });
|
||||
});
|
||||
|
||||
test("`locale` is the host's, not a filter — every localized list link carries it", () => {
|
||||
const query = parseListQuery("/admin/users?locale=sv-SE&status=active");
|
||||
assert.deepEqual(Object.keys(query.filters), ["status"]);
|
||||
});
|
||||
|
||||
@@ -31,7 +31,9 @@ export function parseListQuery(url: URL | URLSearchParams | string, options: Lis
|
||||
const sortParam = options.sortParam ?? "sort";
|
||||
const pageParam = options.pageParam ?? "page";
|
||||
const pageSizeParam = options.pageSizeParam ?? "pageSize";
|
||||
const reserved = new Set([pageParam, pageSizeParam, qParam, sortParam]);
|
||||
// `locale` is host-owned (README → Languages): every list link and both GET forms carry it, so
|
||||
// without this it would arrive as a phantom filter on every localized list page.
|
||||
const reserved = new Set([pageParam, pageSizeParam, qParam, sortParam, "locale"]);
|
||||
|
||||
const filters: Record<string, string[]> = {};
|
||||
for (const key of new Set(params.keys())) {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
@@ -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 menu = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "menu.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, data);
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, { ...ENGLISH_LOCALS, ...data });
|
||||
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
|
||||
|
||||
test("menu renders trigger, positioning, the item matrix and check groups", async () => {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
+10
-5
@@ -6,6 +6,9 @@
|
||||
// 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 { ENGLISH } from "../i18n/english.ts";
|
||||
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 +43,14 @@ export function composeNav(
|
||||
fragments: NavNode[][] = [],
|
||||
override: NavOverride = {},
|
||||
permissions: string[] = [],
|
||||
t: Translate = ENGLISH,
|
||||
): 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 +120,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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
@@ -67,3 +68,10 @@ test("pagination renders a valid empty footer and never throws on missing config
|
||||
assert.match(html, /<option value="50" selected>50 \/ page<\/option>/);
|
||||
assert.match(html, /<button class="page-btn" type="submit">Set<\/button>/);
|
||||
});
|
||||
|
||||
test("the summary message renders its markup but escapes the values interpolated into it", async () => {
|
||||
// It is the one message rendered raw (<%- %>), so a value that arrives as text can't inject.
|
||||
const html = await render({ summary: { from: 1, to: 12, total: '<img src=x onerror=alert(1)>' } });
|
||||
assert.match(html, /<b><img src=x onerror=alert\(1\)><\/b>/);
|
||||
assert.doesNotMatch(html, /<img/);
|
||||
});
|
||||
|
||||
+25
-6
@@ -6,8 +6,22 @@
|
||||
// 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";
|
||||
|
||||
// The brand block both the chrome and the shell model carry. `name`/`sub` pass through `t`, so a
|
||||
// catalog key is translated and an operator's own wording renders as written.
|
||||
export function branding(menu: MenuConfig, t: Translate): { logo?: string; name: string; sub?: string; theme?: string } {
|
||||
const b = menu.branding;
|
||||
return {
|
||||
...(b.logo != null ? { logo: b.logo } : {}),
|
||||
name: t(b.name),
|
||||
...(b.sub != null ? { sub: t(b.sub) } : {}),
|
||||
...(b.theme != null ? { theme: b.theme } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ShellUser {
|
||||
email: string;
|
||||
initials: string;
|
||||
@@ -24,8 +38,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][0] ?? "?").toUpperCase(), name: guest }; // by character: the word is translated
|
||||
}
|
||||
const local = user.email.split("@")[0] || user.email;
|
||||
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
|
||||
}
|
||||
@@ -35,17 +52,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;
|
||||
const { theme, ...brand } = branding(opts.menu, t);
|
||||
return {
|
||||
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: b.sub } : {}) },
|
||||
brand,
|
||||
...(opts.breadcrumbs ? { breadcrumbs: opts.breadcrumbs } : {}),
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}),
|
||||
...(b.theme != null ? { theme: b.theme } : {}),
|
||||
...(theme != null ? { theme } : {}),
|
||||
title: opts.title,
|
||||
user: shellUser(opts.user),
|
||||
user: shellUser(opts.user, t),
|
||||
};
|
||||
}
|
||||
|
||||
+11
-1
@@ -3,9 +3,13 @@ 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";
|
||||
|
||||
// A localeHref that marks what it touches, so a raw href in the chrome is visible to a test.
|
||||
const CARRYING = { ...ENGLISH_LOCALS, localeHref: (href: string) => `${href}?locale=sv-SE` };
|
||||
|
||||
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({
|
||||
@@ -106,3 +110,9 @@ test("app shell escapes text but passes slot HTML through, and renders with defa
|
||||
assert.match(bare, /<aside class="sidebar"/);
|
||||
assert.match(bare, /<main class="content"/);
|
||||
});
|
||||
|
||||
test("the chrome carries the visitor's language: breadcrumb links go through localeHref", async () => {
|
||||
const html = await ejs.renderFile(shell, { ...CARRYING, breadcrumbs: [{ href: "/admin/users", label: "Users" }, { label: "Ada" }], title: "Ada" });
|
||||
assert.match(html, /<a href="\/admin\/users\?locale=sv-SE">Users<\/a>/);
|
||||
assert.match(html, /<span>Ada<\/span>/); // the current crumb has no href to carry
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
- [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.)
|
||||
- [ ] Guard against the double-clicked submit, without client-side JavaScript. The README's non-technical persona double-clicks a button that doesn't respond instantly, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only (no client JS — priority: zero-JS spine), and it must not break an action that is *legitimately* repeatable (an increase-by-one button is not a duplicate, it is two increments). Sketch to evaluate: a CSS-only affordance so the second click has nothing to hit (`:active`/`:focus` state, or the submit visually and semantically settling), paired with the host recognising a duplicate on the server — same session, same route, same payload, within a short window — and then logging it and dropping the second rather than replaying it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form is stronger than hashing the payload, and the CSRF plumbing already mints per-request tokens), how long the window is, where the record lives given the app is stateless (in-memory like the revoke denylist, or push it to the upstream the plugin already writes to), and how a plugin declares a route as repeatable — an opt-out on the route, or opt-in per form. Raised 2026-08-04 with the personas.
|
||||
- [ ] Decide the caching contract for rendered pages. Responses now carry `Vary: Accept-Language` (they content-negotiate), but nothing sets `Cache-Control` — so a shared cache in front of the app has no instruction, and a signed-in page is not marked `private`. Pre-existing, surfaced by the i18n review 2026-08-03: either set the headers deliberately (public pages cacheable, gated pages `private, no-store`) or record in AGENTS.md that the reverse proxy owns this.
|
||||
- [ ] 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.
|
||||
|
||||
|
||||
+10
-6
@@ -1,16 +1,20 @@
|
||||
<!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>
|
||||
<%- include("partials/locale-switch", { up: false }) %>
|
||||
<p><a href="<%= localeHref("/") %>"><%= t("error.backHome") %></a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-6
@@ -1,16 +1,20 @@
|
||||
<!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>
|
||||
<%- include("partials/locale-switch", { up: false }) %>
|
||||
<p><a href="<%= localeHref("/") %>"><%= t("error.backHome") %></a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-6
@@ -1,16 +1,20 @@
|
||||
<!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>
|
||||
<%- include("partials/locale-switch", { up: false }) %>
|
||||
<p><a href="<%= localeHref("/") %>"><%= t("error.backHome") %></a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-6
@@ -1,16 +1,20 @@
|
||||
<!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>
|
||||
<%- include("partials/locale-switch", { up: false }) %>
|
||||
<p><a href="<%= localeHref("/login") %>"><%= t("error.tryAgain") %></a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-7
@@ -1,17 +1,21 @@
|
||||
<!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>
|
||||
<%- include("partials/locale-switch", { up: false }) %>
|
||||
<p><a href="<%= localeHref("/login") %>"><%= t("error.backToSignIn") %></a></p>
|
||||
<% if (locals.id) { %><p><small><%= t("error.reference", { id }) %></small></p><% } %>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+7
-9
@@ -5,24 +5,22 @@
|
||||
handler; this renders until then. Data: model { nav, shell }.
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: model.nav });
|
||||
// These four messages carry markup (<code> spans), so they render raw — the documented
|
||||
// markup-carrying case. They interpolate nothing, so there is no untrusted data to escape.
|
||||
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/<id>/views/<view>.ejs, rendered in this same shell
|
||||
dashboard: (ctx) => ({ 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,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,20 +20,21 @@
|
||||
<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>
|
||||
<%- include("partials/auth-card", {
|
||||
action: "/oauth2/consent",
|
||||
action: localeHref("/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">
|
||||
<form class="auth-alt" method="post" action="<%= localeHref("/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>
|
||||
|
||||
@@ -17,16 +17,16 @@
|
||||
const providers = (sso && sso.providers) || [];
|
||||
const alt = locals.alt;
|
||||
-%>
|
||||
<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>
|
||||
<form class="auth-card" method="<%= method %>"<% if (locals.action) { %> action="<%= localeHref(locals.action) %>"<% } %>>
|
||||
<div class="auth-head"><% if (back) { %><a class="auth-back" href="<%= localeHref(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" %>">
|
||||
<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="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="<%= localeHref(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 || t("auth.sso.divider") %></div>
|
||||
</div>
|
||||
<% } -%>
|
||||
<div class="auth-form"><%- locals.body || "" %></div>
|
||||
<% if (alt) { -%>
|
||||
<p class="auth-alt"><%= alt.text %> <a href="<%= alt.href %>"><%= alt.label %></a></p>
|
||||
<p class="auth-alt"><%= alt.text %> <a href="<%= localeHref(alt.href) %>"><%= alt.label %></a></p>
|
||||
<% } -%>
|
||||
</form>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? }
|
||||
user + rowHeader cells render as <th scope="row"> — they identify the row (the row header).
|
||||
Action = { label, icon?, href?, danger?, separatorBefore? }
|
||||
Every href (sort headers, row-header links, row actions) is run through localeHref, so a sorted or
|
||||
paged list stays in the visitor's language without the caller wiring it.
|
||||
%><%
|
||||
const caption = locals.caption;
|
||||
const selectable = !!locals.selectable;
|
||||
const withActions = !!locals.actions;
|
||||
const columns = locals.columns || [];
|
||||
const rows = locals.rows || [];
|
||||
const emptyText = locals.emptyText || "Nothing here yet."; // shown when a table that has columns has no rows
|
||||
const emptyText = locals.emptyText || t("table.empty"); // shown when a table that has columns has no rows
|
||||
-%>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
@@ -24,17 +26,17 @@
|
||||
<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) { -%>
|
||||
<th scope="col"<% if (col.sort === "asc") { %> aria-sort="ascending"<% } else if (col.sort === "desc") { %> aria-sort="descending"<% } %><% if (col.className) { %> class="<%= col.className %>"<% } %>><a class="th-sort" href="<%= col.href %>"><%= col.label %> <svg class="ico ico-sm sort-ico"><use href="#<%= col.sort ? "i-up" : "i-sort" %>"/></svg></a></th>
|
||||
<th scope="col"<% if (col.sort === "asc") { %> aria-sort="ascending"<% } else if (col.sort === "desc") { %> aria-sort="descending"<% } %><% if (col.className) { %> class="<%= col.className %>"<% } %>><a class="th-sort" href="<%= localeHref(col.href) %>"><%= col.label %> <svg class="ico ico-sm sort-ico"><use href="#<%= col.sort ? "i-up" : "i-sort" %>"/></svg></a></th>
|
||||
<% } else { -%>
|
||||
<th scope="col"<% if (col.className) { %> class="<%= col.className %>"<% } %>><%= col.label %></th>
|
||||
<% } -%>
|
||||
<% }) -%>
|
||||
<% 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 +47,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") { -%>
|
||||
@@ -53,7 +55,7 @@
|
||||
<% } else if (cell.user) { -%>
|
||||
<th scope="row"><span class="cell-user"><span class="avatar" aria-hidden="true"><%= cell.user.initials %></span><span class="cell-strong"><%= cell.user.name %></span></span></th>
|
||||
<% } else if (cell.rowHeader) { -%>
|
||||
<th scope="row"><% if (cell.rowHeader.href) { %><a class="cell-strong" href="<%= cell.rowHeader.href %>"><%= cell.rowHeader.text %></a><% } else { %><span class="cell-strong"><%= cell.rowHeader.text %></span><% } %></th>
|
||||
<th scope="row"><% if (cell.rowHeader.href) { %><a class="cell-strong" href="<%= localeHref(cell.rowHeader.href) %>"><%= cell.rowHeader.text %></a><% } else { %><span class="cell-strong"><%= cell.rowHeader.text %></span><% } %></th>
|
||||
<% } else if (cell.badge) { -%>
|
||||
<td><span class="badge <%= cell.badge.tone %>"><span class="dot"></span><%= cell.badge.label %></span></td>
|
||||
<% } else if (cell.html != null) { -%>
|
||||
@@ -64,8 +66,8 @@
|
||||
<% }) -%>
|
||||
<% 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) => { -%>
|
||||
<% 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><% } %><% }) -%>
|
||||
<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="<%= localeHref(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 { -%>
|
||||
<td class="col-actions"></td>
|
||||
|
||||
@@ -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="<%= localeHref(link.href) %>"><%= link.label %></a><% } else { %><span class="optional"><%= t("field.optional") %></span><% } %></div>
|
||||
<% } else { -%>
|
||||
<label for="<%= id %>"><%= locals.label %></label>
|
||||
<% } -%>
|
||||
|
||||
@@ -8,21 +8,26 @@
|
||||
select { name, label, value?, options:{value,label}[] }
|
||||
chips { name, legend?, value?:string[], options:{value,label}[] } (checkboxes)
|
||||
daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} }
|
||||
The form is a GET, which replaces the whole query string — so the visitor's chosen language rides
|
||||
along as a hidden input, and every href here (pills, clear) is run through localeHref.
|
||||
%><%
|
||||
const action = locals.action || "";
|
||||
const label = locals.label || "Filter";
|
||||
const label = locals.label || t("filter.label");
|
||||
const rows = locals.rows || [];
|
||||
const pills = locals.pills || [];
|
||||
const clearHref = locals.clearHref || "?";
|
||||
const applyLabel = locals.applyLabel || "Apply filters";
|
||||
const applyLabel = locals.applyLabel || t("filter.apply");
|
||||
const eq = (a, b) => String(a ?? "") === String(b);
|
||||
-%>
|
||||
<form class="filters" method="get"<% if (action) { %> action="<%= action %>"<% } %> aria-label="<%= label %>">
|
||||
<form class="filters" method="get"<% if (action) { %> action="<%= localeHref(action) %>"<% } %> aria-label="<%= label %>">
|
||||
<% if (localeParam) { -%>
|
||||
<input type="hidden" name="locale" value="<%= localeParam %>">
|
||||
<% } -%>
|
||||
<% rows.forEach((row) => { -%>
|
||||
<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 +35,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 +44,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="<%= localeHref(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="<%= localeHref(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>
|
||||
|
||||
@@ -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="<%= localeHref(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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<%#
|
||||
Language picker: one link per installed locale, each pointing at this same page with ?locale set,
|
||||
so switching is a plain navigation — zero-JS, and the address bar always says which language the
|
||||
page is in. Renders nothing for a single-language deployment.
|
||||
Locals: localeSwitch (host-supplied: { current, href, label, tag }[]) · up? (open upward, default true)
|
||||
-%>
|
||||
<% const choices = locals.localeSwitch || []; -%>
|
||||
<% if (choices.length > 1) { -%>
|
||||
<%- include("menu", {
|
||||
up: locals.up !== false,
|
||||
trigger: { class: "btn icon-btn", icon: "i-globe", label: t("locale.label") },
|
||||
items: [
|
||||
{ head: t("locale.label") },
|
||||
...choices.map((c) => ({ current: c.current, href: c.href, hreflang: c.tag, label: c.label, ownLocale: true })),
|
||||
// This page can't be re-rendered in another language (its URL answers no GET), so switching
|
||||
// navigates away — and may leave a one-time secret behind. Say it before the click.
|
||||
...(locals.leavesPage ? [{ head: t("locale.leavesPage") }] : []),
|
||||
],
|
||||
}) %>
|
||||
<% } -%>
|
||||
@@ -8,17 +8,18 @@
|
||||
kebab? boolean bare kebab trigger (adds .kebab)
|
||||
width? number|string popover min-width (number ⇒ px)
|
||||
items: Item[] popover content, top→bottom
|
||||
Item ∈ { head } · { sep } · { label, icon?, href? ⇒ <a>, danger? } (default: menu-item button)
|
||||
Item ∈ { head } · { sep } · { label, icon?, href? ⇒ <a>, hreflang?, ownLocale?, current?, danger? } (default: menu-item button)
|
||||
ownLocale: the href already states its language (the picker) — don't carry the current one onto it
|
||||
· { group: { legend?, name, control?(="checkbox"|"radio"), options:{value,label,checked?}[] } }
|
||||
%><%
|
||||
const t = locals.trigger || {};
|
||||
const sumCls = "class" in t ? t.class : "btn";
|
||||
const trigger = locals.trigger || {}; // not `t` — that name is the translator in every view
|
||||
const sumCls = "class" in trigger ? trigger.class : "btn";
|
||||
const items = locals.items || [];
|
||||
const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : "");
|
||||
const width = locals.width;
|
||||
-%>
|
||||
<details class="menu<%= locals.kebab ? " kebab" : "" %>"<%= locals.open ? " open" : "" %>>
|
||||
<summary<% if (sumCls) { %> class="<%= sumCls %>"<% } %><% if (t.label) { %> aria-label="<%= t.label %>"<% } %>><% if (t.html != null) { %><%- t.html %><% } else { if (t.icon) { %><svg class="ico ico-sm"><use href="#<%= t.icon %>"/></svg><% } if (t.text) { %><%= t.text %><% } } %></summary>
|
||||
<summary<% if (sumCls) { %> class="<%= sumCls %>"<% } %><% if (trigger.label) { %> aria-label="<%= trigger.label %>"<% } %>><% if (trigger.html != null) { %><%- trigger.html %><% } else { if (trigger.icon) { %><svg class="ico ico-sm"><use href="#<%= trigger.icon %>"/></svg><% } if (trigger.text) { %><%= trigger.text %><% } } %></summary>
|
||||
<div class="<%= popCls %>"<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>>
|
||||
<% items.forEach((it) => { -%>
|
||||
<% if (it.head != null) { -%>
|
||||
@@ -28,7 +29,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.ownLocale ? it.href : localeHref(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>
|
||||
<% } -%>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user