Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8217a4ff8 | |||
| ea1596eabd | |||
| 1d4c7c88c4 | |||
| 612a0ab8f2 | |||
| 19d90c6323 | |||
| 9cf6c05325 | |||
| cfeee10fa8 | |||
| 5d9bdebf59 | |||
| 64d1387df2 | |||
| 3c64621515 | |||
| 2b09635ed5 | |||
| 9a9c63e625 | |||
| 34b668d49f | |||
| 46548ae758 | |||
| 8f7ab55267 | |||
| 7948596105 | |||
| 38f4cffa43 | |||
| 1dc5892290 | |||
| c063b71d19 | |||
| 89a234b841 | |||
| e03c1d1a2f | |||
| bd76c981ee | |||
| 37b88b2fe6 | |||
| bc659d7d49 | |||
| 01abb2f99c | |||
| 7e4c6940c9 | |||
| 18e1a8d29d | |||
| 93139ea058 | |||
| be3bc2bdbb | |||
| 2b20497785 | |||
| b3df7084c4 | |||
| 6440c543e5 | |||
| 245d1ad5b5 | |||
| c30cd95ebd | |||
| f38b5373bd | |||
| 9966b6bd46 | |||
| 096720904e | |||
| 04fe5b1e06 | |||
| 3486e0ad00 | |||
| 8f9f79ac30 | |||
| b580f7d06e | |||
| f0662cbd0f | |||
| 4b4ac178ab | |||
| 73a78d4404 | |||
| 5a5803b265 | |||
| a2204782fa | |||
| eb5aafdfaa | |||
| 5c3af63847 | |||
| 21cd878447 | |||
| 1abaa22a97 |
@@ -7,7 +7,7 @@ jobs:
|
|||||||
full-gate:
|
full-gate:
|
||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.2.2
|
- uses: actions/checkout@v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # ci.sh's docs-only check needs history; checkout defaults to depth 1
|
fetch-depth: 0 # ci.sh's docs-only check needs history; checkout defaults to depth 1
|
||||||
- run: bash ci.sh
|
- run: bash ci.sh
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
github-mirror:
|
github-mirror:
|
||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.2.2
|
- uses: actions/checkout@v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- run: |
|
- run: |
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ jobs:
|
|||||||
prune-stale-images:
|
prune-stale-images:
|
||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.2.2
|
- uses: actions/checkout@v7.0.1
|
||||||
- name: Delete hash images that are neither release-tagged nor a branch head
|
- name: Delete hash images that are neither release-tagged nor a branch head
|
||||||
env:
|
env:
|
||||||
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||||
@@ -19,4 +19,4 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||||
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
||||||
node:24.18.1-alpine3.24 node registry-cleanup/cleanup.ts
|
node:24.19.0-alpine3.24 node registry-cleanup/cleanup.ts
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ jobs:
|
|||||||
retag-image:
|
retag-image:
|
||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.2.2
|
- uses: actions/checkout@v7.0.1
|
||||||
- name: Promote the commit-hash image to semver + latest
|
- name: Promote the commit-hash image to semver + latest
|
||||||
env:
|
env:
|
||||||
GIT_TAG: ${{ github.ref_name }}
|
GIT_TAG: ${{ github.ref_name }}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
-e RENOVATE_PLATFORM=gitea \
|
-e RENOVATE_PLATFORM=gitea \
|
||||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||||
-e RENOVATE_TOKEN \
|
-e RENOVATE_TOKEN \
|
||||||
renovate/renovate:44.6.0
|
renovate/renovate:44.11.7
|
||||||
|
|
||||||
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
||||||
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
|
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
needs: renovate
|
needs: renovate
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.2.2
|
- uses: actions/checkout@v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Tag a release for what Renovate merged
|
- name: Tag a release for what Renovate merged
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
|
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
|
||||||
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
|
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
|
||||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.18.1-alpine3.24 \
|
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
||||||
node auto-release/next-version.ts "$LATEST" $BUMPS)
|
node auto-release/next-version.ts "$LATEST" $BUMPS)
|
||||||
echo "Releasing $LATEST -> $NEXT"
|
echo "Releasing $LATEST -> $NEXT"
|
||||||
git tag "$NEXT" origin/main
|
git tag "$NEXT" origin/main
|
||||||
|
|||||||
@@ -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/ is a drop-in mount point for your menu/branding override — keep it empty (see examples/config/ for the template)
|
||||||
/config/*
|
/config/*
|
||||||
!/config/.gitkeep
|
!/config/.gitkeep
|
||||||
|
|
||||||
|
# locales/ is a drop-in mount point for extra (or replacement) language catalogs — keep it empty
|
||||||
|
/locales/*
|
||||||
|
!/locales/.gitkeep
|
||||||
|
|||||||
@@ -52,13 +52,14 @@ Intentional, reasoned choices — an architecture review should honor them, not
|
|||||||
them. Revisit only if the stated reason stops holding.
|
them. Revisit only if the stated reason stops holding.
|
||||||
|
|
||||||
- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/`
|
- **`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
|
(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);
|
`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
|
`server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests
|
||||||
are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its
|
are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its
|
||||||
concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain
|
concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain
|
||||||
screens** — even the admin GUI (users/groups/roles) is a drop-in plugin (`examples/plugins/admin/`),
|
screens** — even the admin GUI (users/groups/permissions) is a drop-in plugin (`examples/plugins/admin/`),
|
||||||
not `src/` code.
|
not `src/` code.
|
||||||
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the
|
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the
|
||||||
base request context. It protects the I/O-free hot path on the public, bot-hit landing
|
base request context. It protects the I/O-free hot path on the public, bot-hit landing
|
||||||
@@ -86,6 +87,80 @@ them. Revisit only if the stated reason stops holding.
|
|||||||
`tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks
|
`tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks
|
||||||
in place *and* copies across unchanged. Never commit real plugins/config into the root
|
in place *and* copies across unchanged. Never commit real plugins/config into the root
|
||||||
mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise).
|
mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise).
|
||||||
|
- **Authorization vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships
|
||||||
|
no namespaces — all four in `ory/keto/namespaces.keto.ts` are ours. `Permission` follows RBAC,
|
||||||
|
where a permission is one operation ("read shifts") and a role is a *bundle* of them; a route
|
||||||
|
gates on one operation, so it gates on a permission, and a bundle is just a group with several
|
||||||
|
grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the
|
||||||
|
separate per-row tier.
|
||||||
|
- **Plainpages says "user" everywhere; Ory's word for it is "identity".** Kratos calls the record
|
||||||
|
an identity, but Ory's own docs state it uses that term *interchangeably* with "users" and
|
||||||
|
"accounts" — so this is house style, not a renamed concept, and "user" is the word readers
|
||||||
|
already know (Nielsen's heuristic #2: match between the system and the real world). One note in
|
||||||
|
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
|
- **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`:
|
`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),
|
concurrent jobs can race (one job's logout can 401 another's push — recover by re-running),
|
||||||
@@ -93,6 +168,32 @@ them. Revisit only if the stated reason stops holding.
|
|||||||
workspace dir, so ci.sh's web-image build races another run's container creation on the
|
workspace dir, so ci.sh's web-image build races another run's container creation on the
|
||||||
`<project>-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow
|
`<project>-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow
|
||||||
`concurrency` group if it ever bites.
|
`concurrency` group if it ever bites.
|
||||||
|
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
|
||||||
|
owns open/close, which is the only zero-JS way to dismiss a menu by clicking outside it (the whole
|
||||||
|
point), and the panel sits in the top layer so a row kebab is no longer clipped by `.table-wrap`'s
|
||||||
|
`overflow`. Four rules hold it together, none of them cosmetic. The panel carries
|
||||||
|
**`position-anchor: auto`** — a bare `anchor()` resolves to nothing in Chromium, Firefox *and*
|
||||||
|
WebKit alike, which is why the `@engines`-tagged test in `visual.spec.ts` runs in all three rather
|
||||||
|
than resting on a one-time manual measurement. The panel stays the trigger's **next sibling inside
|
||||||
|
the `.menu` wrapper**, because the open-state style and the old-browser fallback both read that
|
||||||
|
adjacency, and a two-element partial cannot be dropped into an arbitrary layout. The `menu` partial
|
||||||
|
**requires a caller-named `id`** and fails loud without one: it is the `popovertarget` idref, and
|
||||||
|
generated random ids were tried and rejected the same day — nondeterministic HTML forecloses the
|
||||||
|
still-open caching decision and names nothing a reader can use. And **neither `aria-expanded` nor
|
||||||
|
`aria-haspopup` is written**: a zero-JS invoker cannot keep the first truthful, and the second would
|
||||||
|
promise `role="menu"` keyboard semantics these panels do not implement. `<details>` stays where it
|
||||||
|
means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
||||||
|
profile menu because its trigger composes escaped user values and its one item is a CSRF POST form,
|
||||||
|
neither of which the partial's `Item` shapes cover — keep the two in step, or fold it in if
|
||||||
|
`todo.md`'s "does the profile dropdown still earn a dropdown" settles the other way. Decided 2026-08-05.
|
||||||
|
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract.** It is
|
||||||
|
deliberately not re-exported from `#plugin-api`, and README → Nav & permission gates already tells an
|
||||||
|
author that using a new icon means registering it there. So the palette may narrow when the last
|
||||||
|
reference to an id goes — `i-gear` left with the settings menu 2026-08-05 — and a plugin needing one
|
||||||
|
gets it re-registered in the same change. Accepted cost: an unknown sprite id renders a blank icon
|
||||||
|
instead of failing loud; the `every icon <use> resolves to a defined <symbol>` e2e test catches it for
|
||||||
|
anything reaching the nav. Removing an id is a core edit, so weigh it per icon rather than sweeping the
|
||||||
|
registry — a few ids are registered ahead of a caller (see `todo.md`).
|
||||||
|
|
||||||
## Docker only — no host tooling
|
## Docker only — no host tooling
|
||||||
|
|
||||||
@@ -120,10 +221,15 @@ docker compose -f compose.yml up --build -d # production
|
|||||||
running **building plugins** comes first, then **configuring and securing** the system
|
running **building plugins** comes first, then **configuring and securing** the system
|
||||||
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
|
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
|
||||||
deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
|
deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
|
||||||
Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
Users, groups & permissions → Building plugins → menu/blocks/interactivity →
|
||||||
Architecture → Testing → Production → Observability → the JWT-rotation runbook → the
|
Configuration → Auth → Email → Architecture → Testing → Production → Observability → the
|
||||||
Project-layout file map → Extending. When adding a section, place it by this value (how
|
JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place
|
||||||
early an adopter needs it), not by where it sits in the stack.
|
it by this value (how early an adopter needs it), not by where it sits in the stack.
|
||||||
|
|
||||||
|
**Users, groups & permissions precedes Building plugins** because a manifest's
|
||||||
|
`permission:` gate is unreadable without the model, and operators need it as much as plugin
|
||||||
|
authors. It is the one home for that model — the plugin and auth sections link to it rather
|
||||||
|
than restating it.
|
||||||
|
|
||||||
When editing: put content in the section it belongs to (don't prepend rationale above Quick
|
When editing: put content in the section it belongs to (don't prepend rationale above Quick
|
||||||
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
|
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
|
||||||
@@ -160,6 +266,17 @@ 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
|
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.
|
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
|
||||||
`node:24.16.0-alpine3.24`).
|
`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.
|
||||||
|
**The frozen surface includes `views/partials/*.ejs`**, not just the manifest and the barrel: the
|
||||||
|
view resolver makes every core partial an `include()` root for a plugin's views, so their option
|
||||||
|
names and emitted markup are author-visible (under this freeze the popover change dropped the `menu`
|
||||||
|
partial's `open?` and rewrote its markup). Know the hole that leaves — discovery fails loud on a bad
|
||||||
|
`apiVersion`, but `include("menu", { open: true })` silently ignores the option and a plugin styling
|
||||||
|
`.menu > summary` silently loses it. Promotion must cover the partial vocabulary too. Added 2026-08-05.
|
||||||
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version the
|
- 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
|
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
|
`HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the
|
||||||
@@ -170,8 +287,26 @@ Same test before adding a row to a table or the file map — a clause, not a par
|
|||||||
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
|
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
|
||||||
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
|
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
|
||||||
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
|
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
|
||||||
|
- **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking
|
||||||
|
canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin
|
||||||
|
routing, 405/404 and error mapping. The pure parts are already extracted and separately tested; what
|
||||||
|
remains is orchestration. Planned split along those seams; don't grow it further without taking one
|
||||||
|
out. Raised by the architecture review 2026-08-03, deliberately not done inside the i18n change.
|
||||||
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
|
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
|
||||||
agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
|
agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
|
||||||
|
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
|
||||||
|
`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`.
|
||||||
|
- **One verb per action in the English UI: sign in, sign out, create account.** Not "log in",
|
||||||
|
"log out" or "sign up", inflections included — a second spelling for one button reads as a second
|
||||||
|
thing; the noun ("a sign-in error", "the sign-in identifier") is unaffected. A plugin's catalog and
|
||||||
|
every other locale follow the same rule in their own language. An unmapped Kratos id renders
|
||||||
|
Kratos' own wording — map the id when it matters. **Held by the author, never by a test:** as the
|
||||||
|
UI grows, slightly different wording is often the right call, and a check that fails the build on
|
||||||
|
a word takes that judgment away. Maintainer's call 2026-08-05, dropping the guard that shipped
|
||||||
|
with the rule.
|
||||||
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
|
- 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"
|
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
|
||||||
and not "ids=x,y".
|
and not "ids=x,y".
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
||||||
FROM node:24.18.1-alpine3.24
|
FROM node:24.19.0-alpine3.24
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ docker compose up -d # http://localhost:3000, live-reloads on source chan
|
|||||||
**`admin@plainpages.local` / `admin`**.
|
**`admin@plainpages.local` / `admin`**.
|
||||||
|
|
||||||
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
|
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
|
||||||
/ Roles / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
|
/ Permissions / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -r examples/plugins/admin plugins/admin
|
cp -r examples/plugins/admin plugins/admin
|
||||||
docker compose restart web
|
docker compose restart web
|
||||||
```
|
```
|
||||||
|
|
||||||
The seeded admin already holds the `admin` role, so the **Admin** section now shows in the menu.
|
The seeded admin already holds the `admin` permission, so the **Admin** section now shows in the menu.
|
||||||
See [`examples/plugins/admin/`](examples/plugins/admin/).
|
See [`examples/plugins/admin/`](examples/plugins/admin/).
|
||||||
|
|
||||||
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new
|
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new
|
||||||
@@ -69,6 +69,10 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
|
|
||||||
- [Overview](#overview)
|
- [Overview](#overview)
|
||||||
- [how it compares](#how-it-compares)
|
- [how it compares](#how-it-compares)
|
||||||
|
- [Users, groups & permissions](#users-groups--permissions)
|
||||||
|
- [a worked example](#a-worked-example)
|
||||||
|
- [granting a permission](#granting-a-permission)
|
||||||
|
- [fine-grained, per-row access](#fine-grained-per-row-access)
|
||||||
- [Building plugins](#building-plugins)
|
- [Building plugins](#building-plugins)
|
||||||
- [anatomy](#anatomy-of-a-plugin)
|
- [anatomy](#anatomy-of-a-plugin)
|
||||||
- [the manifest](#the-manifest)
|
- [the manifest](#the-manifest)
|
||||||
@@ -76,7 +80,7 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
- [landing pages](#the-landing-pages-home--dashboard)
|
- [landing pages](#the-landing-pages-home--dashboard)
|
||||||
- [RequestContext](#requestcontext)
|
- [RequestContext](#requestcontext)
|
||||||
- [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface)
|
- [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface)
|
||||||
- [nav & permissions](#nav--permissions)
|
- [nav & permission gates](#nav--permission-gates)
|
||||||
- [versioning](#contract-versioning)
|
- [versioning](#contract-versioning)
|
||||||
- [conflict rules](#conflict-rules)
|
- [conflict rules](#conflict-rules)
|
||||||
- [hooks](#hooks)
|
- [hooks](#hooks)
|
||||||
@@ -85,15 +89,17 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
- [The menu system](#the-menu-system)
|
- [The menu system](#the-menu-system)
|
||||||
- [Building blocks](#building-blocks)
|
- [Building blocks](#building-blocks)
|
||||||
- [Interactivity: zero-JS spine](#interactivity-zero-js-spine)
|
- [Interactivity: zero-JS spine](#interactivity-zero-js-spine)
|
||||||
|
- [Languages (i18n)](#languages-i18n)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [canonical host](#canonical-host-one-public-url)
|
- [canonical host](#canonical-host-one-public-url)
|
||||||
- [what you must supply](#what-you-must-supply-the-only-manual-prep)
|
- [what you must supply](#what-you-must-supply-the-only-manual-prep)
|
||||||
- [SSO](#social-sign-in-sso)
|
- [SSO](#social-sign-in-sso)
|
||||||
- [Auth, sessions & permissions](#auth-sessions--permissions)
|
- [Auth, sessions & access](#auth-sessions--access)
|
||||||
- [login & the session JWT](#login-and-the-session-jwt)
|
- [login & the session JWT](#login-and-the-session-jwt)
|
||||||
- [instant revoke](#instant-revoke-the-optional-denylist)
|
- [instant revoke](#instant-revoke-the-optional-denylist)
|
||||||
- [three tiers](#three-tiers-of-may-i)
|
- [three tiers](#three-tiers-of-may-i)
|
||||||
- [OAuth2 (Hydra)](#oauth2-provider-hydra)
|
- [OAuth2 (Hydra)](#oauth2-provider-hydra)
|
||||||
|
- [security model](#security-model)
|
||||||
- [Email](#email)
|
- [Email](#email)
|
||||||
- [Architecture](#architecture)
|
- [Architecture](#architecture)
|
||||||
- [Stateless](#stateless)
|
- [Stateless](#stateless)
|
||||||
@@ -123,14 +129,33 @@ and operational tools, dashboards, portals, or public sites with a gated area
|
|||||||
use or for a client. You know HTTP, Docker, and identity
|
use or for a client. You know HTTP, Docker, and identity
|
||||||
providers, and you'd rather assemble pages from building blocks than fight a framework or
|
providers, and you'd rather assemble pages from building blocks than fight a framework or
|
||||||
hand-roll auth for the tenth time. It's not a no-code tool and doesn't hide its moving
|
hand-roll auth for the tenth time. It's not a no-code tool and doesn't hide its moving
|
||||||
parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--permissions)) reads as
|
parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--access)) reads as
|
||||||
obvious rather than surprising, you're the audience.
|
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 vs. what you add.**
|
||||||
|
|
||||||
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
|
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
|
||||||
system + app shell, the config-driven menu, sessions, and access control. No domain screens.
|
system + app shell, the config-driven menu, sessions, and access control. No domain screens.
|
||||||
- **Opt-in admin plugin:** the **users, groups, roles, and OAuth2-clients** screens (users via
|
- **Opt-in admin plugin:** the **users, groups, permissions, and OAuth2-clients** screens (users via
|
||||||
Kratos, the relationship graph via Keto, OAuth2 clients via Hydra) ship as
|
Kratos, the relationship graph via Keto, OAuth2 clients via Hydra) ship as
|
||||||
[`examples/plugins/admin/`](examples/plugins/admin/) — copy it into `plugins/` to get a GUI for
|
[`examples/plugins/admin/`](examples/plugins/admin/) — copy it into `plugins/` to get a GUI for
|
||||||
user & group admin. It's an ordinary plugin, using the privileged
|
user & group admin. It's an ordinary plugin, using the privileged
|
||||||
@@ -189,10 +214,142 @@ Plainpages sits relative to them:
|
|||||||
| **Themed auth UI on Ory** — Kratos self-service UIs (`ory/kratos-selfservice-ui-node`, `kratos-admin-ui`) | the **login / registration screens** over Ory | The one *slice* with a direct off-the-shelf alternative: Plainpages reimplements it inside its own shell, so you could swap it out to avoid maintaining that part. |
|
| **Themed auth UI on Ory** — Kratos self-service UIs (`ory/kratos-selfservice-ui-node`, `kratos-admin-ui`) | the **login / registration screens** over Ory | The one *slice* with a direct off-the-shelf alternative: Plainpages reimplements it inside its own shell, so you could swap it out to avoid maintaining that part. |
|
||||||
|
|
||||||
No family combines the whole set: **[drop-in plugin folders](#building-plugins)**, a **zero-JS
|
No family combines the whole set: **[drop-in plugin folders](#building-plugins)**, a **zero-JS
|
||||||
server-rendered** design system, **[optional auth](#auth-sessions--permissions)** (any page
|
server-rendered** design system, **[optional auth](#auth-sessions--access)** (any page
|
||||||
public or gated), **no app database**, and a **framework-light TypeScript** core with no build
|
public or gated), **no app database**, and a **framework-light TypeScript** core with no build
|
||||||
step. Each neighbour shares one trait and trades away the rest — Plainpages is the intersection.
|
step. Each neighbour shares one trait and trades away the rest — Plainpages is the intersection.
|
||||||
|
|
||||||
|
## Users, groups & permissions
|
||||||
|
|
||||||
|
Authorization here is two hops: a **user** — directly, or through a **group** — is granted a
|
||||||
|
**permission**, and that permission's *name* is exactly the string a plugin gates on.
|
||||||
|
|
||||||
|
- **Group** answers *who* — a reusable set of people. Optional: a permission can be granted
|
||||||
|
straight to a user.
|
||||||
|
- **Permission** answers *what* — its **name is the string** you write in a manifest's
|
||||||
|
`permission:` gate.
|
||||||
|
- **A relation tuple** is the grant: `Permission:<name>#granted@user:<id>`, or
|
||||||
|
`@Group:<name>#members`.
|
||||||
|
- **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it.
|
||||||
|
|
||||||
|
| Entity | Lives in | Answers | Example |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **User** | Kratos | who you are | `user:0198f2c1-…` |
|
||||||
|
| **Group** | Keto | who — a reusable set | `Group:support` |
|
||||||
|
| **Permission** | Keto | what you may do | `Permission:scheduling:read` |
|
||||||
|
| **Resource** | Keto | which specific row | `Resource:shift-4471` |
|
||||||
|
|
||||||
|
Users live in Kratos; every authorization edge is a Keto relation tuple. The app itself
|
||||||
|
stores none of it — it is [stateless](#stateless).
|
||||||
|
|
||||||
|
**Keto ships no entities of its own.** Its entire model is one primitive —
|
||||||
|
`namespace:object#relation@subject` — so the four namespaces above are *ours*, declared in
|
||||||
|
`ory/keto/namespaces.keto.ts`; Keto only supplies the machinery that resolves them (including
|
||||||
|
transitively, through nested groups).
|
||||||
|
|
||||||
|
> **Ory calls a user an "identity".** Kratos owns that record and names it so: its API is
|
||||||
|
> `/admin/identities`, and a session carries `session.identity`. Plainpages says **user**
|
||||||
|
> everywhere, because that is the word readers already know — and Ory's own documentation states
|
||||||
|
> it uses "identity" interchangeably with "users" and "accounts". You will meet Ory's spelling in
|
||||||
|
> exactly two places: the Kratos API itself, and the `Identity` type in `src/auth/kratos-admin.ts`
|
||||||
|
> that mirrors it.
|
||||||
|
|
||||||
|
> **There is no `Role`.** In RBAC a permission is a single operation ("read shifts") and a role is
|
||||||
|
> a *bundle* of them ("IT Support staff"). A route gates on one operation, so it gates on a
|
||||||
|
> **permission**. When you want the bundle, make a group and grant it several — groups nest, so a
|
||||||
|
> group of groups works too.
|
||||||
|
|
||||||
|
### A worked example
|
||||||
|
|
||||||
|
Alice works support and leads scheduling; Bob works support; Carol administers the system.
|
||||||
|
|
||||||
|
```
|
||||||
|
people groups permissions
|
||||||
|
────── ────── ───────────
|
||||||
|
|
||||||
|
alice ──┬─────────> Group:support ────┐
|
||||||
|
│ ├──> Group:staff ──> Permission:scheduling:read
|
||||||
|
bob ────┘ │
|
||||||
|
│
|
||||||
|
alice ────────────> Group:sched-leads ┴──> Permission:scheduling:write
|
||||||
|
|
||||||
|
carol ───────────────────────────────────────────────> Permission:admin
|
||||||
|
```
|
||||||
|
|
||||||
|
At login the host asks Keto which permissions the user holds, walking those arrows
|
||||||
|
transitively, and bakes the answer into the session JWT (see [Login and the session
|
||||||
|
JWT](#login-and-the-session-jwt)):
|
||||||
|
|
||||||
|
```
|
||||||
|
alice → permissions: ["scheduling:read", "scheduling:write"]
|
||||||
|
bob → permissions: ["scheduling:read"]
|
||||||
|
carol → permissions: ["admin"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Note what Carol does *not* have. **Permissions do not nest, and there is no superuser** — `admin`
|
||||||
|
is just another name, granting nothing except where a route gates on `admin` itself.
|
||||||
|
|
||||||
|
Against the reference plugins' actual routes:
|
||||||
|
|
||||||
|
| Request | Gate | alice | bob | carol | anonymous |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
|
||||||
|
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||||
|
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||||
|
| `GET /admin/users` | `admin` | 403 | 403 | ✅ | → `/login` |
|
||||||
|
|
||||||
|
Bob reaches the shifts list with no direct grant: he is in `support`, support's members are
|
||||||
|
`staff`, and staff holds `scheduling:read` — two hops, resolved by Keto at his login. He is
|
||||||
|
refused the new-shift form because `scheduling:write` hangs off `sched-leads`, which he is not in.
|
||||||
|
An anonymous visitor gets a **redirect**, not a 403, carrying `return_to` so signing in lands them
|
||||||
|
on the page they asked for; a signed-in user who merely lacks the permission gets the 403 page,
|
||||||
|
because there is nothing to sign in *as* that would help. The menu is filtered by the same
|
||||||
|
permissions, so nobody is shown a door they cannot open.
|
||||||
|
|
||||||
|
### Granting a permission
|
||||||
|
|
||||||
|
Write the tuple. The admin plugin's **Groups** and **Permissions** screens do exactly this, or use
|
||||||
|
Keto's write API directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# everyone in sched-leads may write shifts
|
||||||
|
curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application/json' -d '{
|
||||||
|
"namespace": "Permission", "object": "scheduling:write", "relation": "granted",
|
||||||
|
"subject_set": { "namespace": "Group", "object": "sched-leads", "relation": "members" }
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Permissions are authored **only in Keto** — nothing else writes them. Their names are a shared
|
||||||
|
global namespace on purpose, so an operator grants `scheduling:read` once and every plugin
|
||||||
|
referencing it is gated consistently; namespace yours as `<id>:<action>`.
|
||||||
|
|
||||||
|
A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant
|
||||||
|
revoke](#instant-revoke-the-optional-denylist) when you need it sooner.
|
||||||
|
|
||||||
|
### Fine-grained, per-row access
|
||||||
|
|
||||||
|
The `Resource` namespace covers what a coarse permission cannot express: *this* row, shared with
|
||||||
|
*this* person. It is a separate mechanism — a `Resource` carries Keto `permits` (`view`, `edit`,
|
||||||
|
`delete`, which nest as `owner` ⊇ `editor` ⊇ `viewer`) and never appears in the JWT.
|
||||||
|
|
||||||
|
**A per-row grant never widens a coarse gate.** The route's `permission` is checked *before* the
|
||||||
|
handler runs, so a user rejected there never reaches the check. Gate the route on something they
|
||||||
|
hold, then narrow inside the handler:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ method: "POST", path: "/shifts/:id", permission: READ, handler: editShift }
|
||||||
|
|
||||||
|
async function editShift(ctx) {
|
||||||
|
if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id, relation: "editors" })))
|
||||||
|
throw new GuardError(403, "not an editor of this shift");
|
||||||
|
…
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reserve this tier for relationship rules (sharing, delegation, inheritance). Ownership and tenant
|
||||||
|
rules belong in the upstream service that holds the row — see [Three tiers of "may
|
||||||
|
I?"](#three-tiers-of-may-i).
|
||||||
|
|
||||||
## Building plugins
|
## Building plugins
|
||||||
|
|
||||||
A plugin is a self-contained folder under `plugins/` that the host discovers at boot — no
|
A plugin is a self-contained folder under `plugins/` that the host discovers at boot — no
|
||||||
@@ -205,7 +362,7 @@ the host enforces. A complete, runnable example lives in
|
|||||||
**[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a
|
**[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a
|
||||||
permission-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend;
|
permission-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend;
|
||||||
the dev compose ships a tiny mock, `examples/shifts-upstream/`), a CSRF-guarded form forwarding
|
the dev compose ships a tiny mock, `examples/shifts-upstream/`), a CSRF-guarded form forwarding
|
||||||
writes upstream, and a mix of public + role-gated nav. It is **not** pre-installed — `plugins/`
|
writes upstream, and a mix of public + permission-gated nav. It is **not** pre-installed — `plugins/`
|
||||||
ships empty so you mount your own. To run it in dev, copy it in
|
ships empty so you mount your own. To run it in dev, copy it in
|
||||||
(`cp -r examples/plugins/scheduling plugins/scheduling`, then restart) — the dev compose already
|
(`cp -r examples/plugins/scheduling plugins/scheduling`, then restart) — the dev compose already
|
||||||
points `SCHEDULING_UPSTREAM` at its mock backend. Copy it to `plugins/<id>/` and adapt.
|
points `SCHEDULING_UPSTREAM` at its mock backend. Copy it to `plugins/<id>/` and adapt.
|
||||||
@@ -219,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" }
|
things.ejs # your view files; a handler picks one with { view: "things" }
|
||||||
public/ # fixed name, optional — static assets, served at /public/things/
|
public/ # fixed name, optional — static assets, served at /public/things/
|
||||||
things.css # your asset files
|
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
|
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
|
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
|
**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
|
default-exported manifest. `views/`, `public/` and `i18n/` are the fixed folder *names* it resolves
|
||||||
against — used only if the plugin renders views or serves assets — but the files inside are
|
against — used only if the plugin renders views, serves assets or ships translations — but the files
|
||||||
yours to name. Everything else (handlers, upstream clients, their filenames and folder layout)
|
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
|
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
|
just an example — name and arrange your modules however you like, or keep a routes-only plugin to a
|
||||||
single `plugin.ts`.
|
single `plugin.ts`.
|
||||||
@@ -236,7 +395,7 @@ single `plugin.ts`.
|
|||||||
must be **URL/path-safe** (`isValidPluginId`: lowercase `a–z`, digits, and dashes — dashes
|
must be **URL/path-safe** (`isValidPluginId`: lowercase `a–z`, digits, and dashes — dashes
|
||||||
anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name
|
anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name
|
||||||
at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` assets, and (by
|
at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` assets, and (by
|
||||||
convention) its nav/permission tokens.
|
convention) its nav/permission names.
|
||||||
|
|
||||||
A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the
|
A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the
|
||||||
Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
|
Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
|
||||||
@@ -272,10 +431,10 @@ export default definePlugin({
|
|||||||
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
|
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
|
||||||
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
|
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
|
||||||
|
|
||||||
// Permission tokens this plugin introduces. Optional — see Nav & permissions.
|
// Permissions this plugin gates on. Optional — see Nav & permission gates.
|
||||||
permissions: [
|
permissions: [
|
||||||
{ token: "things:read", description: "View things" },
|
{ description: "View things", name: "things:read" },
|
||||||
{ token: "things:write", description: "Create and edit things" },
|
{ description: "Create and edit things", name: "things:write" },
|
||||||
],
|
],
|
||||||
|
|
||||||
// Route handlers, mounted under the plugin's path (/things). `permission` gates first.
|
// Route handlers, mounted under the plugin's path (/things). `permission` gates first.
|
||||||
@@ -297,8 +456,8 @@ 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). |
|
| `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). |
|
| `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). |
|
| `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 | Tokens this plugin introduces. See [Nav & permissions](#nav--permissions). |
|
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
|
||||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||||
| `hooks` | no | See [Hooks](#hooks). |
|
| `hooks` | no | See [Hooks](#hooks). |
|
||||||
|
|
||||||
@@ -309,11 +468,11 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
|
|||||||
A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's
|
A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's
|
||||||
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host
|
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host
|
||||||
matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`,
|
matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`,
|
||||||
runs the `permission` gate (a coarse JWT-claim check — see [Nav & permissions](#nav--permissions)),
|
runs the `permission` gate (a coarse JWT-claim check — see [Nav & permission gates](#nav--permission-gates)),
|
||||||
and only then calls the handler with the [request context](#requestcontext). When the gate fails, an
|
and only then calls the handler with the [request context](#requestcontext). When the gate fails, an
|
||||||
**anonymous** visitor is redirected to `/login` to sign in; the
|
**anonymous** visitor is redirected to `/login` to sign in; the
|
||||||
requested page is preserved as `return_to`, so after signing in they land **back on the page they
|
requested page is preserved as `return_to`, so after signing in they land **back on the page they
|
||||||
asked for**, not the dashboard. A **signed-in** user who simply lacks the role gets the **403** page.
|
asked for**, not the dashboard. A **signed-in** user who simply lacks the permission gets the **403** page.
|
||||||
A route marked **`public: true`** has no gate at all — anyone reaches it (see [Public pages & menu
|
A route marked **`public: true`** has no gate at all — anyone reaches it (see [Public pages & menu
|
||||||
items](#public-pages--menu-items)).
|
items](#public-pages--menu-items)).
|
||||||
|
|
||||||
@@ -356,7 +515,7 @@ export async function listThings(ctx: RequestContext) {
|
|||||||
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
|
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
|
||||||
- **Finer authorization than the route `permission`** uses the guards from `#plugin-api`:
|
- **Finer authorization than the route `permission`** uses the guards from `#plugin-api`:
|
||||||
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
|
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
|
||||||
to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
|
to sign in), `can(ctx, permission)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
|
||||||
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
|
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
|
||||||
signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check`
|
signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check`
|
||||||
to render the 403 page.
|
to render the 403 page.
|
||||||
@@ -412,7 +571,7 @@ a signed-in visitor, or sign-in / register to an anonymous one). After login the
|
|||||||
points there.
|
points there.
|
||||||
|
|
||||||
For the gated `dashboard`, the host enforces the session gate first, so `ctx.user` is non-null;
|
For the gated `dashboard`, the host enforces the session gate first, so `ctx.user` is non-null;
|
||||||
branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a
|
branch on `ctx.permissions` *inside* to tailor the page per permission. Don't gate `dashboard` itself behind a
|
||||||
single permission — there's no second dashboard to fall back to, so a user lacking it would land on a
|
single permission — there's no second dashboard to fall back to, so a user lacking it would land on a
|
||||||
403. (Both slots answer `GET` and `HEAD`.)
|
403. (Both slots answer `GET` and `HEAD`.)
|
||||||
|
|
||||||
@@ -429,15 +588,19 @@ request:
|
|||||||
```ts
|
```ts
|
||||||
interface RequestContext {
|
interface RequestContext {
|
||||||
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
|
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
|
||||||
|
user: User | null; // { id, email, permissions } from the verified session JWT, or null
|
||||||
log: Log; // request-scoped logger, in this request's trace
|
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 }
|
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
|
query: URLSearchParams; // alias of url.searchParams
|
||||||
req: IncomingMessage;
|
req: IncomingMessage;
|
||||||
res: ServerResponse;
|
res: ServerResponse;
|
||||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
|
||||||
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
|
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
|
||||||
url: URL;
|
url: URL;
|
||||||
user: User | null; // { id, email, roles } from the verified session JWT, or null
|
|
||||||
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
|
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -446,7 +609,7 @@ interface RequestContext {
|
|||||||
theme, user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same
|
theme, user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same
|
||||||
sidebar, branding, theme switch and signed-in profile every page uses); `chrome.nav` is the
|
sidebar, branding, theme switch and signed-in profile every page uses); `chrome.nav` is the
|
||||||
global menu — your plugin's nav fragment plus every other installed plugin's (the admin section among
|
global menu — your plugin's nav fragment plus every other installed plugin's (the admin section among
|
||||||
them, when that plugin is present) — already composed, role-filtered, and current-marked for this
|
them, when that plugin is present) — already composed, permission-filtered, and current-marked for this
|
||||||
request (the gated **Dashboard** link is omitted for an
|
request (the gated **Dashboard** link is omitted for an
|
||||||
anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the
|
anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the
|
||||||
current page baked in as `return_to`. Map each `chrome.*` to the matching `partials/shell` local —
|
current page baked in as `return_to`. Map each `chrome.*` to the matching `partials/shell` local —
|
||||||
@@ -455,13 +618,17 @@ reference `examples/plugins/scheduling/views/overview.ejs` does; a value you for
|
|||||||
shell default (e.g. a bare `/login`), it does not error. **`ctx.verifyCsrf(submitted)`** guards a
|
shell default (e.g. a bare `/login`), it does not error. **`ctx.verifyCsrf(submitted)`** guards a
|
||||||
state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then on POST read your own
|
state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then on POST read your own
|
||||||
body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the
|
body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the
|
||||||
secret and sets the cookie; the plugin never touches it. (See the reference: `examples/plugins/scheduling/`.)
|
secret and sets the cookie; the plugin never touches it. It is **opt-in per handler** — a route
|
||||||
|
that never calls it has no CSRF guard at all. (See the reference: `examples/plugins/scheduling/`.)
|
||||||
|
|
||||||
The same shell renders **every** page (the dashboard, your plugin pages — the admin plugin's included, and the
|
The same shell renders **every** page (the dashboard, your plugin pages — the admin plugin's included, and the
|
||||||
login/registration/front pages), so the menu looks identical signed in or out — it just role-filters.
|
login/registration/front pages), so the menu looks identical signed in or out — it just permission-filters.
|
||||||
A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the
|
A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the
|
||||||
sidebar, single column); everything else still renders.
|
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))
|
**`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`,
|
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`
|
metadata values are string/number/boolean), and **`ctx.log.fetch(url, init?)`** — a drop-in `fetch`
|
||||||
@@ -475,7 +642,7 @@ OpenTelemetry Collector when `OTLP_ENDPOINT` is set).
|
|||||||
**Stability guarantee.** The fields above are the stable contract — present and non-breaking
|
**Stability guarantee.** The fields above are the stable contract — present and non-breaking
|
||||||
across a major `apiVersion`. New fields may be **added** within a major version (additive, never
|
across a major `apiVersion`. New fields may be **added** within a major version (additive, never
|
||||||
breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine,
|
breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine,
|
||||||
but prefer the typed fields so a handler keeps working as the host evolves. `user`/`roles` come
|
but prefer the typed fields so a handler keeps working as the host evolves. `user`/`permissions` come
|
||||||
from the JWT middleware and are `null`/`[]` until a session exists.
|
from the JWT middleware and are `null`/`[]` until a session exists.
|
||||||
|
|
||||||
### System capabilities (the `ctx.system` surface)
|
### System capabilities (the `ctx.system` surface)
|
||||||
@@ -489,7 +656,7 @@ needs the host's Ory admin clients and the instant-revoke hook instead. The host
|
|||||||
```ts
|
```ts
|
||||||
interface SystemCapabilities { // every field optional — present only when the host wired it
|
interface SystemCapabilities { // every field optional — present only when the host wired it
|
||||||
hydra?: HydraAdmin; // OAuth2 client admin (register/list/delete Hydra clients)
|
hydra?: HydraAdmin; // OAuth2 client admin (register/list/delete Hydra clients)
|
||||||
keto?: KetoClient; // relationship read/write (groups, roles)
|
keto?: KetoClient; // relationship read/write (groups, permissions)
|
||||||
kratosAdmin?: KratosAdmin; // identity admin (create/edit/deactivate/delete users)
|
kratosAdmin?: KratosAdmin; // identity admin (create/edit/deactivate/delete users)
|
||||||
revoke?: (sub: string) => void; // instant-revoke a subject's live tokens (needs the denylist)
|
revoke?: (sub: string) => void; // instant-revoke a subject's live tokens (needs the denylist)
|
||||||
}
|
}
|
||||||
@@ -499,20 +666,20 @@ interface SystemCapabilities { // every field optional — present only
|
|||||||
Hydra configured, the [revocation denylist](#instant-revoke-the-optional-denylist) enabled). A system
|
Hydra configured, the [revocation denylist](#instant-revoke-the-optional-denylist) enabled). A system
|
||||||
plugin treats every field as optional and **degrades when absent** — the host never fails a request
|
plugin treats every field as optional and **degrades when absent** — the host never fails a request
|
||||||
over it. The built-in **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the
|
over it. The built-in **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the
|
||||||
reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups/Roles use `ctx.system.keto`,
|
reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups/Permissions use `ctx.system.keto`,
|
||||||
OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user role-change calls
|
OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user permission-change calls
|
||||||
`ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing
|
`ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing
|
||||||
the screen renders a themed 503.
|
the screen renders a themed 503.
|
||||||
|
|
||||||
This is a **privileged** surface — it hands a plugin the keys to identity and permissions. It's meant
|
This is a **privileged** surface — it hands a plugin the keys to identity and authorization. It's meant
|
||||||
for first-party system plugins you author or vendor, the same trust level as any plugin (the host
|
for first-party system plugins you author or vendor, the same trust level as any plugin (the host
|
||||||
doesn't sandbox — [crash-isolation is a non-goal](#overview)). An ordinary domain plugin ignores it.
|
doesn't sandbox — [crash-isolation is a non-goal](#overview)). An ordinary domain plugin ignores it.
|
||||||
|
|
||||||
### Nav & permissions
|
### Nav & permission gates
|
||||||
|
|
||||||
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
|
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
|
||||||
applies the central override and then **filters per user** by the roles in the session JWT — a
|
applies the central override and then **filters per user** by the permissions in the session JWT — a
|
||||||
node shows iff it is `public`, declares no `permission`, or the user's roles include that token. Use
|
node shows iff it is `public`, declares no `permission`, or the user's permissions include that name. Use
|
||||||
arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a
|
arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a
|
||||||
**Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids
|
**Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids
|
||||||
are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
|
are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
|
||||||
@@ -520,30 +687,26 @@ are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its luci
|
|||||||
#### Public pages & menu items
|
#### Public pages & menu items
|
||||||
|
|
||||||
A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**,
|
A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**,
|
||||||
and the menu item shows for everyone. This is the same as omitting `permission` (a no-permission
|
and the menu item shows for everyone. This is the same as omitting `permission` (an ungated
|
||||||
route/node is already open) but stated outright, so "public" is a **deliberate choice, not the
|
route/node is already open) but stated outright, so "public" is a **deliberate choice, not the
|
||||||
accident of a forgotten gate**. `public` and `permission` are **mutually exclusive** — declaring
|
accident of a forgotten gate**. `public` and `permission` are **mutually exclusive** — declaring
|
||||||
both is contradictory and discovery refuses the plugin at boot.
|
both is contradictory and discovery refuses the plugin at boot.
|
||||||
|
|
||||||
A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor
|
A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor
|
||||||
`ctx.user` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page)
|
`ctx.user` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page)
|
||||||
in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.roles` is
|
in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.permissions` is
|
||||||
empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling`
|
empty (read a permission with `can(ctx, …)` to branch). The reference plugin's `/scheduling`
|
||||||
**Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone,
|
**Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone,
|
||||||
while the actual shifts list stays behind `scheduling:read`.
|
while the actual shifts list stays behind `scheduling:read`.
|
||||||
|
|
||||||
**A `permission` token is a coarse role.** The route/nav gate passes iff the user's JWT `roles`
|
The gate passes iff the user's JWT `permissions` include that name. How permissions are granted, why their
|
||||||
include the token; those roles come from Keto at login, so an operator grants a token by writing the
|
names are a shared global namespace, and the fine-grained per-row tier are all covered in
|
||||||
Keto tuple `Role:<token>#members@user:<id>` (or to a group) — the admin **Roles** screen does this.
|
[Users, groups & permissions](#users-groups--permissions).
|
||||||
(The fine-grained, per-row tier is the separate Keto `Resource` namespace — see
|
|
||||||
[Three tiers of "may I?"](#three-tiers-of-may-i); it is not what a route `permission` checks.)
|
|
||||||
|
|
||||||
Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants
|
Declaring the ones you gate on in `permissions` is **optional but recommended**: it documents them,
|
||||||
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace
|
feeds conflict detection, and lets the one-command bootstrap seed them — the demo admin is
|
||||||
your tokens as `<id>:<action>` to avoid accidental clashes. Declaring them in `permissions` is
|
granted every discovered plugin's declared permissions, so a dropped-in plugin works out of the box
|
||||||
optional but recommended: it documents them, feeds conflict detection, and lets the one-command
|
without editing host config.
|
||||||
bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens, so
|
|
||||||
a dropped-in plugin works out of the box without editing host config.
|
|
||||||
|
|
||||||
### Contract versioning
|
### Contract versioning
|
||||||
|
|
||||||
@@ -578,7 +741,7 @@ with `findConflicts` and resolves them **loudly — never last-write-wins**. `er
|
|||||||
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
|
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
|
||||||
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
|
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
|
||||||
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
|
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
|
||||||
| `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `<id>:<action>` if unintended. |
|
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; namespace as `<id>:<action>` if unintended. |
|
||||||
|
|
||||||
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its
|
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its
|
||||||
uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns
|
uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns
|
||||||
@@ -713,11 +876,11 @@ The menu is **driven entirely by config** and assembled from two sources:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Every nav item may carry a `permission`; the rendered tree is **filtered per user** by
|
Every nav item may carry a `permission`; the rendered tree is **filtered per user** by
|
||||||
reading the roles in the session JWT (no per-request authz call — see
|
reading the permissions in the session JWT (no per-request authz call — see
|
||||||
[Auth, sessions & permissions](#auth-sessions--permissions)), so the menu only ever shows
|
[Auth, sessions & access](#auth-sessions--access)), so the menu only ever shows
|
||||||
what that person can reach. An item (or a whole page) may instead be marked **`public:
|
what that person can reach. An item (or a whole page) may instead be marked **`public:
|
||||||
true`** to show it to **everyone, signed in or not** — the blessed, explicit way to expose
|
true`** to show it to **everyone, signed in or not** — the blessed, explicit way to expose
|
||||||
a public page and its menu entry (a no-permission item is already public; `public` just
|
a public page and its menu entry (an ungated item is already public; `public` just
|
||||||
says so on purpose, and is mutually exclusive with `permission`). The markup is the
|
says so on purpose, and is mutually exclusive with `permission`). The markup is the
|
||||||
recursive, zero-JS nav tree from the design foundation (header/leaf × clickable/static,
|
recursive, zero-JS nav tree from the design foundation (header/leaf × clickable/static,
|
||||||
counts, arbitrary depth). Branding (name, logo, default theme) renders in the app shell —
|
counts, arbitrary depth). Branding (name, logo, default theme) renders in the app shell —
|
||||||
@@ -743,14 +906,19 @@ set of reusable EJS partials + TS helpers, fully styled and zero-JS:
|
|||||||
- **Helpers:** `composeNav` (menu from config), `parseListQuery`
|
- **Helpers:** `composeNav` (menu from config), `parseListQuery`
|
||||||
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the
|
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the
|
||||||
auth guards a handler calls to authorize (`src/auth/guards.ts`): `requireSession` (assert a
|
auth guards a handler calls to authorize (`src/auth/guards.ts`): `requireSession` (assert a
|
||||||
session — a `GuardError` the host turns into a redirect to sign in), `can(role)` (a coarse
|
session — a `GuardError` the host turns into a redirect to sign in), `can(permission)` (a coarse
|
||||||
JWT-claim check, zero I/O), `check(relation, object)` (the one live Keto call, for
|
JWT-claim check, zero I/O), `check(relation, object)` (the one live Keto call, for
|
||||||
relationship rules).
|
relationship rules).
|
||||||
|
|
||||||
## Interactivity: zero-JS spine
|
## Interactivity: zero-JS spine
|
||||||
|
|
||||||
The core and all building blocks **work with zero JavaScript** — menus, theme switching,
|
The core and all building blocks **work with zero JavaScript** — theme switching and filtering
|
||||||
and filtering are pure CSS + GET forms. On the [low-end, low-bandwidth targets](#overview)
|
are pure CSS + GET forms, and menus are the platform's own [popover
|
||||||
|
API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API): a `<button popovertarget>`
|
||||||
|
opens the panel and the browser dismisses it on a click outside or `Esc`; CSS anchor positioning
|
||||||
|
places it. On a browser too old for the popover API the trigger is inert, so each panel falls back to
|
||||||
|
flowing inline underneath it — cramped inside a table cell, but nothing is unreachable. That path
|
||||||
|
is deliberately untested: no browser that supports popovers can render it. On the [low-end, low-bandwidth targets](#overview)
|
||||||
we care about this is usually *faster*: a round-trip returning a small, pre-rendered HTML
|
we care about this is usually *faster*: a round-trip returning a small, pre-rendered HTML
|
||||||
page beats a client-side runtime that must boot, fetch JSON, and re-render before anything
|
page beats a client-side runtime that must boot, fetch JSON, and re-render before anything
|
||||||
shows. List state (`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is
|
shows. List state (`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is
|
||||||
@@ -760,6 +928,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
|
may **opt into progressive enhancement** (htmx, Alpine, or vanilla JS) on top of working
|
||||||
server-rendered HTML. The baseline never depends on it.
|
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
|
## Configuration
|
||||||
|
|
||||||
Read from the environment once at boot (`src/config.ts`) and validated there — a bad URL,
|
Read from the environment once at boot (`src/config.ts`) and validated there — a bad URL,
|
||||||
@@ -785,13 +1079,13 @@ The app is **environment-agnostic**: there is no `NODE_ENV`. Behaviour that used
|
|||||||
| `OTLP_ENDPOINT` | _unset_ | OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only) |
|
| `OTLP_ENDPOINT` | _unset_ | OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only) |
|
||||||
| `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` |
|
| `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` |
|
||||||
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) |
|
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) |
|
||||||
| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | permission check / write |
|
| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | authorization check / write |
|
||||||
| `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) |
|
| `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) |
|
||||||
| `JWKS_URL` | `file://…/tokenizer/jwks.json` | the Kratos tokenizer signing key; verifies the session JWT |
|
| `JWKS_URL` | `file://…/tokenizer/jwks.json` | the Kratos tokenizer signing key; verifies the session JWT |
|
||||||
| `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) |
|
| `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) |
|
||||||
| `JWT_CLOCK_SKEW_SEC` | `60` | exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets `0`) |
|
| `JWT_CLOCK_SKEW_SEC` | `60` | exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets `0`) |
|
||||||
| `ORY_TIMEOUT_SEC` | `5` | per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request |
|
| `ORY_TIMEOUT_SEC` | `5` | per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request |
|
||||||
| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant role/session revoke denylist](#instant-revoke-the-optional-denylist) |
|
| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant permission/session revoke denylist](#instant-revoke-the-optional-denylist) |
|
||||||
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
|
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
|
||||||
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
|
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
|
||||||
|
|
||||||
@@ -836,20 +1130,35 @@ both default to `localhost` (the dev override sets `APP_URL=http://localhost:300
|
|||||||
|
|
||||||
A clean clone needs **none** of the above — `docker compose up` brings up the whole stack
|
A clean clone needs **none** of the above — `docker compose up` brings up the whole stack
|
||||||
with dev-throwaway secrets, an auto-generated signing key, and a seeded admin (see
|
with dev-throwaway secrets, an auto-generated signing key, and a seeded admin (see
|
||||||
[Quick start](#quick-start)). Exactly **two** things can't be auto-generated, and **both
|
[Quick start](#quick-start)). What can't be auto-generated is **production-only** — none of it
|
||||||
are production-only** — neither blocks a clean clone:
|
blocks a clean clone:
|
||||||
|
|
||||||
|
1. **Production secrets** — every value below ships as a committed dev throwaway that works
|
||||||
|
out of the box and **must** be replaced before a deploy faces the internet. Only the first
|
||||||
|
is enforced: `REQUIRE_SECURE_SECRETS=true` refuses to boot on a missing or throwaway
|
||||||
|
`CSRF_SECRET` and **nothing else** — the rest fail silently, so treat this as a checklist.
|
||||||
|
|
||||||
|
| Secret | Where | Protects |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `CSRF_SECRET` | web env | signs our double-submit CSRF token |
|
||||||
|
| JWT signing key | mount a real `jwks.json` or set `…_JWKS_URL` | mints/verifies the session JWT — see [rotation](#jwt-signing-key--rotation) |
|
||||||
|
| `SECRETS_COOKIE` | kratos env | signs Kratos' session + anti-CSRF cookies |
|
||||||
|
| `SECRETS_CIPHER` | kratos env (32 chars) | encrypts credentials at rest |
|
||||||
|
| `SECRETS_SYSTEM` | hydra env | encrypts OAuth2 tokens + consent at rest |
|
||||||
|
| `POSTGRES_USER` / `POSTGRES_PASSWORD` | compose env | the Ory databases (default `ory`/`ory`) |
|
||||||
|
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | bootstrap env | the seeded first admin login (default `admin@plainpages.local` / `admin`) |
|
||||||
|
|
||||||
|
`CSRF_SECRET`, the Postgres pair and the admin pair are interpolated from the host
|
||||||
|
environment. The three Ory secrets are **not**: `compose.yml` passes only `DSN` to
|
||||||
|
`kratos`/`hydra`, so add them to those services' `environment:` (or an `env_file:`) or they
|
||||||
|
silently stay on the throwaways.
|
||||||
|
|
||||||
1. **Production secrets** — replace the committed dev throwaway `CSRF_SECRET` (env), plus
|
|
||||||
the **JWT signing key** (mount a real `jwks.json` or set `…_JWKS_URL` — see
|
|
||||||
[JWT signing key & rotation](#jwt-signing-key--rotation)). Set
|
|
||||||
`REQUIRE_SECURE_SECRETS=true` and the app refuses to boot until `CSRF_SECRET` is supplied
|
|
||||||
and differs from the throwaway.
|
|
||||||
2. **SSO provider client id/secret** — **optional**; password login works without them.
|
2. **SSO provider client id/secret** — **optional**; password login works without them.
|
||||||
Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see
|
Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see
|
||||||
[Social sign-in (SSO)](#social-sign-in-sso)).
|
[Social sign-in (SSO)](#social-sign-in-sso)).
|
||||||
|
|
||||||
Everything else is generated or seeded on first boot — Ory migrations, the dev signing key,
|
Everything else is generated or seeded on first boot — Ory migrations, the dev signing key,
|
||||||
the demo admin identity and its Keto roles, the Keto OPL model — so there is nothing else to
|
the demo admin identity and its Keto permissions, the Keto OPL model — so there is nothing else to
|
||||||
hand-configure.
|
hand-configure.
|
||||||
|
|
||||||
### Social sign-in (SSO)
|
### Social sign-in (SSO)
|
||||||
@@ -864,7 +1173,7 @@ button, and the whole SSO section disappears when none are configured — no cod
|
|||||||
add or remove one. Open-source Kratos has **no native SAML** — front it with an OIDC bridge
|
add or remove one. Open-source Kratos has **no native SAML** — front it with an OIDC bridge
|
||||||
(Ory Polis) and register that bridge as a generic OIDC provider the same way.
|
(Ory Polis) and register that bridge as a generic OIDC provider the same way.
|
||||||
|
|
||||||
## Auth, sessions & permissions
|
## Auth, sessions & access
|
||||||
|
|
||||||
Identity comes from **Kratos**; the hot path stays I/O-free by carrying coarse authorization
|
Identity comes from **Kratos**; the hot path stays I/O-free by carrying coarse authorization
|
||||||
in a **locally-validated JWT**, and **Keto** is reserved for the rare fine-grained,
|
in a **locally-validated JWT**, and **Keto** is reserved for the rare fine-grained,
|
||||||
@@ -883,32 +1192,32 @@ the session for a signed JWT once** via the Kratos **session tokenizer** (`whoam
|
|||||||
```
|
```
|
||||||
── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
|
── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
|
||||||
Kratos verifies credentials
|
Kratos verifies credentials
|
||||||
└─► app reads the user's roles from Keto (direct + transitive via groups)
|
└─► app reads the user's permissions from Keto (direct + transitive via groups)
|
||||||
└─► app writes them as a derived projection on the identity (admin API)
|
└─► app writes them as a derived projection on the identity (admin API)
|
||||||
└─► whoami(tokenize_as: "plainpages") ─► signed JWT
|
└─► whoami(tokenize_as: "plainpages") ─► signed JWT
|
||||||
claims: { sub, email, roles:[…from Keto], exp ≈ 10m }
|
claims: { sub, email, permissions:[…from Keto], exp ≈ 10m }
|
||||||
└─► stored as the session cookie
|
└─► stored as the session cookie
|
||||||
|
|
||||||
── EVERY REQUEST (hot path — pure CPU, no I/O) ───────────────────
|
── EVERY REQUEST (hot path — pure CPU, no I/O) ───────────────────
|
||||||
Browser ─cookie(JWT)─► web : verify signature (cached JWKS)
|
Browser ─cookie(JWT)─► web : verify signature (cached JWKS)
|
||||||
read claims.roles
|
read claims.permissions
|
||||||
filter menu · gate routes
|
filter menu · gate routes
|
||||||
```
|
```
|
||||||
|
|
||||||
**Keto is the single source of truth for roles.** Coarse roles are Keto relations (e.g.
|
**Keto is the single source of truth for permissions.** Coarse permissions are Keto relations (e.g.
|
||||||
`role:admin#members@user:alice`); the admin screens write them *only* to Keto. But the
|
`Permission:admin#granted@user:alice`); the admin screens write them *only* to Keto. But the
|
||||||
tokenizer's claims mapper can read only the **identity**, not call Keto — so at login the
|
tokenizer's claims mapper can read only the **identity**, not call Keto — so at login the
|
||||||
app reads the roles from Keto and refreshes a **derived projection**: a read-only copy
|
app reads the permissions from Keto and refreshes a **derived projection**: a read-only copy
|
||||||
written onto the identity's `metadata_public` for the tokenizer to see, which the template
|
written onto the identity's `metadata_public` for the tokenizer to see, which the template
|
||||||
maps into the JWT `roles` claim. (It must be `metadata_public`, not `metadata_admin`: the
|
maps into the JWT `permissions` claim. (It must be `metadata_public`, not `metadata_admin`: the
|
||||||
session Kratos hands the tokenizer carries only *public* metadata — and the user can already
|
session Kratos hands the tokenizer carries only *public* metadata — and the user can already
|
||||||
read these coarse roles in their own JWT, so nothing is leaked.) That projection is a
|
read these coarse permissions in their own JWT, so nothing is leaked.) That projection is a
|
||||||
per-login cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals
|
per-login cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals
|
||||||
on the next login.
|
on the next login.
|
||||||
|
|
||||||
A role can be granted to a user directly or to a **group** the user belongs to; login
|
A permission can be granted to a user directly or to a **group** the user belongs to; login
|
||||||
resolves both (enumerate the defined roles, ask Keto to resolve each membership), so the JWT
|
resolves both (enumerate the defined permissions, ask Keto to resolve each membership), so the JWT
|
||||||
`roles` match what the admin **Effective access** view shows.
|
`permissions` match what the admin **Effective access** view shows.
|
||||||
|
|
||||||
Cost: **a handful of Keto reads + one identity refresh per login** — never per request. JWKS
|
Cost: **a handful of Keto reads + one identity refresh per login** — never per request. JWKS
|
||||||
is cached, so even signature verification hits the network only on key rotation. The app
|
is cached, so even signature verification hits the network only on key rotation. The app
|
||||||
@@ -920,8 +1229,8 @@ recomputed from Keto.
|
|||||||
This design buys an I/O-free hot path that scales to **tens of thousands of concurrent
|
This design buys an I/O-free hot path that scales to **tens of thousands of concurrent
|
||||||
users** on modest hardware. In return:
|
users** on modest hardware. In return:
|
||||||
|
|
||||||
- **Role changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a granted
|
- **Permission changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a granted
|
||||||
or revoked role only takes effect when the token is next minted (re-login or TTL refresh).
|
or revoked permission only takes effect when the token is next minted (re-login or TTL refresh).
|
||||||
For an admin tool this is intentional — the alternative is a Keto call per request, which
|
For an admin tool this is intentional — the alternative is a Keto call per request, which
|
||||||
we traded away. For instant revoke, turn on the optional
|
we traded away. For instant revoke, turn on the optional
|
||||||
[revocation denylist](#instant-revoke-the-optional-denylist) — it closes the gap for
|
[revocation denylist](#instant-revoke-the-optional-denylist) — it closes the gap for
|
||||||
@@ -934,12 +1243,12 @@ users** on modest hardware. In return:
|
|||||||
### Instant revoke: the optional denylist
|
### Instant revoke: the optional denylist
|
||||||
|
|
||||||
Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/auth/denylist.ts`). For
|
Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/auth/denylist.ts`). For
|
||||||
security-critical revoke (offboarding, a compromised account) the ~10m role/session lag
|
security-critical revoke (offboarding, a compromised account) the ~10m permission/session lag
|
||||||
above is too long. When enabled, an admin **deactivating** or **deleting** a user, or
|
above is too long. When enabled, an admin **deactivating** or **deleting** a user, or
|
||||||
**granting/revoking** a role to a *user*, records that subject as revoked-now; the hot path
|
**granting/revoking** a permission to a *user*, records that subject as revoked-now; the hot path
|
||||||
then rejects every token for it minted **before** the revoke and forces a re-mint — which
|
then rejects every token for it minted **before** the revoke and forces a re-mint — which
|
||||||
re-reads roles from Keto, or clears a now-dead session. A fresh re-login (its JWT issued
|
re-reads permissions from Keto, or clears a now-dead session. A fresh re-login (its JWT issued
|
||||||
*after* the revoke) passes, so a role downgrade lands immediately without locking the
|
*after* the revoke) passes, so a permission downgrade lands immediately without locking the
|
||||||
account.
|
account.
|
||||||
|
|
||||||
It's an in-memory, auto-evicting map — no database, like the JWKS cache, so it stays inside
|
It's an in-memory, auto-evicting map — no database, like the JWKS cache, so it stays inside
|
||||||
@@ -949,10 +1258,13 @@ CPU — **Keto stays off the hot path**. Two deliberate bounds: it's instant on
|
|||||||
instance** that handled the revoke (across replicas/restarts the guarantee falls back to the
|
instance** that handled the revoke (across replicas/restarts the guarantee falls back to the
|
||||||
token TTL — back the denylist with a shared store for hard multi-instance instant-revoke),
|
token TTL — back the denylist with a shared store for hard multi-instance instant-revoke),
|
||||||
and a **group** membership change is transitive across many users, so it's left to lag —
|
and a **group** membership change is transitive across many users, so it's left to lag —
|
||||||
deactivate the user, or use a direct user-role change, for an instant effect.
|
deactivate the user, or use a direct user-permission change, for an instant effect.
|
||||||
|
|
||||||
### Three tiers of "may I?"
|
### Three tiers of "may I?"
|
||||||
|
|
||||||
|
[Users, groups & permissions](#users-groups--permissions) covers *what* the entities are; this is where each
|
||||||
|
**kind** of rule belongs.
|
||||||
|
|
||||||
```
|
```
|
||||||
coarse (menu / route / feature) → JWT claim · in-process, zero I/O
|
coarse (menu / route / feature) → JWT claim · in-process, zero I/O
|
||||||
fine + attribute (owner / tenant / …) → upstream service that owns the row
|
fine + attribute (owner / tenant / …) → upstream service that owns the row
|
||||||
@@ -967,10 +1279,8 @@ deactivate the user, or use a direct user-role change, for an instant effect.
|
|||||||
is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already
|
is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already
|
||||||
answer from its own data.
|
answer from its own data.
|
||||||
|
|
||||||
The built-in users / groups / permissions screens write authorization **only to Keto** —
|
The admin plugin's users / groups / permissions screens write authorization **only to Keto** — coarse
|
||||||
coarse roles and fine-grained relationships alike. Roles reach the JWT by being read from
|
permissions and fine-grained relationships alike.
|
||||||
Keto at login and projected through the tokenizer (above); nothing authors them anywhere
|
|
||||||
else.
|
|
||||||
|
|
||||||
### OAuth2 provider (Hydra)
|
### OAuth2 provider (Hydra)
|
||||||
|
|
||||||
@@ -997,6 +1307,42 @@ generated `client_secret` **once**, on the confirmation page — confidential cl
|
|||||||
delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration;
|
delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration;
|
||||||
writes go only to Hydra.
|
writes go only to Hydra.
|
||||||
|
|
||||||
|
### Security model
|
||||||
|
|
||||||
|
Everything above is *how* auth works. These are the few things the code won't tell you quickly,
|
||||||
|
and that get a deployment wrong if you don't know them.
|
||||||
|
|
||||||
|
**The private container network is the *only* thing guarding the Ory APIs.** Kratos admin
|
||||||
|
(`4434`), Hydra admin (`4445`) and Keto write (`4467`) authenticate no one — reaching them *is*
|
||||||
|
full identity and authorization control. Keto **read** (`4466`) cannot write, but discloses the
|
||||||
|
entire authorization graph, so treat it the same. `compose.yml` publishes none of the six Ory
|
||||||
|
ports (guarded by `src/compose.test.ts`); dev publishes only the two a browser must reach. Never
|
||||||
|
expose one, and never front one with a proxy that lacks its own auth.
|
||||||
|
|
||||||
|
**The JWT is signed, not encrypted.** Claims are base64: a signed-in user can read their own
|
||||||
|
`sub`, `email` and `permissions`. `HttpOnly` keeps page JavaScript out of the cookie, not the user.
|
||||||
|
Never put anything in a claim you wouldn't show them.
|
||||||
|
|
||||||
|
**The JWT's ~10m TTL is not the session lifetime.** The browser also holds Kratos'
|
||||||
|
`plainpages_session` cookie (30 days, sliding), and *that* is what silently re-mints a lapsed
|
||||||
|
JWT. So a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our
|
||||||
|
two cookies obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
|
||||||
|
|
||||||
|
**Offboarding is not instant by default.** An expired JWT re-mints off that live Kratos session,
|
||||||
|
re-reading permissions from Keto — so a revoked permission, or a deactivated identity, lands within one
|
||||||
|
token TTL rather than immediately. With the
|
||||||
|
[denylist](#instant-revoke-the-optional-denylist) on (it is off by default), both take effect at
|
||||||
|
once, on the instance that handled the change.
|
||||||
|
|
||||||
|
**Not guaranteed** — accepted, and stated where each mechanism is: permission changes
|
||||||
|
[lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the
|
||||||
|
denylist is [single-instance and skips group changes](#instant-revoke-the-optional-denylist).
|
||||||
|
Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing
|
||||||
|
**every** committed dev secret — see
|
||||||
|
[what you must supply](#what-you-must-supply-the-only-manual-prep). `REQUIRE_SECURE_SECRETS`
|
||||||
|
guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres' or the demo admin's
|
||||||
|
throwaways.
|
||||||
|
|
||||||
## Email
|
## Email
|
||||||
|
|
||||||
The only emails are the **recovery** and **verification** codes from Kratos' self-service
|
The only emails are the **recovery** and **verification** codes from Kratos' self-service
|
||||||
@@ -1025,7 +1371,7 @@ docs for the full template-type list and the data each template receives.
|
|||||||
|
|
||||||
Plainpages runs as a small set of containers, orchestrated by Docker Compose:
|
Plainpages runs as a small set of containers, orchestrated by Docker Compose:
|
||||||
|
|
||||||
| Container | Role |
|
| Container | Permission |
|
||||||
| -------------- | ---- |
|
| -------------- | ---- |
|
||||||
| `web` | The Node 24 + TypeScript app: server-rendered EJS, the plugin host, the building-block partials. Stays tiny. |
|
| `web` | The Node 24 + TypeScript app: server-rendered EJS, the plugin host, the building-block partials. Stays tiny. |
|
||||||
| `kratos` | **Ory Kratos** — identity: login, registration, password reset, SSO, sessions. |
|
| `kratos` | **Ory Kratos** — identity: login, registration, password reset, SSO, sessions. |
|
||||||
@@ -1035,12 +1381,12 @@ Plainpages runs as a small set of containers, orchestrated by Docker Compose:
|
|||||||
|
|
||||||
The `web` app is an Ory **relying party**: it never stores passwords. At login it turns
|
The `web` app is an Ory **relying party**: it never stores passwords. At login it turns
|
||||||
the Kratos session into a short-lived, **locally-validated JWT** (the Kratos session
|
the Kratos session into a short-lived, **locally-validated JWT** (the Kratos session
|
||||||
tokenizer) carrying the user's coarse roles — so every later request gates the menu and
|
tokenizer) carrying the user's coarse permissions — so every later request gates the menu and
|
||||||
pages by **verifying the JWT in-process, with no per-request call to Ory**. Keto answers
|
pages by **verifying the JWT in-process, with no per-request call to Ory**. Keto answers
|
||||||
the rarer fine-grained checks; Hydra is used only when the app acts as an OAuth2 **login &
|
the rarer fine-grained checks; Hydra is used only when the app acts as an OAuth2 **login &
|
||||||
consent provider** for other apps. It reaches the Ory services over their **REST APIs
|
consent provider** for other apps. It reaches the Ory services over their **REST APIs
|
||||||
using Node's built-in `fetch`** — no SDK dependency. See
|
using Node's built-in `fetch`** — no SDK dependency. See
|
||||||
[Auth, sessions & permissions](#auth-sessions--permissions).
|
[Auth, sessions & access](#auth-sessions--access).
|
||||||
|
|
||||||
In **dev** the host-facing Ory ports are published — Kratos public `4433` (where the browser
|
In **dev** the host-facing Ory ports are published — Kratos public `4433` (where the browser
|
||||||
POSTs self-service flows) and Hydra public `4444`; **prod** (`docker compose -f compose.yml
|
POSTs self-service flows) and Hydra public `4444`; **prod** (`docker compose -f compose.yml
|
||||||
@@ -1081,11 +1427,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`
|
E2E runs in the official Playwright image (browsers preinstalled) against the live `web`
|
||||||
service — no Node/browsers on the host. There are five suites:
|
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
|
**Visual + design system** (`visual.spec.ts`, `language.spec.ts`) — Ory-free, so it stays fast. It
|
||||||
the live pages and asserts the rendered design system — the app shell, theme switch, mobile
|
screenshots the live pages and asserts the rendered design system — the app shell, theme switch,
|
||||||
off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and
|
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
|
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
|
```bash
|
||||||
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --build --rm e2e # run the suite
|
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --build --rm e2e # run the suite
|
||||||
@@ -1096,7 +1444,7 @@ docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v
|
|||||||
boots the real Ory stack (Postgres + Kratos + Keto + bootstrap), shortens the session→JWT TTL
|
boots the real Ory stack (Postgres + Kratos + Keto + bootstrap), shortens the session→JWT TTL
|
||||||
to 8s (`ory/kratos/e2e.yml`) and sets `JWT_CLOCK_SKEW_SEC=0`, then logs in the seeded admin
|
to 8s (`ory/kratos/e2e.yml`) and sets `JWT_CLOCK_SKEW_SEC=0`, then logs in the seeded admin
|
||||||
and proves the "stay signed in" hot path: the lapsed JWT is silently **re-minted** from the
|
and proves the "stay signed in" hot path: the lapsed JWT is silently **re-minted** from the
|
||||||
live Kratos session (roles re-read from Keto), and once that session is revoked the stale
|
live Kratos session (permissions re-read from Keto), and once that session is revoked the stale
|
||||||
cookie is **cleared**.
|
cookie is **cleared**.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1118,7 +1466,7 @@ docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v
|
|||||||
|
|
||||||
**Full browser flow** (`full-flow.spec.ts`) — the real Playwright UI against the live stack:
|
**Full browser flow** (`full-flow.spec.ts`) — the real Playwright UI against the live stack:
|
||||||
the themed **password login** and a **mocked-SSO** login (an in-network mock OIDC provider,
|
the themed **password login** and a **mocked-SSO** login (an in-network mock OIDC provider,
|
||||||
`e2e-tests/mock-oidc.ts`), **menu filtering by role**, the **users/groups/roles** admin CRUD, the
|
`e2e-tests/mock-oidc.ts`), **menu filtering by permission**, the **users/groups/permissions** admin CRUD, the
|
||||||
**OAuth2-clients** admin screen (register → one-time secret → delete; Hydra is part of this stack
|
**OAuth2-clients** admin screen (register → one-time secret → delete; Hydra is part of this stack
|
||||||
for it), a permission-gated **plugin page**, and **logout**. Because the themed form posts straight to
|
for it), a permission-gated **plugin page**, and **logout**. Because the themed form posts straight to
|
||||||
Kratos and cookies are host-scoped, a tiny same-origin gateway (`e2e-tests/proxy.ts`) fronts web +
|
Kratos and cookies are host-scoped, a tiny same-origin gateway (`e2e-tests/proxy.ts`) fronts web +
|
||||||
@@ -1164,7 +1512,7 @@ bash ci.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
Each E2E suite **owns a clean stack** — never point two suites at one backend (auth-refresh
|
Each E2E suite **owns a clean stack** — never point two suites at one backend (auth-refresh
|
||||||
revokes the admin's sessions; full-flow writes users/groups/roles to Keto), which is why the
|
revokes the admin's sessions; full-flow writes users/groups/permissions to Keto), which is why the
|
||||||
gate runs them serially, one stack up/down per suite.
|
gate runs them serially, one stack up/down per suite.
|
||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
@@ -1314,8 +1662,8 @@ The server drains in-flight requests on `SIGTERM`/`SIGINT` rather than cutting t
|
|||||||
mid-response, so container restarts are clean.
|
mid-response, so container restarts are clean.
|
||||||
|
|
||||||
The first-boot **bootstrap** is idempotent and runs on every `up` — it generates the JWT
|
The first-boot **bootstrap** is idempotent and runs on every `up` — it generates the JWT
|
||||||
signing key if absent, creates the demo admin in Kratos, and grants it the `admin` role plus
|
signing key if absent, creates the demo admin in Kratos, and grants it the `admin` permission plus
|
||||||
every discovered plugin's declared permission tokens in Keto, so permission checks (and any
|
every discovered plugin's declared permission names in Keto, so permission checks (and any
|
||||||
dropped-in plugin) resolve out of the box. The web app waits for Kratos + Keto to be healthy
|
dropped-in plugin) resolve out of the box. The web app waits for Kratos + Keto to be healthy
|
||||||
*and* the bootstrap to finish before starting. **Change the demo admin before production.**
|
*and* the bootstrap to finish before starting. **Change the demo admin before production.**
|
||||||
|
|
||||||
@@ -1336,7 +1684,7 @@ whole handler (an `AsyncLocalStorage`), so logs and traces correlate. Three expl
|
|||||||
|
|
||||||
Every request emits one access line (`method`, `path` — the query is dropped, it can carry
|
Every request emits one access line (`method`, `path` — the query is dropped, it can carry
|
||||||
tokens — `status`, `ms`, `requestId`); login/logout, admin writes (who-did-what), and
|
tokens — `status`, `ms`, `requestId`); login/logout, admin writes (who-did-what), and
|
||||||
missing-role/CSRF rejections log at `info`/`warn`, and the catch-all 500 + the
|
missing-permission/CSRF rejections log at `info`/`warn`, and the catch-all 500 + the
|
||||||
Ory-unreachable re-mint at `error`/`warn`. An inbound W3C `traceparent` is **adopted**, so a
|
Ory-unreachable re-mint at `error`/`warn`. An inbound W3C `traceparent` is **adopted**, so a
|
||||||
request continues a trace started by an upstream proxy/gateway.
|
request continues a trace started by an upstream proxy/gateway.
|
||||||
|
|
||||||
@@ -1397,7 +1745,7 @@ container-relative; with the dev bind-mount they edit the real file).
|
|||||||
2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`.
|
2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`.
|
||||||
(web needs no restart — it hot-reloads the file. The hot path verifies JWTs locally, so a
|
(web needs no restart — it hot-reloads the file. The hot path verifies JWTs locally, so a
|
||||||
brief Kratos blip only touches login/re-mint.)
|
brief Kratos blip only touches login/re-mint.)
|
||||||
3. **Verify** new logins mint the new `kid` — decode the `plainpages_session` cookie's JWT
|
3. **Verify** new logins mint the new `kid` — decode the `plainpages_jwt` cookie's JWT
|
||||||
header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients
|
header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients
|
||||||
present the new key.
|
present the new key.
|
||||||
4. **Wait ~12 min**, then **prune** the superseded key:
|
4. **Wait ~12 min**, then **prune** the superseded key:
|
||||||
@@ -1448,10 +1796,10 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
|||||||
|
|
||||||
auth/ Identity, the session-JWT hot path, guards, and the Ory REST clients
|
auth/ Identity, the session-JWT hot path, guards, and the Ory REST clients
|
||||||
jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
|
jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
|
||||||
jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles; flags a lapsed token for re-mint
|
jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/permissions; flags a lapsed token for re-mint
|
||||||
jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload)
|
jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload)
|
||||||
gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation
|
gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation
|
||||||
login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie
|
login.ts completeLogin()/remintSession(): login completion + TTL re-mint — permissions from Keto → metadata_public projection → tokenize → session JWT cookie
|
||||||
guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route permission gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call
|
guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route permission gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call
|
||||||
csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate
|
csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate
|
||||||
denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
|
denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
|
||||||
@@ -1459,13 +1807,23 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
|||||||
oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login
|
oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login
|
||||||
oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes
|
oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes
|
||||||
routes.ts buildAuthRoutes(): the built-in auth/OAuth2 endpoints as named handlers on the internal route table — themed flow pages, /oauth2/* challenges, /auth/complete, POST /logout, /error; only what the wired clients support is registered
|
routes.ts buildAuthRoutes(): the built-in auth/OAuth2 endpoints as named handlers on the internal route table — themed flow pages, /oauth2/* challenges, /auth/complete, POST /logout, /error; only what the wired clients support is registered
|
||||||
bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
|
bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin permission in Keto
|
||||||
kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize
|
kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize
|
||||||
kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection)
|
kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login permission projection)
|
||||||
keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API)
|
keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API)
|
||||||
hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD
|
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
|
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-host/ Plugin discovery, routing, hooks, view resolution + the stable author barrel
|
||||||
plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath()
|
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)
|
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)
|
||||||
@@ -1479,19 +1837,20 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
|||||||
chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (unified across all pages) — exposed on ctx.chrome
|
chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (unified across all pages) — exposed on ctx.chrome
|
||||||
shell-context.ts buildShellContext(): brand/theme/user view-model for the dashboard shell (real signed-in user, no demo profile)
|
shell-context.ts buildShellContext(): brand/theme/user view-model for the dashboard shell (real signed-in user, no demo profile)
|
||||||
dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler). Both render the one unified menu (ctx.chrome)
|
dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler). Both render the one unified menu (ctx.chrome)
|
||||||
nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
|
nav.ts composeNav(): merge plugin nav fragments + central override, permission-filter → nav-tree model
|
||||||
menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding, imported as `#menu-config`), validated at boot
|
menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding, imported as `#menu-config`), validated at boot
|
||||||
icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
|
icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
|
||||||
list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
|
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
|
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/Roles/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)
|
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
|
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
|
||||||
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 — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
|
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
|
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/Roles/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)
|
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)
|
||||||
e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them
|
e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-permission, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them
|
||||||
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
|
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
|
||||||
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
|
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
|
||||||
mirror.yml — force-sync main + tags to the GitHub mirror; see CI/CD
|
mirror.yml — force-sync main + tags to the GitHub mirror; see CI/CD
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ services:
|
|||||||
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
||||||
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead.
|
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.18.1-alpine3.24
|
image: node:24.19.0-alpine3.24
|
||||||
command: node /srv/server.ts
|
command: node /srv/server.ts
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+3
-3
@@ -130,9 +130,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||||
# Base roles for the demo admin; bootstrap also grants every discovered plugin's declared
|
# Base permissions for the demo admin; bootstrap also grants every discovered plugin's declared
|
||||||
# permission tokens (so the reference plugin — and any drop-in — works out of the box).
|
# permission names (so the reference plugin — and any drop-in — works out of the box).
|
||||||
ADMIN_ROLES: ${ADMIN_ROLES:-admin}
|
ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-admin}
|
||||||
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
|
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
|
||||||
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
||||||
KETO_WRITE_URL: http://keto:4467
|
KETO_WRITE_URL: http://keto:4467
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
const WEB = process.env.BASE_URL ?? "http://web:3000";
|
const WEB = process.env.BASE_URL ?? "http://web:3000";
|
||||||
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
||||||
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
|
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
|
||||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin role granted in Keto
|
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin permission granted in Keto
|
||||||
const ADMIN_PASSWORD = "admin";
|
const ADMIN_PASSWORD = "admin";
|
||||||
|
|
||||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||||
@@ -29,8 +29,8 @@ function relayCookies(res: Response): string {
|
|||||||
.filter((kv) => kv.split("=")[1] !== "")
|
.filter((kv) => kv.split("=")[1] !== "")
|
||||||
.join("; ");
|
.join("; ");
|
||||||
}
|
}
|
||||||
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/roles).
|
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/permissions).
|
||||||
function jwtClaims(jwt: string): { email: string; exp: number; roles: string[]; sub: string } {
|
function jwtClaims(jwt: string): { email: string; exp: number; permissions: string[]; sub: string } {
|
||||||
return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
|
return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ async function awaitJwtSetCookie(session: string, jwt: string): Promise<string>
|
|||||||
test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => {
|
test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => {
|
||||||
test.setTimeout(90_000); // two short-TTL windows (8s each) + Ory round-trips
|
test.setTimeout(90_000); // two short-TTL windows (8s each) + Ory round-trips
|
||||||
|
|
||||||
// 1. Log in for real, then complete login on web → our session JWT (roles read from Keto).
|
// 1. Log in for real, then complete login on web → our session JWT (permissions read from Keto).
|
||||||
const session = await kratosLogin();
|
const session = await kratosLogin();
|
||||||
const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
|
const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
|
||||||
expect(complete.status, "auth/complete redirects home").toBe(303);
|
expect(complete.status, "auth/complete redirects home").toBe(303);
|
||||||
@@ -83,7 +83,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
|
|||||||
const claims1 = jwtClaims(jwt1);
|
const claims1 = jwtClaims(jwt1);
|
||||||
expect(claims1.email).toBe(ADMIN_EMAIL);
|
expect(claims1.email).toBe(ADMIN_EMAIL);
|
||||||
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
|
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
|
||||||
expect(claims1.roles, "roles are projected from Keto").toContain("admin");
|
expect(claims1.permissions, "permissions are projected from Keto").toContain("admin");
|
||||||
|
|
||||||
// 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
|
// 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
|
||||||
const jwt2Line = await awaitJwtSetCookie(session, jwt1);
|
const jwt2Line = await awaitJwtSetCookie(session, jwt1);
|
||||||
@@ -91,7 +91,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
|
|||||||
expect(jwt2, "a different token was minted").not.toBe(jwt1);
|
expect(jwt2, "a different token was minted").not.toBe(jwt1);
|
||||||
const claims2 = jwtClaims(jwt2);
|
const claims2 = jwtClaims(jwt2);
|
||||||
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp);
|
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp);
|
||||||
expect(claims2.roles, "re-mint re-reads roles from Keto").toContain("admin");
|
expect(claims2.permissions, "re-mint re-reads permissions from Keto").toContain("admin");
|
||||||
|
|
||||||
// 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared.
|
// 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared.
|
||||||
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
|
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
||||||
# login, menu filtering by role, users/groups/roles/OAuth2-clients CRUD, a plugin page, logout. A
|
# login, menu filtering by permission, users/groups/permissions/OAuth2-clients CRUD, a plugin page, logout. A
|
||||||
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
||||||
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
|
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
|
||||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e
|
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e
|
||||||
@@ -30,7 +30,7 @@ services:
|
|||||||
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
||||||
- ./examples/plugins/admin:/app/plugins/admin:ro
|
- ./examples/plugins/admin:/app/plugins/admin:ro
|
||||||
|
|
||||||
# bootstrap grants the demo admin every discovered plugin's permission tokens, so it needs the
|
# bootstrap grants the demo admin every discovered plugin's permission names, so it needs the
|
||||||
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
|
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
|
||||||
bootstrap:
|
bootstrap:
|
||||||
volumes:
|
volumes:
|
||||||
@@ -53,7 +53,7 @@ services:
|
|||||||
|
|
||||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.18.1-alpine3.24
|
image: node:24.19.0-alpine3.24
|
||||||
command: ["node", "/server.ts"]
|
command: ["node", "/server.ts"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
||||||
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
||||||
mock-oidc:
|
mock-oidc:
|
||||||
image: node:24.18.1-alpine3.24
|
image: node:24.19.0-alpine3.24
|
||||||
command: ["node", "/mock-oidc.ts"]
|
command: ["node", "/mock-oidc.ts"]
|
||||||
environment:
|
environment:
|
||||||
ISSUER: http://mock-oidc:9000
|
ISSUER: http://mock-oidc:9000
|
||||||
@@ -81,7 +81,7 @@ services:
|
|||||||
|
|
||||||
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
||||||
proxy:
|
proxy:
|
||||||
image: node:24.18.1-alpine3.24
|
image: node:24.19.0-alpine3.24
|
||||||
command: ["node", "/proxy.ts"]
|
command: ["node", "/proxy.ts"]
|
||||||
depends_on:
|
depends_on:
|
||||||
web:
|
web:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# Playwright E2E. Brings up the app + a Playwright runner and exercises the live pages (design
|
# 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 run --build --rm e2e
|
||||||
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v # tear down after
|
# 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.
|
# --build rebuilds the runner (the image bakes in e2e-tests/) so spec edits are picked up.
|
||||||
@@ -29,8 +30,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: e2e-tests/Dockerfile
|
dockerfile: e2e-tests/Dockerfile
|
||||||
# Just the Ory-free visual suite; the full-stack auth spec runs via e2e-tests/compose.auth.yml.
|
# The Ory-free suites (design system + language switching); the full-stack auth spec runs via
|
||||||
command: ["npx", "playwright", "test", "visual.spec.ts"]
|
# e2e-tests/compose.auth.yml.
|
||||||
|
command: ["npx", "playwright", "test", "visual.spec.ts", "language.spec.ts"]
|
||||||
depends_on:
|
depends_on:
|
||||||
web:
|
web:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ async function signIn(page: import("@playwright/test").Page): Promise<void> {
|
|||||||
|
|
||||||
test("seeded admin logs in from the advertised URL (http://localhost:3000) and reaches the dashboard", async ({ page }) => {
|
test("seeded admin logs in from the advertised URL (http://localhost:3000) and reaches the dashboard", async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
// Open the app at the URL the first-run banner prints, then follow its "Log in" call to action.
|
// Open the app at the URL the first-run banner prints, then follow the landing's "Sign in" action.
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByRole("link", { name: "Log in" }).click();
|
await page.locator("#main-content").getByRole("link", { name: "Sign in" }).click();
|
||||||
await signIn(page);
|
await signIn(page);
|
||||||
|
|
||||||
// Signed in on the app — NOT dumped on the Kratos /error "Page not found" page.
|
// Signed in on the app — NOT dumped on the Kratos /error "Page not found" page.
|
||||||
|
|||||||
+81
-10
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
|
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
|
||||||
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
|
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
|
||||||
// (no cross-group shared backend writes) or serialise the file if that ever changes.
|
// (no cross-group shared backend writes) or serialise the file if that ever changes.
|
||||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin role in Keto
|
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin permission in Keto
|
||||||
const ADMIN_PASSWORD = "admin";
|
const ADMIN_PASSWORD = "admin";
|
||||||
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
|
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
|
||||||
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
|
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
|
||||||
@@ -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
|
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", () => {
|
test.describe.serial("authenticated admin journey", () => {
|
||||||
let browser: Browser;
|
let browser: Browser;
|
||||||
let page: Page;
|
let page: Page;
|
||||||
@@ -36,7 +49,58 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
});
|
});
|
||||||
test.afterAll(async () => { await page.context().close(); });
|
test.afterAll(async () => { await page.context().close(); });
|
||||||
|
|
||||||
test("menu filters by role: an admin sees the gated Admin section + the plugin", async () => {
|
// 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('button[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('button[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
|
// 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).
|
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
@@ -56,6 +120,11 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
const row = page.locator("tr", { hasText: email });
|
const row = page.locator("tr", { hasText: email });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
|
// Row actions sit behind the kebab popover: opening it reveals them, in the top layer, so the
|
||||||
|
// scrolling table around the row cannot clip the panel.
|
||||||
|
await row.locator("button.kebab").click();
|
||||||
|
await expect(row.locator('a[href^="/admin/users/"]').first()).toBeVisible();
|
||||||
|
|
||||||
// Delete through the confirm interstitial (the row's Edit link carries the id).
|
// Delete through the confirm interstitial (the row's Edit link carries the id).
|
||||||
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
||||||
await page.goto(`${editHref}/delete`);
|
await page.goto(`${editHref}/delete`);
|
||||||
@@ -65,7 +134,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("groups + roles CRUD: create one of each (writes go to Keto) and see them listed", async () => {
|
test("groups + permissions CRUD: create one of each (writes go to Keto) and see them listed", async () => {
|
||||||
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
|
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
|
||||||
// enforces it); pick the first option (a user) from the required picker.
|
// enforces it); pick the first option (a user) from the required picker.
|
||||||
const group = `e2e-grp-${suffix}`;
|
const group = `e2e-grp-${suffix}`;
|
||||||
@@ -76,13 +145,13 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
|
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
|
||||||
await expect(page.locator("main")).toContainText(group);
|
await expect(page.locator("main")).toContainText(group);
|
||||||
|
|
||||||
const role = `e2e-role-${suffix}`;
|
const permission = `e2e-permission-${suffix}`;
|
||||||
await page.goto("/admin/roles/new");
|
await page.goto("/admin/permissions/new");
|
||||||
await page.fill('input[name="name"]', role);
|
await page.fill('input[name="name"]', permission);
|
||||||
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
||||||
await page.locator('.form-card button[type="submit"]').click();
|
await page.locator('.form-card button[type="submit"]').click();
|
||||||
await expect(page).toHaveURL(/\/admin\/roles(\?|\/|$)/);
|
await expect(page).toHaveURL(/\/admin\/permissions(\?|\/|$)/);
|
||||||
await expect(page.locator("main")).toContainText(role);
|
await expect(page.locator("main")).toContainText(permission);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
|
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
|
||||||
@@ -122,7 +191,9 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
|
|
||||||
test("logout: signing out ends the session and returns to the login page", async () => {
|
test("logout: signing out ends the session and returns to the login page", async () => {
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await page.locator("summary.profile").click(); // open the profile dropdown
|
await page.locator("button.profile").click(); // open the profile dropdown
|
||||||
|
// Sign out is the only item in it — the menu offers nothing that goes nowhere.
|
||||||
|
await expect(page.locator("#profile-menu .menu-item")).toHaveText(["Sign out"]);
|
||||||
await page.locator('form[action="/logout"] button[type="submit"]').click();
|
await page.locator('form[action="/logout"] button[type="submit"]').click();
|
||||||
await page.waitForURL(/\/login(\?|$)/);
|
await page.waitForURL(/\/login(\?|$)/);
|
||||||
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
|
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
|
||||||
@@ -153,6 +224,6 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({
|
|||||||
await page.locator(".sso-btn").click();
|
await page.locator(".sso-btn").click();
|
||||||
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
|
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
|
||||||
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
|
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
|
||||||
// A fresh SSO identity holds no roles, so the gated Admin section stays hidden.
|
// A fresh SSO identity holds no permissions, so the gated Admin section stays hidden.
|
||||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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('button[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('button[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");
|
||||||
|
});
|
||||||
@@ -15,5 +15,11 @@ export default defineConfig({
|
|||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
viewport: { width: 1280, height: 800 },
|
viewport: { width: 1280, height: 800 },
|
||||||
},
|
},
|
||||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
// CSS anchor positioning is the newest platform feature in the app and every popup menu rests on
|
||||||
|
// it, so the tests tagged @engines run in all three engines; the rest stay on chromium.
|
||||||
|
projects: [
|
||||||
|
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||||
|
{ name: "firefox", grep: /@engines/, use: { ...devices["Desktop Firefox"] } },
|
||||||
|
{ name: "webkit", grep: /@engines/, use: { ...devices["Desktop Safari"] } },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,19 +13,19 @@ const shot = (page: Page, name: string): Promise<Buffer> =>
|
|||||||
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
|
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
|
||||||
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
|
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
|
||||||
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
|
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
|
||||||
function devSession(roles: string[] = []): string {
|
function devSession(permissions: string[] = []): string {
|
||||||
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
|
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
|
||||||
const key = createPrivateKey({ format: "jwk", key: jwk });
|
const key = createPrivateKey({ format: "jwk", key: jwk });
|
||||||
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
|
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
|
||||||
const now = Math.floor(Date.now() / 1000);
|
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, roles, sub: "visual-demo" })}`;
|
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "visual-demo" })}`;
|
||||||
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
|
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
|
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
|
||||||
|
|
||||||
// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
|
// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
|
||||||
// member (no roles) so the gated scheduling nav stays filtered out.
|
// member (no permissions) so the gated scheduling nav stays filtered out.
|
||||||
test.beforeEach(async ({ context }) => {
|
test.beforeEach(async ({ context }) => {
|
||||||
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
||||||
});
|
});
|
||||||
@@ -70,6 +70,35 @@ test("theme switch flips the palette with no JavaScript", async ({ page }) => {
|
|||||||
expect(dark).not.toBe(light);
|
expect(dark).not.toBe(light);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The menus are <button popovertarget> + [popover], so the browser dismisses them: the visitor no
|
||||||
|
// longer has to click the trigger again to get rid of one. Driven through the language picker; the
|
||||||
|
// profile menu is the same block. Anchoring is asserted too — without `position-anchor` the panel
|
||||||
|
// silently detaches and lands in the middle of the viewport.
|
||||||
|
test("a popover menu sits on its trigger and closes on an outside click or Esc — no JavaScript @engines", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
const trigger = page.locator('button[aria-label="Language"]');
|
||||||
|
const panel = page.locator('button[aria-label="Language"] + .menu-pop');
|
||||||
|
|
||||||
|
await expect(panel).toBeHidden();
|
||||||
|
await trigger.click();
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
|
||||||
|
// Anchored to the button that opened it: directly above (.up), right edges flush.
|
||||||
|
const t = (await trigger.boundingBox())!;
|
||||||
|
const p = (await panel.boundingBox())!;
|
||||||
|
expect(Math.abs(p.x + p.width - (t.x + t.width))).toBeLessThan(2);
|
||||||
|
expect(t.y - (p.y + p.height)).toBeGreaterThan(-1); // above the trigger, subpixel-tolerant
|
||||||
|
expect(t.y - (p.y + p.height)).toBeLessThan(12);
|
||||||
|
|
||||||
|
await page.getByRole("heading", { name: "Starter dashboard" }).click(); // anywhere else on the page
|
||||||
|
await expect(panel).toBeHidden();
|
||||||
|
|
||||||
|
await trigger.click();
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(panel).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
|
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
@@ -99,10 +128,12 @@ test("the public landing at / is ungated and links to sign in + register", async
|
|||||||
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
|
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.locator(".landing")).toBeVisible();
|
await expect(page.locator(".landing")).toBeVisible();
|
||||||
// the same app shell every page renders — the menu shows even signed out (role-filtered).
|
// the same app shell every page renders — the menu shows even signed out (permission-filtered).
|
||||||
await expect(page.locator(".sidebar")).toBeVisible();
|
await expect(page.locator(".sidebar")).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
|
await expect(page.locator('use[href="#i-gear"]')).toHaveCount(0); // no settings cog to offer a signed-out visitor
|
||||||
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
|
// Scoped to the landing itself: the anonymous sidebar offers a "Sign in" link of its own.
|
||||||
|
await expect(page.locator("#main-content").getByRole("link", { name: "Sign in" })).toHaveAttribute("href", "/login");
|
||||||
|
await expect(page.locator("#main-content").getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
|
||||||
await shot(page, "live-05-public-landing");
|
await shot(page, "live-05-public-landing");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,7 +167,7 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
|
|||||||
expect(res.status()).toBe(303);
|
expect(res.status()).toBe(303);
|
||||||
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
||||||
|
|
||||||
// The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav,
|
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
|
||||||
// but the gated Shifts leaf is filtered out.
|
// but the gated Shifts leaf is filtered out.
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
||||||
|
|||||||
+1
-1
@@ -6,6 +6,6 @@ across (or bind-mount your own) and restart.
|
|||||||
| Path | Copy into | Example of |
|
| Path | Copy into | Example of |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
|
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
|
||||||
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
|
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
|
||||||
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
|
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
|
||||||
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
|
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ import { defineMenu } from "#menu-config";
|
|||||||
export default defineMenu({
|
export default defineMenu({
|
||||||
branding: {
|
branding: {
|
||||||
name: "Plainpages", // app name shown in the sidebar
|
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)
|
// logo: "/public/logo.svg", // optional logo asset (rendered in the sidebar brand)
|
||||||
// theme: "auto", // default color theme: auto | light | dark
|
// theme: "auto", // default color theme: auto | light | dark
|
||||||
},
|
},
|
||||||
|
|
||||||
// Operator override (rename → group → order → hide), keyed by node id.
|
// Operator override (rename → group → order → hide), keyed by node id.
|
||||||
override: {
|
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", "roles"] }],
|
// groups: [{ id: "admin", label: "Admin", children: ["users", "permissions"] }],
|
||||||
// order: ["people", "reports"], // top-level order by id
|
// order: ["people", "reports"], // top-level order by id
|
||||||
// hide: ["teams"], // remove nodes (any depth)
|
// hide: ["teams"], // remove nodes (any depth)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Admin — the system-administration plugin
|
# Admin — the system-administration plugin
|
||||||
|
|
||||||
The Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. These used to be
|
The Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. These used to be
|
||||||
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
|
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
|
||||||
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
|
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
|
||||||
screens live at `/admin/*`) and restart:
|
screens live at `/admin/*`) and restart:
|
||||||
@@ -10,9 +10,14 @@ cp -r examples/plugins/admin plugins/admin
|
|||||||
docker compose restart web
|
docker compose restart web
|
||||||
```
|
```
|
||||||
|
|
||||||
The seeded `admin@plainpages.local` already holds the `admin` role, so the section appears in the
|
The seeded `admin@plainpages.local` already holds the `admin` permission, so the section appears in the
|
||||||
menu and the screens work immediately.
|
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
|
## What it demonstrates — a *system* plugin
|
||||||
|
|
||||||
Most plugins fetch their data from an upstream service of their own (see the [scheduling
|
Most plugins fetch their data from an upstream service of their own (see the [scheduling
|
||||||
@@ -20,10 +25,10 @@ reference](../scheduling/README.md)). The admin screens instead administer **Pla
|
|||||||
stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin:
|
stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin:
|
||||||
|
|
||||||
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
|
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
|
||||||
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Roles).
|
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Permissions).
|
||||||
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
|
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
|
||||||
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
|
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
|
||||||
user's role change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
||||||
|
|
||||||
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
|
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
|
||||||
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
|
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
|
||||||
@@ -32,9 +37,9 @@ gated per route by `permission: "admin"`, rendering the core building blocks in
|
|||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission token, and the
|
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission, and the
|
||||||
route table — one thin handler per method+path, all gated by `permission: "admin"`.
|
route table — one thin handler per method+path, all gated by `permission: "admin"`.
|
||||||
- `admin-users.ts` · `admin-groups.ts` · `admin-roles.ts` · `admin-clients.ts` — each a set of pure
|
- `admin-users.ts` · `admin-groups.ts` · `admin-permissions.ts` · `admin-clients.ts` — each a set of pure
|
||||||
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
|
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
|
||||||
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
|
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
|
||||||
admin gate + the needed `ctx.system` clients once.
|
admin gate + the needed `ctx.system` clients once.
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
|
// 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.
|
// 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 { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
|
||||||
import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
import { ADMIN_CLIENTS_BASE, ADMIN_EN, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
@@ -64,14 +64,14 @@ export function clientPayload(input: ClientInput): Record<string, unknown> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateClientInput(input: ClientInput): string | null {
|
export function validateClientInput(input: ClientInput, t: Translate = ADMIN_EN): string | null {
|
||||||
if (!input.name) return "Enter a name for the client.";
|
if (!input.name) return t("admin.clients.validation.name");
|
||||||
if (!input.redirectUris.length) return "Add at least one redirect URI.";
|
if (!input.redirectUris.length) return t("admin.clients.validation.redirectUris");
|
||||||
for (const uri of input.redirectUris) {
|
for (const uri of input.redirectUris) {
|
||||||
try {
|
try {
|
||||||
new URL(uri); // must be an absolute URL — any scheme (public/native clients use custom ones)
|
new URL(uri); // must be an absolute URL — any scheme (public/native clients use custom ones)
|
||||||
} catch {
|
} 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;
|
return null;
|
||||||
@@ -102,8 +102,10 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
|||||||
export function buildClientsListModel(opts: {
|
export function buildClientsListModel(opts: {
|
||||||
clients: OAuth2Client[];
|
clients: OAuth2Client[];
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
|
t?: Translate;
|
||||||
url: URL | URLSearchParams | string;
|
url: URL | URLSearchParams | string;
|
||||||
}) {
|
}) {
|
||||||
|
const t = opts.t ?? ADMIN_EN;
|
||||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||||
const needle = query.q.toLowerCase();
|
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 };
|
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }],
|
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }],
|
||||||
filterBar: listFilterBar(state),
|
filterBar: listFilterBar(state, t),
|
||||||
pagination: listPagination(state, page),
|
pagination: listPagination(state, page, t),
|
||||||
table: listTable(rows),
|
table: listTable(rows, t),
|
||||||
title: "OAuth2 clients",
|
title: t("admin.clients.title"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listTable(rows: ClientView[]) {
|
function listTable(rows: ClientView[], t: Translate) {
|
||||||
return {
|
return {
|
||||||
caption: "OAuth2 clients",
|
caption: t("admin.clients.title"),
|
||||||
columns: [{ label: "Name" }, { label: "Client ID" }, { label: "Type" }],
|
columns: [{ label: t("admin.clients.column.name") }, { label: t("admin.clients.column.id") }, { label: t("admin.clients.column.type") }],
|
||||||
rows: rows.map((c) => ({
|
rows: rows.map((c) => ({
|
||||||
cells: [
|
cells: [
|
||||||
{ rowHeader: { href: detailHref(c.id), text: c.name } },
|
{ rowHeader: { href: detailHref(c.id), text: c.name } },
|
||||||
{ className: "cell-muted", text: c.id },
|
{ 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,
|
name: c.name,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listFilterBar(state: ListState) {
|
function listFilterBar(state: ListState, t: Translate) {
|
||||||
const pills: { label: string; remove: string; value: string }[] = [];
|
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 {
|
return {
|
||||||
applyLabel: "Apply",
|
applyLabel: t("filter.apply"),
|
||||||
clearHref: ADMIN_CLIENTS_BASE,
|
clearHref: ADMIN_CLIENTS_BASE,
|
||||||
label: "Filter clients",
|
label: t("admin.clients.filter"),
|
||||||
pills,
|
pills,
|
||||||
rows: [[
|
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" },
|
{ 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 }[] = [];
|
const hidden: { name: string; value: string }[] = [];
|
||||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||||
return {
|
return {
|
||||||
label: "Clients pagination",
|
label: t("admin.clients.pagination"),
|
||||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
||||||
pages: page.pages.map((p) =>
|
pages: page.pages.map((p) =>
|
||||||
p.ellipsis ? { ellipsis: true }
|
p.ellipsis ? { ellipsis: true }
|
||||||
: p.current ? { current: true, label: String(p.page) }
|
: p.current ? { current: true, label: String(p.page) }
|
||||||
: { href: listHref(state, { page: p.page as number }), 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 },
|
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 },
|
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: {
|
export function buildClientFormModel(opts: {
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
t?: Translate;
|
||||||
values?: Partial<ClientInput>;
|
values?: Partial<ClientInput>;
|
||||||
}) {
|
}) {
|
||||||
|
const t = opts.t ?? ADMIN_EN;
|
||||||
const v = opts.values;
|
const v = opts.values;
|
||||||
const nameField: FieldConfig = {
|
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 = {
|
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,
|
value: v?.scope ?? DEFAULT_SCOPE,
|
||||||
};
|
};
|
||||||
return {
|
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,
|
error: opts.error,
|
||||||
form: {
|
form: {
|
||||||
action: ADMIN_CLIENTS_BASE,
|
action: ADMIN_CLIENTS_BASE,
|
||||||
@@ -197,9 +201,9 @@ export function buildClientFormModel(opts: {
|
|||||||
public: v?.public ?? false,
|
public: v?.public ?? false,
|
||||||
redirectUris: (v?.redirectUris ?? []).join("\n"),
|
redirectUris: (v?.redirectUris ?? []).join("\n"),
|
||||||
scopeField,
|
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)
|
created?: boolean; // just registered → success banner + the one-time secret (if any)
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
secret?: string; // one-time client_secret (confidential clients), shown once right after create
|
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);
|
const base = detailHref(opts.client.id);
|
||||||
return {
|
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,
|
client: opts.client,
|
||||||
created: opts.created ?? false,
|
created: opts.created ?? false,
|
||||||
csrfToken: opts.csrfToken ?? "",
|
csrfToken: opts.csrfToken ?? "",
|
||||||
delete: { action: `${base}/delete` },
|
delete: { action: `${base}/delete` },
|
||||||
secret: opts.secret,
|
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) => {
|
return async (ctx) => {
|
||||||
const user = requireAdmin(ctx);
|
const user = requireAdmin(ctx);
|
||||||
const hydra = ctx.system?.hydra;
|
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 });
|
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 =>
|
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 =>
|
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.
|
// GET /admin/clients — the list.
|
||||||
export const clientsList = withClients(async ({ ctx, hydra }) => {
|
export const clientsList = withClients(async ({ ctx, hydra }) => {
|
||||||
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
|
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
|
// 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.
|
// 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 }) => {
|
export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
|
||||||
const input = readClientInput((await guardedForm(ctx))!);
|
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 };
|
if (error) return { ...clientFormResult(ctx, { error, values: input }), status: 400 };
|
||||||
let created: OAuth2Client;
|
let created: OAuth2Client;
|
||||||
try {
|
try {
|
||||||
created = await hydra.createClient(clientPayload(input));
|
created = await hydra.createClient(clientPayload(input));
|
||||||
} catch (err) {
|
} 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;
|
throw err;
|
||||||
}
|
}
|
||||||
ctx.log.info("admin: oauth2 client registered", { actor: user.id, client: created.client_id ?? "" });
|
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) => {
|
export const clientsDeleteConfirm = withClient((deps, client, id) => {
|
||||||
const base = detailHref(id);
|
const base = detailHref(id);
|
||||||
const name = toClientView(client).name;
|
const name = toClientView(client).name;
|
||||||
|
const tt = deps.ctx.t;
|
||||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }],
|
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: tt("admin.clients.title") }, { href: base, label: name }, { label: tt("common.delete") }],
|
||||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client",
|
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.clients.delete"),
|
||||||
message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, title: "Delete client",
|
message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"),
|
||||||
}) }, view: "confirm" });
|
}) }, view: "confirm" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
||||||
// each returning a RouteResult.
|
// 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 { 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_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
import { ADMIN_EN, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
|
|
||||||
const GROUP_NS = "Group";
|
const GROUP_NS = "Group";
|
||||||
@@ -90,8 +90,8 @@ const SORT: Record<string, (g: GroupView) => number | string> = {
|
|||||||
name: (g) => g.name,
|
name: (g) => g.name,
|
||||||
};
|
};
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
{ key: "name", label: "Group" },
|
{ key: "name", label: "admin.groups.column.name" },
|
||||||
{ key: "members", label: "Members" },
|
{ key: "members", label: "admin.groups.column.members" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function detailHref(name: string): string {
|
function detailHref(name: string): string {
|
||||||
@@ -112,8 +112,10 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
|||||||
export function buildGroupsListModel(opts: {
|
export function buildGroupsListModel(opts: {
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
groups: GroupView[];
|
groups: GroupView[];
|
||||||
|
t?: Translate;
|
||||||
url: URL | URLSearchParams | string;
|
url: URL | URLSearchParams | string;
|
||||||
}) {
|
}) {
|
||||||
|
const t = opts.t ?? ADMIN_EN;
|
||||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||||
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
||||||
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : 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 };
|
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Admin" }, { label: "Groups" }],
|
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }],
|
||||||
filterBar: listFilterBar(state),
|
filterBar: listFilterBar(state, t),
|
||||||
pagination: listPagination(state, page),
|
pagination: listPagination(state, page, t),
|
||||||
table: listTable(rows, state, sort),
|
table: listTable(rows, state, sort, t),
|
||||||
title: "Groups",
|
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 {
|
return {
|
||||||
caption: "Groups",
|
caption: t("admin.groups.title"),
|
||||||
columns: COLUMNS.map((c) => {
|
columns: COLUMNS.map((c) => {
|
||||||
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
||||||
const next = dir === "asc" ? `-${c.key}` : c.key;
|
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) => ({
|
rows: rows.map((g) => ({
|
||||||
cells: [{ rowHeader: { href: detailHref(g.name), text: g.name } }, String(g.memberCount)],
|
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 }[] = [];
|
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 {
|
return {
|
||||||
applyLabel: "Apply",
|
applyLabel: t("filter.apply"),
|
||||||
clearHref: ADMIN_GROUPS_BASE,
|
clearHref: ADMIN_GROUPS_BASE,
|
||||||
label: "Filter groups",
|
label: t("admin.groups.filter"),
|
||||||
pills,
|
pills,
|
||||||
rows: [[
|
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" },
|
{ 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 }[] = [];
|
const hidden: { name: string; value: string }[] = [];
|
||||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
||||||
return {
|
return {
|
||||||
label: "Groups pagination",
|
label: t("admin.groups.pagination"),
|
||||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
||||||
pages: page.pages.map((p) =>
|
pages: page.pages.map((p) =>
|
||||||
p.ellipsis ? { ellipsis: true }
|
p.ellipsis ? { ellipsis: true }
|
||||||
: p.current ? { current: true, label: String(p.page) }
|
: p.current ? { current: true, label: String(p.page) }
|
||||||
: { href: listHref(state, { page: p.page as number }), 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 },
|
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 },
|
summary: { from: page.from, to: page.to, total: page.total },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -197,14 +199,16 @@ export function buildGroupFormModel(opts: {
|
|||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
memberOptions: MemberOption[];
|
memberOptions: MemberOption[];
|
||||||
|
t?: Translate;
|
||||||
values?: { member?: string; name?: string };
|
values?: { member?: string; name?: string };
|
||||||
}) {
|
}) {
|
||||||
|
const t = opts.t ?? ADMIN_EN;
|
||||||
const nameField: FieldConfig = {
|
const nameField: FieldConfig = {
|
||||||
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-layers",
|
autocomplete: "off", hint: t("admin.groups.field.nameHint"), icon: "i-layers",
|
||||||
id: "name", label: "Group name", name: "name", required: true, value: opts.values?.name ?? "",
|
id: "name", label: t("admin.groups.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
|
||||||
};
|
};
|
||||||
return {
|
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,
|
error: opts.error,
|
||||||
form: {
|
form: {
|
||||||
action: ADMIN_GROUPS_BASE,
|
action: ADMIN_GROUPS_BASE,
|
||||||
@@ -213,9 +217,9 @@ export function buildGroupFormModel(opts: {
|
|||||||
memberOptions: opts.memberOptions,
|
memberOptions: opts.memberOptions,
|
||||||
nameField,
|
nameField,
|
||||||
selectedMember: opts.values?.member ?? "",
|
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;
|
error?: string;
|
||||||
group: { name: string };
|
group: { name: string };
|
||||||
members: MemberView[];
|
members: MemberView[];
|
||||||
|
t?: Translate;
|
||||||
}) {
|
}) {
|
||||||
|
const t = opts.t ?? ADMIN_EN;
|
||||||
const name = opts.group.name;
|
const name = opts.group.name;
|
||||||
const base = detailHref(name);
|
const base = detailHref(name);
|
||||||
const taken = new Set(opts.members.map((m) => m.subject));
|
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));
|
const options = opts.candidates.filter((c) => c.value !== self && !taken.has(c.value));
|
||||||
return {
|
return {
|
||||||
add: { action: `${base}/members`, options },
|
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 ?? "",
|
csrfToken: opts.csrfToken ?? "",
|
||||||
delete: { action: `${base}/delete` },
|
delete: { action: `${base}/delete` },
|
||||||
error: opts.error,
|
error: opts.error,
|
||||||
@@ -288,7 +294,7 @@ function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHan
|
|||||||
const user = requireAdmin(ctx);
|
const user = requireAdmin(ctx);
|
||||||
const keto = ctx.system?.keto;
|
const keto = ctx.system?.keto;
|
||||||
const kratosAdmin = ctx.system?.kratosAdmin;
|
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 });
|
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 groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
|
||||||
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
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.
|
// GET /admin/groups — the list.
|
||||||
export const groupsList = withGroups(async ({ ctx, keto }) => {
|
export const groupsList = withGroups(async ({ ctx, keto }) => {
|
||||||
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
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).
|
// 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 member = (form.get("member") ?? "").trim();
|
||||||
const tuple = memberTuple(name, member);
|
const tuple = memberTuple(name, member);
|
||||||
const reject = async (error: string): Promise<RouteResult> => ({ ...(await groupFormResult(deps, { error, values: { member, name } })), status: 400 });
|
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 (!isValidGroupName(name)) return reject(ctx.t("admin.groups.validation.name"));
|
||||||
if (!tuple) return reject("Pick a member to add as the group's first member.");
|
if (!tuple) return reject(ctx.t("admin.groups.validation.member"));
|
||||||
if (await groupExists(keto, name)) return reject("A group with that name already exists.");
|
if (await groupExists(keto, name)) return reject("A group with that name already exists.");
|
||||||
await keto.writeTuple(tuple);
|
await keto.writeTuple(tuple);
|
||||||
ctx.log.info("admin: group created", { actor: user.id, group: name });
|
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) => {
|
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
|
||||||
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
||||||
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
|
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).
|
// 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.
|
// GET /admin/groups/:name/delete — the deliberate confirm step.
|
||||||
export const groupsDeleteConfirm = withGroupName((deps, name) => {
|
export const groupsDeleteConfirm = withGroupName((deps, name) => {
|
||||||
const base = detailHref(name);
|
const base = detailHref(name);
|
||||||
|
const tt = deps.ctx.t;
|
||||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }],
|
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("common.delete") }],
|
||||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete group",
|
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"),
|
||||||
message: `Delete group ${name}? This removes the group and all its memberships.`, title: "Delete group",
|
message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"),
|
||||||
}) }, view: "confirm" });
|
}) }, view: "confirm" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Built-in Roles admin screen: the pure view-model + Keto builders. A permission is a
|
||||||
|
// Keto subject set (Permission:<name>#members); members are users (subject_id) or groups (subject_set) —
|
||||||
|
// "assign permissions to users/groups". The "effective access" view flattens a Keto `expand` tree into the
|
||||||
|
// distinct set of users who hold the permission directly or transitively via a group. The HTTP
|
||||||
|
// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { memberView } from "./admin-groups.ts";
|
||||||
|
import {
|
||||||
|
buildPermissionDetailModel,
|
||||||
|
buildPermissionFormModel,
|
||||||
|
buildPermissionsListModel,
|
||||||
|
expandToEffectiveUsers,
|
||||||
|
isValidRoleName,
|
||||||
|
permissionGrantTuple,
|
||||||
|
} from "./admin-permissions.ts";
|
||||||
|
import type { ExpandTree, RelationTuple } from "#plugin-api";
|
||||||
|
|
||||||
|
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
||||||
|
const userTuple = (permission: string, n: number): RelationTuple =>
|
||||||
|
({ namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${uid(n)}` });
|
||||||
|
const groupTuple = (permission: string, group: string): RelationTuple =>
|
||||||
|
({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
||||||
|
|
||||||
|
test("isValidRoleName + permissionGrantTuple map the form value to a Permission tuple over a user/group (else null)", () => {
|
||||||
|
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
|
||||||
|
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
|
||||||
|
|
||||||
|
assert.deepEqual(permissionGrantTuple("editor", `user:${uid(2)}`), { namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${uid(2)}` });
|
||||||
|
assert.deepEqual(permissionGrantTuple("editor", "group:eng"), { namespace: "Permission", object: "editor", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||||
|
for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(permissionGrantTuple("editor", bad), null, bad);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
|
||||||
|
// The subject rides on each node's `tuple` (Keto v26.2.0 shape, verified live).
|
||||||
|
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `user:${uid(n)}` }, type: "leaf" });
|
||||||
|
const tree: ExpandTree = {
|
||||||
|
children: [
|
||||||
|
leaf(1), // direct
|
||||||
|
{
|
||||||
|
children: [leaf(2), leaf(1)], // via group + dup
|
||||||
|
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Group", object: "eng", relation: "members" } }, // a member group, not a user
|
||||||
|
type: "union",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } },
|
||||||
|
type: "union",
|
||||||
|
};
|
||||||
|
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
|
||||||
|
assert.deepEqual(expandToEffectiveUsers(null), []);
|
||||||
|
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty permission
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildPermissionsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
||||||
|
const permissions = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `permission-${String(i).padStart(2, "0")}` }));
|
||||||
|
|
||||||
|
const all = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions" });
|
||||||
|
assert.equal(all.pagination.summary.total, 30);
|
||||||
|
assert.equal(all.table.rows.length, 25); // default page size
|
||||||
|
assert.equal(all.title, "Permissions");
|
||||||
|
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||||
|
assert.equal(first.rowHeader.text, "permission-00");
|
||||||
|
assert.equal(first.rowHeader.href, "/admin/permissions/permission-00");
|
||||||
|
|
||||||
|
const one = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?q=permission-07" });
|
||||||
|
assert.equal(one.pagination.summary.total, 1);
|
||||||
|
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||||
|
|
||||||
|
const desc = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?sort=-members" });
|
||||||
|
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "permission-29");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildPermissionFormModel: a create form with a required name field + member options (user or group)", () => {
|
||||||
|
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||||
|
const m = buildPermissionFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||||
|
assert.equal(m.title, "New permission");
|
||||||
|
assert.equal(m.form.action, "/admin/permissions");
|
||||||
|
assert.equal(m.form.submitLabel, "Create permission");
|
||||||
|
assert.equal(m.form.csrfToken, "tok.sig");
|
||||||
|
assert.equal(m.form.nameField.required, true);
|
||||||
|
assert.deepEqual(m.form.memberOptions, options);
|
||||||
|
|
||||||
|
const err = buildPermissionFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
|
||||||
|
assert.equal(err.error, "That name is taken.");
|
||||||
|
assert.equal(err.form.nameField.value, "Admin");
|
||||||
|
assert.equal(err.form.selectedMember, "group:eng");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildPermissionDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
|
||||||
|
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
|
||||||
|
const candidates = [
|
||||||
|
{ label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded
|
||||||
|
{ label: "grace@example.com", value: `user:${uid(2)}` },
|
||||||
|
{ label: "eng (group)", value: "group:eng" }, // already a member → excluded
|
||||||
|
{ label: "ops (group)", value: "group:ops" },
|
||||||
|
];
|
||||||
|
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
|
||||||
|
const m = buildPermissionDetailModel({ candidates, effective, members, permission: { name: "admin" } });
|
||||||
|
assert.equal(m.title, "admin");
|
||||||
|
assert.equal(m.members.rows.length, 2);
|
||||||
|
assert.equal(m.members.action, "/admin/permissions/admin/members/delete");
|
||||||
|
assert.equal(m.add.action, "/admin/permissions/admin/members");
|
||||||
|
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
|
||||||
|
assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
|
||||||
|
assert.equal(m.delete.action, "/admin/permissions/admin/delete");
|
||||||
|
});
|
||||||
+107
-100
@@ -1,15 +1,15 @@
|
|||||||
// Roles & permissions admin screen: list / create / delete Keto roles and assign
|
// Permissions admin screen: list / create / delete Keto permissions and assign
|
||||||
// them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users
|
// them to users and groups. A permission is a Keto subject set `Permission:<name>#members` (OPL: members are users
|
||||||
// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the
|
// or groups, resolved transitively) — the source of truth for the JWT `permissions` claim. It shares the
|
||||||
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
|
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
|
||||||
// are reused from admin-groups. The role-specific piece is the **effective access** view:
|
// are reused from admin-groups. The permission-specific piece is the **effective access** view:
|
||||||
// `keto.expand(Role:<name>#members)` flattened to the distinct users who hold the role directly or via
|
// `keto.expand(Permission:<name>#members)` flattened to the distinct users who hold the permission directly or via
|
||||||
// a group — matching what login projects into the JWT (login.ts readRoles). Writes go only to Keto;
|
// a group — matching what login projects into the JWT (login.ts readPermissions). Writes go only to Keto;
|
||||||
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
|
// 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.
|
// 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 { 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_PERMISSION, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
import { ADMIN_EN, ADMIN_PERMISSION, ADMIN_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||||
import {
|
import {
|
||||||
type GroupView,
|
type GroupView,
|
||||||
groupsFromTuples,
|
groupsFromTuples,
|
||||||
@@ -23,29 +23,29 @@ import {
|
|||||||
} from "./admin-groups.ts";
|
} from "./admin-groups.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
|
|
||||||
const ROLE_NS = "Role";
|
const PERMISSION_NS = "Permission";
|
||||||
const MEMBERS = "members";
|
const GRANTED = "granted";
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
const PAGE_SIZES = [25, 50, 100];
|
const PAGE_SIZES = [25, 50, 100];
|
||||||
// Expand far past any sane group-nesting depth so the effective-access view never silently
|
// Expand far past any sane group-nesting depth so the effective-access view never silently
|
||||||
// under-reports the deepest members (Keto's own default is shallow).
|
// under-reports the deepest members (Keto's own default is shallow).
|
||||||
const EXPAND_MAX_DEPTH = 50;
|
const EXPAND_MAX_DEPTH = 50;
|
||||||
|
|
||||||
// A role and a group share the URL-safe name rule and the user|group membership model.
|
// A permission and a group share the URL-safe name rule and the user|group membership model.
|
||||||
export type RoleView = GroupView;
|
export type PermissionView = GroupView;
|
||||||
export const isValidRoleName = isValidGroupName;
|
export const isValidRoleName = isValidGroupName;
|
||||||
export const rolesFromTuples = groupsFromTuples;
|
export const permissionsFromTuples = groupsFromTuples;
|
||||||
export interface EffectiveUser {
|
export interface EffectiveUser {
|
||||||
label: string; // email (or the raw id when unresolved)
|
label: string; // email (or the raw id when unresolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The full membership tuple for assigning/revoking `value` to/from `role` (null if value is invalid).
|
// The full membership tuple for assigning/revoking `value` to/from `permission` (null if value is invalid).
|
||||||
export function roleMemberTuple(role: string, value: string): RelationTuple | null {
|
export function permissionGrantTuple(permission: string, value: string): RelationTuple | null {
|
||||||
const subject = parseSubject(value);
|
const subject = parseSubject(value);
|
||||||
return subject ? { namespace: ROLE_NS, object: role, relation: MEMBERS, ...subject } : null;
|
return subject ? { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the role
|
// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the permission
|
||||||
// (direct leaves + users reached through member groups, any depth). The subject rides on each
|
// (direct leaves + users reached through member groups, any depth). The subject rides on each
|
||||||
// node's `tuple`; subject-set nodes (the groups) contribute nothing directly — their members
|
// node's `tuple`; subject-set nodes (the groups) contribute nothing directly — their members
|
||||||
// surface as leaves under them.
|
// surface as leaves under them.
|
||||||
@@ -70,17 +70,17 @@ interface ListState {
|
|||||||
sort: string | null;
|
sort: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SORT: Record<string, (r: RoleView) => number | string> = {
|
const SORT: Record<string, (r: PermissionView) => number | string> = {
|
||||||
members: (r) => r.memberCount,
|
members: (r) => r.memberCount,
|
||||||
name: (r) => r.name,
|
name: (r) => r.name,
|
||||||
};
|
};
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
{ key: "name", label: "Role" },
|
{ key: "name", label: "admin.permissions.column.name" },
|
||||||
{ key: "members", label: "Members" },
|
{ key: "members", label: "admin.permissions.column.members" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function detailHref(name: string): string {
|
function detailHref(name: string): string {
|
||||||
return `${ADMIN_ROLES_BASE}/${encodeURIComponent(name)}`;
|
return `${ADMIN_PERMISSIONS_BASE}/${encodeURIComponent(name)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
|
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
|
||||||
@@ -91,20 +91,22 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
|||||||
if (s.page > 1) p.set("page", String(s.page));
|
if (s.page > 1) p.set("page", String(s.page));
|
||||||
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
|
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
|
||||||
const qs = p.toString();
|
const qs = p.toString();
|
||||||
return qs ? `${ADMIN_ROLES_BASE}?${qs}` : ADMIN_ROLES_BASE;
|
return qs ? `${ADMIN_PERMISSIONS_BASE}?${qs}` : ADMIN_PERMISSIONS_BASE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRolesListModel(opts: {
|
export function buildPermissionsListModel(opts: {
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
roles: RoleView[];
|
permissions: PermissionView[];
|
||||||
|
t?: Translate;
|
||||||
url: URL | URLSearchParams | string;
|
url: URL | URLSearchParams | string;
|
||||||
}) {
|
}) {
|
||||||
|
const t = opts.t ?? ADMIN_EN;
|
||||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||||
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
||||||
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
||||||
const needle = query.q.toLowerCase();
|
const needle = query.q.toLowerCase();
|
||||||
|
|
||||||
let list = opts.roles.filter((r) => !needle || r.name.toLowerCase().includes(needle));
|
let list = opts.permissions.filter((r) => !needle || r.name.toLowerCase().includes(needle));
|
||||||
if (sort) {
|
if (sort) {
|
||||||
const get = SORT[sort.field]!;
|
const get = SORT[sort.field]!;
|
||||||
const dir = sort.dir === "desc" ? -1 : 1;
|
const dir = sort.dir === "desc" ? -1 : 1;
|
||||||
@@ -121,21 +123,21 @@ export function buildRolesListModel(opts: {
|
|||||||
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }],
|
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.nav.section") }, { label: t("admin.permissions.title") }],
|
||||||
filterBar: listFilterBar(state),
|
filterBar: listFilterBar(state, t),
|
||||||
pagination: listPagination(state, page),
|
pagination: listPagination(state, page, t),
|
||||||
table: listTable(rows, state, sort),
|
table: listTable(rows, state, sort, t),
|
||||||
title: "Roles",
|
title: t("admin.permissions.title"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listTable(rows: RoleView[], 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 {
|
return {
|
||||||
caption: "Roles",
|
caption: t("admin.permissions.title"),
|
||||||
columns: COLUMNS.map((c) => {
|
columns: COLUMNS.map((c) => {
|
||||||
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
||||||
const next = dir === "asc" ? `-${c.key}` : c.key;
|
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) => ({
|
rows: rows.map((r) => ({
|
||||||
cells: [{ rowHeader: { href: detailHref(r.name), text: r.name } }, String(r.memberCount)],
|
cells: [{ rowHeader: { href: detailHref(r.name), text: r.name } }, String(r.memberCount)],
|
||||||
@@ -144,111 +146,115 @@ function listTable(rows: RoleView[], state: ListState, sort: { dir: "asc" | "des
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listFilterBar(state: ListState) {
|
function listFilterBar(state: ListState, t: Translate) {
|
||||||
const pills: { label: string; remove: string; value: string }[] = [];
|
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 {
|
return {
|
||||||
applyLabel: "Apply",
|
applyLabel: t("filter.apply"),
|
||||||
clearHref: ADMIN_ROLES_BASE,
|
clearHref: ADMIN_PERMISSIONS_BASE,
|
||||||
label: "Filter roles",
|
label: t("admin.permissions.filter"),
|
||||||
pills,
|
pills,
|
||||||
rows: [[
|
rows: [[
|
||||||
{ label: "Search roles", name: "q", placeholder: "Search role 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" },
|
{ 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 }[] = [];
|
const hidden: { name: string; value: string }[] = [];
|
||||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
||||||
return {
|
return {
|
||||||
label: "Roles pagination",
|
label: t("admin.permissions.pagination"),
|
||||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
||||||
pages: page.pages.map((p) =>
|
pages: page.pages.map((p) =>
|
||||||
p.ellipsis ? { ellipsis: true }
|
p.ellipsis ? { ellipsis: true }
|
||||||
: p.current ? { current: true, label: String(p.page) }
|
: p.current ? { current: true, label: String(p.page) }
|
||||||
: { href: listHref(state, { page: p.page as number }), 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 },
|
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 },
|
summary: { from: page.from, to: page.to, total: page.total },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- create form + detail view models ----
|
// ---- create form + detail view models ----
|
||||||
|
|
||||||
export function buildRoleFormModel(opts: {
|
export function buildPermissionFormModel(opts: {
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
memberOptions: MemberOption[];
|
memberOptions: MemberOption[];
|
||||||
|
t?: Translate;
|
||||||
values?: { member?: string; name?: string };
|
values?: { member?: string; name?: string };
|
||||||
}) {
|
}) {
|
||||||
|
const t = opts.t ?? ADMIN_EN;
|
||||||
const nameField: FieldConfig = {
|
const nameField: FieldConfig = {
|
||||||
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield",
|
autocomplete: "off", hint: t("admin.permissions.field.nameHint"), icon: "i-shield",
|
||||||
id: "name", label: "Role name", name: "name", required: true, value: opts.values?.name ?? "",
|
id: "name", label: t("admin.permissions.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }],
|
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: t("common.new") }],
|
||||||
error: opts.error,
|
error: opts.error,
|
||||||
form: {
|
form: {
|
||||||
action: ADMIN_ROLES_BASE,
|
action: ADMIN_PERMISSIONS_BASE,
|
||||||
cancelHref: ADMIN_ROLES_BASE,
|
cancelHref: ADMIN_PERMISSIONS_BASE,
|
||||||
csrfToken: opts.csrfToken ?? "",
|
csrfToken: opts.csrfToken ?? "",
|
||||||
memberOptions: opts.memberOptions,
|
memberOptions: opts.memberOptions,
|
||||||
nameField,
|
nameField,
|
||||||
selectedMember: opts.values?.member ?? "",
|
selectedMember: opts.values?.member ?? "",
|
||||||
submitLabel: "Create role",
|
submitLabel: t("admin.permissions.create"),
|
||||||
},
|
},
|
||||||
title: "New role",
|
title: t("admin.permissions.new"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRoleDetailModel(opts: {
|
export function buildPermissionDetailModel(opts: {
|
||||||
candidates: MemberOption[];
|
candidates: MemberOption[];
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
effective: EffectiveUser[];
|
effective: EffectiveUser[];
|
||||||
error?: string;
|
error?: string;
|
||||||
members: MemberView[];
|
members: MemberView[];
|
||||||
role: { name: string };
|
permission: { name: string };
|
||||||
|
t?: Translate;
|
||||||
}) {
|
}) {
|
||||||
const name = opts.role.name;
|
const t = opts.t ?? ADMIN_EN;
|
||||||
|
const name = opts.permission.name;
|
||||||
const base = detailHref(name);
|
const base = detailHref(name);
|
||||||
const taken = new Set(opts.members.map((m) => m.subject));
|
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 role itself
|
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the permission itself
|
||||||
return {
|
return {
|
||||||
add: { action: `${base}/members`, options },
|
add: { action: `${base}/members`, options },
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }],
|
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: name }],
|
||||||
csrfToken: opts.csrfToken ?? "",
|
csrfToken: opts.csrfToken ?? "",
|
||||||
delete: { action: `${base}/delete` },
|
delete: { action: `${base}/delete` },
|
||||||
effective: opts.effective,
|
effective: opts.effective,
|
||||||
error: opts.error,
|
error: opts.error,
|
||||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
members: { action: `${base}/members/delete`, rows: opts.members },
|
||||||
role: { name },
|
permission: { name },
|
||||||
title: name,
|
title: name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- request handler (imperative shell) ----
|
// ---- request handler (imperative shell) ----
|
||||||
|
|
||||||
// instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
|
// instant-revoke: a permission change for a `user:<id>` member must take effect now, so revoke that
|
||||||
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
|
// user's live tokens (a re-mint then re-reads permissions from Keto). A `group:<name>` change is
|
||||||
// transitive across many users — left to lag (documented), so only direct user members revoke.
|
// transitive across many users — left to lag (documented), so only direct user members revoke.
|
||||||
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
|
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
|
||||||
if (revoke && member.startsWith("user:")) revoke(member.slice("user:".length));
|
if (revoke && member.startsWith("user:")) revoke(member.slice("user:".length));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A role exists exactly while it has ≥1 member (Keto has no create-object).
|
// A permission exists exactly while it has ≥1 member (Keto has no create-object).
|
||||||
async function roleExists(keto: KetoClient, name: string): Promise<boolean> {
|
async function roleExists(keto: KetoClient, name: string): Promise<boolean> {
|
||||||
const page = await keto.listRelations({ namespace: ROLE_NS, object: name, relation: MEMBERS, pageSize: 1 });
|
const page = await keto.listRelations({ namespace: PERMISSION_NS, object: name, relation: GRANTED, pageSize: 1 });
|
||||||
return page.tuples.length > 0;
|
return page.tuples.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The distinct users who effectively hold the role (expand → flatten → label by email). Skipped for
|
// The distinct users who effectively hold the permission (expand → flatten → label by email). Skipped for
|
||||||
// an empty role (no member tuples) so we don't expand a non-existent Keto object.
|
// an empty permission (no member tuples) so we don't expand a non-existent Keto object.
|
||||||
async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map<string, string>): Promise<EffectiveUser[]> {
|
async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map<string, string>): Promise<EffectiveUser[]> {
|
||||||
if (!hasMembers) return [];
|
if (!hasMembers) return [];
|
||||||
const tree = await keto.expand({ namespace: ROLE_NS, object: name, relation: MEMBERS }, { maxDepth: EXPAND_MAX_DEPTH });
|
const tree = await keto.expand({ namespace: PERMISSION_NS, object: name, relation: GRANTED }, { maxDepth: EXPAND_MAX_DEPTH });
|
||||||
return expandToEffectiveUsers(tree)
|
return expandToEffectiveUsers(tree)
|
||||||
.map((id) => ({ label: emailById.get(id) ?? `user:${id}` }))
|
.map((id) => ({ label: emailById.get(id) ?? `user:${id}` }))
|
||||||
.sort((a, b) => a.label.localeCompare(b.label));
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
@@ -263,12 +269,12 @@ function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandl
|
|||||||
const user = requireAdmin(ctx);
|
const user = requireAdmin(ctx);
|
||||||
const keto = ctx.system?.keto;
|
const keto = ctx.system?.keto;
|
||||||
const kratosAdmin = ctx.system?.kratosAdmin;
|
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 });
|
return inner({ ctx, keto, kratosAdmin, revoke: ctx.system?.revoke, user });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same, plus the validated :name from ctx.params (an invalid role name → themed 404).
|
// Same, plus the validated :name from ctx.params (an invalid permission name → themed 404).
|
||||||
function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
|
function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
|
||||||
return withRoles((deps) => {
|
return withRoles((deps) => {
|
||||||
const name = deps.ctx.params["name"] ?? "";
|
const name = deps.ctx.params["name"] ?? "";
|
||||||
@@ -279,89 +285,90 @@ 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 roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
|
||||||
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
||||||
return { data: { chrome: deps.ctx.chrome, model: buildRoleFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "role-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 role detail (members + effective access). With `error` set it's a 400 (a rejected action).
|
// The permission detail (members + effective access). With `error` set it's a 400 (a rejected action).
|
||||||
const roleDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => {
|
const permissionDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => {
|
||||||
const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
||||||
const tuples = await pagedTuples(deps.keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
|
const tuples = await pagedTuples(deps.keto, { namespace: PERMISSION_NS, object: name, relation: GRANTED });
|
||||||
const members = tuples.map((t) => memberView(t, emailById));
|
const members = tuples.map((t) => memberView(t, emailById));
|
||||||
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
|
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
|
||||||
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildRoleDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, role: { name }, ...(error ? { error } : {}) }) }, view: "role-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;
|
return error ? { ...result, status: 400 } : result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// GET /admin/roles — the list.
|
// GET /admin/permissions — the list.
|
||||||
export const rolesList = withRoles(async ({ ctx, keto }) => {
|
export const rolesList = withRoles(async ({ ctx, keto }) => {
|
||||||
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
|
const permissions = permissionsFromTuples(await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED }));
|
||||||
return { data: { chrome: ctx.chrome, model: buildRolesListModel({ csrfToken: ctx.chrome.csrfToken, roles, url: ctx.url }) }, view: "roles" };
|
return { data: { chrome: ctx.chrome, model: buildPermissionsListModel({ csrfToken: ctx.chrome.csrfToken, permissions, t: ctx.t, url: ctx.url }) }, view: "permissions" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/roles — create + assign the first member (a *user* grant revokes their live tokens).
|
// POST /admin/permissions — create + assign the first member (a *user* grant revokes their live tokens).
|
||||||
export const rolesCreate = withRoles(async (deps) => {
|
export const rolesCreate = withRoles(async (deps) => {
|
||||||
const { ctx, keto, revoke, user } = deps;
|
const { ctx, keto, revoke, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
const name = (form.get("name") ?? "").trim();
|
const name = (form.get("name") ?? "").trim();
|
||||||
const member = (form.get("member") ?? "").trim();
|
const member = (form.get("member") ?? "").trim();
|
||||||
const tuple = roleMemberTuple(name, member);
|
const tuple = permissionGrantTuple(name, member);
|
||||||
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
|
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
|
||||||
if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores.");
|
if (!isValidRoleName(name)) return reject(ctx.t("admin.permissions.validation.name"));
|
||||||
if (!tuple) return reject("Pick a user or group to assign the role to.");
|
if (!tuple) return reject(ctx.t("admin.permissions.validation.member"));
|
||||||
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
|
if (await roleExists(keto, name)) return reject("A permission with that name already exists.");
|
||||||
await keto.writeTuple(tuple);
|
await keto.writeTuple(tuple);
|
||||||
revokeUserMember(revoke, member);
|
revokeUserMember(revoke, member);
|
||||||
ctx.log.info("admin: role created + first member assigned", { actor: user.id, member, role: name });
|
ctx.log.info("admin: permission created + first member assigned", { actor: user.id, member, permission: name });
|
||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/roles/new — the create form.
|
// GET /admin/permissions/new — the create form.
|
||||||
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
|
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
|
||||||
|
|
||||||
// GET /admin/roles/:name — the detail (members + effective access via Keto expand).
|
// GET /admin/permissions/:name — the detail (members + effective access via Keto expand).
|
||||||
export const rolesDetail = withRoleName((deps, name) => roleDetailResult(deps, name));
|
export const rolesDetail = withRoleName((deps, name) => permissionDetailResult(deps, name));
|
||||||
|
|
||||||
// POST /admin/roles/:name/members — assign a user/group; a *user* grant revokes their live tokens.
|
// POST /admin/permissions/:name/members — assign a user/group; a *user* grant revokes their live tokens.
|
||||||
export const rolesAddMember = withRoleName(async (deps, name) => {
|
export const rolesAddMember = withRoleName(async (deps, name) => {
|
||||||
const { ctx, keto, revoke, user } = deps;
|
const { ctx, keto, revoke, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
const member = (form.get("member") ?? "").trim();
|
const member = (form.get("member") ?? "").trim();
|
||||||
const tuple = roleMemberTuple(name, member); // the picker only offers real users/groups
|
const tuple = permissionGrantTuple(name, member); // the picker only offers real users/groups
|
||||||
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role assigned", { actor: user.id, member, role: name }); }
|
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission assigned", { actor: user.id, member, permission: name }); }
|
||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/roles/:name/delete — confirm, except the admin role can't be deleted.
|
// GET /admin/permissions/:name/delete — confirm, except the admin permission can't be deleted.
|
||||||
export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
||||||
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role 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 base = detailHref(name);
|
||||||
|
const tt = deps.ctx.t;
|
||||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
|
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: tt("admin.permissions.title") }, { href: base, label: name }, { label: tt("common.delete") }],
|
||||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role",
|
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.permissions.delete"),
|
||||||
message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role",
|
message: tt("admin.permissions.deleteMessage", { name }), title: tt("admin.permissions.delete"),
|
||||||
}) }, view: "confirm" });
|
}) }, view: "confirm" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/roles/:name/delete — remove every member tuple (a whole-role delete lags per the
|
// POST /admin/permissions/:name/delete — remove every member tuple (a whole-permission delete lags per the
|
||||||
// documented instant-revoke tradeoff; the admin role is protected).
|
// documented instant-revoke tradeoff; the admin permission is protected).
|
||||||
export const rolesDelete = withRoleName(async (deps, name) => {
|
export const rolesDelete = withRoleName(async (deps, name) => {
|
||||||
const { ctx, keto, user } = deps;
|
const { ctx, keto, user } = deps;
|
||||||
await guardedForm(ctx); // CSRF-verify the POST
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role 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: ROLE_NS, object: name, relation: MEMBERS });
|
await keto.deleteTuple({ namespace: PERMISSION_NS, object: name, relation: GRANTED });
|
||||||
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
|
ctx.log.info("admin: permission deleted", { actor: user.id, permission: name });
|
||||||
return { redirect: ADMIN_ROLES_BASE };
|
return { redirect: ADMIN_PERMISSIONS_BASE };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/roles/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
|
// POST /admin/permissions/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
|
||||||
// Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
|
// Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
|
||||||
// covered — the robust "last effective admin" check is deferred).
|
// covered — the robust "last effective admin" check is deferred).
|
||||||
export const rolesRemoveMember = withRoleName(async (deps, name) => {
|
export const rolesRemoveMember = withRoleName(async (deps, name) => {
|
||||||
const { ctx, keto, revoke, user } = deps;
|
const { ctx, keto, revoke, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
const member = (form.get("member") ?? "").trim();
|
const member = (form.get("member") ?? "").trim();
|
||||||
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return roleDetailResult(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 = roleMemberTuple(name, member);
|
const tuple = permissionGrantTuple(name, member);
|
||||||
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
|
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) };
|
return { redirect: detailHref(name) };
|
||||||
});
|
});
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
// Built-in Roles & permissions admin screen: the pure view-model + Keto builders. A role is a
|
|
||||||
// Keto subject set (Role:<name>#members); members are users (subject_id) or groups (subject_set) —
|
|
||||||
// "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the
|
|
||||||
// distinct set of users who hold the role directly or transitively via a group. The HTTP
|
|
||||||
// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import { memberView } from "./admin-groups.ts";
|
|
||||||
import {
|
|
||||||
buildRoleDetailModel,
|
|
||||||
buildRoleFormModel,
|
|
||||||
buildRolesListModel,
|
|
||||||
expandToEffectiveUsers,
|
|
||||||
isValidRoleName,
|
|
||||||
roleMemberTuple,
|
|
||||||
} from "./admin-roles.ts";
|
|
||||||
import type { ExpandTree, RelationTuple } from "#plugin-api";
|
|
||||||
|
|
||||||
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
|
||||||
const userTuple = (role: string, n: number): RelationTuple =>
|
|
||||||
({ namespace: "Role", object: role, relation: "members", subject_id: `user:${uid(n)}` });
|
|
||||||
const groupTuple = (role: string, group: string): RelationTuple =>
|
|
||||||
({ namespace: "Role", object: role, relation: "members", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
|
||||||
|
|
||||||
test("isValidRoleName + roleMemberTuple map the form value to a Role tuple over a user/group (else null)", () => {
|
|
||||||
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
|
|
||||||
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
|
|
||||||
|
|
||||||
assert.deepEqual(roleMemberTuple("editor", `user:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `user:${uid(2)}` });
|
|
||||||
assert.deepEqual(roleMemberTuple("editor", "group:eng"), { namespace: "Role", object: "editor", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
|
||||||
for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("editor", bad), null, bad);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
|
|
||||||
// The subject rides on each node's `tuple` (Keto v26.2.0 shape, verified live).
|
|
||||||
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `user:${uid(n)}` }, type: "leaf" });
|
|
||||||
const tree: ExpandTree = {
|
|
||||||
children: [
|
|
||||||
leaf(1), // direct
|
|
||||||
{
|
|
||||||
children: [leaf(2), leaf(1)], // via group + dup
|
|
||||||
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Group", object: "eng", relation: "members" } }, // a member group, not a user
|
|
||||||
type: "union",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } },
|
|
||||||
type: "union",
|
|
||||||
};
|
|
||||||
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
|
|
||||||
assert.deepEqual(expandToEffectiveUsers(null), []);
|
|
||||||
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty role
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildRolesListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
|
||||||
const roles = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `role-${String(i).padStart(2, "0")}` }));
|
|
||||||
|
|
||||||
const all = buildRolesListModel({ roles, url: "http://x/admin/roles" });
|
|
||||||
assert.equal(all.pagination.summary.total, 30);
|
|
||||||
assert.equal(all.table.rows.length, 25); // default page size
|
|
||||||
assert.equal(all.title, "Roles");
|
|
||||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
|
||||||
assert.equal(first.rowHeader.text, "role-00");
|
|
||||||
assert.equal(first.rowHeader.href, "/admin/roles/role-00");
|
|
||||||
|
|
||||||
const one = buildRolesListModel({ roles, url: "http://x/admin/roles?q=role-07" });
|
|
||||||
assert.equal(one.pagination.summary.total, 1);
|
|
||||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
|
||||||
|
|
||||||
const desc = buildRolesListModel({ roles, url: "http://x/admin/roles?sort=-members" });
|
|
||||||
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "role-29");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => {
|
|
||||||
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
|
||||||
const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
|
||||||
assert.equal(m.title, "New role");
|
|
||||||
assert.equal(m.form.action, "/admin/roles");
|
|
||||||
assert.equal(m.form.submitLabel, "Create role");
|
|
||||||
assert.equal(m.form.csrfToken, "tok.sig");
|
|
||||||
assert.equal(m.form.nameField.required, true);
|
|
||||||
assert.deepEqual(m.form.memberOptions, options);
|
|
||||||
|
|
||||||
const err = buildRoleFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
|
|
||||||
assert.equal(err.error, "That name is taken.");
|
|
||||||
assert.equal(err.form.nameField.value, "Admin");
|
|
||||||
assert.equal(err.form.selectedMember, "group:eng");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildRoleDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
|
|
||||||
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
|
|
||||||
const candidates = [
|
|
||||||
{ label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded
|
|
||||||
{ label: "grace@example.com", value: `user:${uid(2)}` },
|
|
||||||
{ label: "eng (group)", value: "group:eng" }, // already a member → excluded
|
|
||||||
{ label: "ops (group)", value: "group:ops" },
|
|
||||||
];
|
|
||||||
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
|
|
||||||
const m = buildRoleDetailModel({ candidates, effective, members, role: { name: "admin" } });
|
|
||||||
assert.equal(m.title, "admin");
|
|
||||||
assert.equal(m.members.rows.length, 2);
|
|
||||||
assert.equal(m.members.action, "/admin/roles/admin/members/delete");
|
|
||||||
assert.equal(m.add.action, "/admin/roles/admin/members");
|
|
||||||
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
|
|
||||||
assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
|
|
||||||
assert.equal(m.delete.action, "/admin/roles/admin/delete");
|
|
||||||
});
|
|
||||||
@@ -7,10 +7,10 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
|
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", roles: ["admin"] };
|
const admin: User = { email: "ada@x.io", id: "u1", permissions: ["admin"] };
|
||||||
const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
|
const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] };
|
||||||
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
|
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
|
||||||
|
|
||||||
function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||||
@@ -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;
|
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||||
req.method = opts.method ?? "GET";
|
req.method = opts.method ?? "GET";
|
||||||
return {
|
return {
|
||||||
chrome: CHROME, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
chrome: CHROME, user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
|
||||||
roles: opts.user?.roles ?? [], url, user: opts.user ?? null, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
|
||||||
|
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,8 +30,11 @@ test("ADMIN_NAV: a gated Admin header over the four screens; no per-request curr
|
|||||||
assert.equal(ADMIN_NAV.id, "admin");
|
assert.equal(ADMIN_NAV.id, "admin");
|
||||||
assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
|
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.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/roles", "/admin/clients"]);
|
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", "Roles", "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
|
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,29 +3,35 @@
|
|||||||
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
|
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
|
||||||
// everything imports the host only through the #plugin-api barrel.
|
// 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";
|
||||||
|
|
||||||
export const ADMIN_PERMISSION = "admin"; // role token gating the whole admin section
|
// 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";
|
export const ADMIN_USERS_BASE = "/admin/users";
|
||||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||||
export const ADMIN_ROLES_BASE = "/admin/roles";
|
export const ADMIN_PERMISSIONS_BASE = "/admin/permissions";
|
||||||
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||||
|
|
||||||
export type AdminScreen = "clients" | "groups" | "roles" | "users";
|
export type AdminScreen = "clients" | "groups" | "permissions" | "users";
|
||||||
|
|
||||||
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
|
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
|
||||||
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
|
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
|
||||||
// non-admin), and current-marks the active item — so there is no `current`/`open` state here.
|
// non-admin), and current-marks the active item — so there is no `current`/`open` state here.
|
||||||
export const ADMIN_NAV: NavNode = {
|
export const ADMIN_NAV: NavNode = {
|
||||||
children: [
|
children: [
|
||||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
|
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users" },
|
||||||
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
|
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups" },
|
||||||
{ href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" },
|
{ href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "admin.nav.permissions" },
|
||||||
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
|
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients" },
|
||||||
],
|
],
|
||||||
icon: "i-shield",
|
icon: "i-shield",
|
||||||
id: "admin",
|
id: "admin",
|
||||||
label: "Admin",
|
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
|
||||||
permission: ADMIN_PERMISSION,
|
permission: ADMIN_PERMISSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,7 +40,7 @@ export const ADMIN_NAV: NavNode = {
|
|||||||
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
|
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
|
||||||
export function requireAdmin(ctx: RequestContext): User {
|
export function requireAdmin(ctx: RequestContext): User {
|
||||||
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
||||||
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
|
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin permission required");
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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.
|
// 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 {
|
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 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.
|
// 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 {
|
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
|
// 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
|
// 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).
|
// — 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 { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
|
||||||
import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
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 SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
@@ -30,8 +30,6 @@ export interface UserInput {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
|
|
||||||
function nameParts(identity: Identity): { first: string; last: string } {
|
function nameParts(identity: Identity): { first: string; last: string } {
|
||||||
const nm = ((identity.traits?.name ?? {}) as { first?: unknown; last?: unknown });
|
const nm = ((identity.traits?.name ?? {}) as { first?: unknown; last?: unknown });
|
||||||
return {
|
return {
|
||||||
@@ -88,9 +86,9 @@ const SORT: Record<string, (u: UserView) => string> = {
|
|||||||
status: (u) => u.state,
|
status: (u) => u.state,
|
||||||
};
|
};
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
{ key: "name", label: "Name" },
|
{ key: "name", label: "admin.users.column.name" },
|
||||||
{ key: "email", label: "Email" },
|
{ key: "email", label: "admin.users.column.email" },
|
||||||
{ key: "status", label: "Status" },
|
{ key: "status", label: "admin.users.column.status" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Canonical list URL from the current state + per-link overrides; omits defaults so links stay tidy.
|
// 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: {
|
export function buildUsersListModel(opts: {
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
identities: Identity[];
|
identities: Identity[];
|
||||||
|
t?: Translate;
|
||||||
url: URL | URLSearchParams | string;
|
url: URL | URLSearchParams | string;
|
||||||
}) {
|
}) {
|
||||||
|
const t = opts.t ?? ADMIN_EN;
|
||||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||||
const status = query.filters.status?.[0] ?? "all";
|
const status = query.filters.status?.[0] ?? "all";
|
||||||
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
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 };
|
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken, status };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Admin" }, { label: "Users" }],
|
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }],
|
||||||
filterBar: listFilterBar(state, all.length),
|
filterBar: listFilterBar(state, all.length, t),
|
||||||
pagination: listPagination(state, page),
|
pagination: listPagination(state, page, t),
|
||||||
table: listTable(rows, state, sort),
|
table: listTable(rows, state, sort, t),
|
||||||
title: "Users",
|
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 {
|
return {
|
||||||
actions: true,
|
actions: true,
|
||||||
caption: "Users",
|
caption: t("admin.users.title"),
|
||||||
columns: COLUMNS.map((c) => {
|
columns: COLUMNS.map((c) => {
|
||||||
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
||||||
const next = dir === "asc" ? `-${c.key}` : c.key; // asc→desc, else→asc
|
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) => ({
|
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: [
|
cells: [
|
||||||
{ user: { initials: u.initials, name: u.name } },
|
{ user: { initials: u.initials, name: u.name } },
|
||||||
u.email,
|
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,
|
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 }[] = [];
|
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 });
|
||||||
if (state.status !== "all") pills.push({ label: "Status", remove: listHref(state, { page: 1, status: "all" }), value: cap(state.status) });
|
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 {
|
return {
|
||||||
applyLabel: "Apply filters",
|
applyLabel: t("filter.apply"), // an untranslated core key still resolves: the host catalog is the fallback
|
||||||
clearHref: ADMIN_USERS_BASE,
|
clearHref: ADMIN_USERS_BASE,
|
||||||
label: "Filter users",
|
label: t("admin.users.filter"),
|
||||||
pills,
|
pills,
|
||||||
rows: [[
|
rows: [[
|
||||||
{ label: "Search users", name: "q", placeholder: "Search name or email…", type: "search", value: state.q },
|
{ label: t("admin.users.searchLabel"), name: "q", placeholder: t("admin.users.searchPlaceholder"), type: "search", value: state.q },
|
||||||
{ legend: "Status", name: "status", options: [
|
{ legend: t("admin.users.status.label"), name: "status", options: [
|
||||||
{ count: total, label: "All", value: "all" },
|
{ count: total, label: t("admin.users.status.all"), value: "all" },
|
||||||
{ label: "Active", value: "active" },
|
{ label: t("admin.users.status.active"), value: "active" },
|
||||||
{ label: "Inactive", value: "inactive" },
|
{ label: t("admin.users.status.inactive"), value: "inactive" },
|
||||||
], type: "segmented", value: state.status },
|
], type: "segmented", value: state.status },
|
||||||
{ type: "spacer" },
|
{ 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 }[] = [];
|
const hidden: { name: string; value: string }[] = [];
|
||||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||||
if (state.status !== "all") hidden.push({ name: "status", value: state.status });
|
if (state.status !== "all") hidden.push({ name: "status", value: state.status });
|
||||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
||||||
return {
|
return {
|
||||||
label: "Users pagination",
|
label: t("admin.users.pagination"),
|
||||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
||||||
pages: page.pages.map((p) =>
|
pages: page.pages.map((p) =>
|
||||||
p.ellipsis ? { ellipsis: true }
|
p.ellipsis ? { ellipsis: true }
|
||||||
: p.current ? { current: true, label: String(p.page) }
|
: p.current ? { current: true, label: String(p.page) }
|
||||||
: { href: listHref(state, { page: p.page as number }), 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 },
|
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 },
|
summary: { from: page.from, to: page.to, total: page.total },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -220,8 +220,10 @@ export function buildUserFormModel(opts: {
|
|||||||
error?: string;
|
error?: string;
|
||||||
identity?: Identity | null;
|
identity?: Identity | null;
|
||||||
recovery?: RecoveryCode;
|
recovery?: RecoveryCode;
|
||||||
|
t?: Translate;
|
||||||
values?: Partial<UserInput>;
|
values?: Partial<UserInput>;
|
||||||
}) {
|
}) {
|
||||||
|
const t = opts.t ?? ADMIN_EN;
|
||||||
const editing = opts.identity != null;
|
const editing = opts.identity != null;
|
||||||
const view = editing ? toUserView(opts.identity!) : null;
|
const view = editing ? toUserView(opts.identity!) : null;
|
||||||
const np = editing ? nameParts(opts.identity!) : { first: opts.values?.first ?? "", last: opts.values?.last ?? "" };
|
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 idPath = editing ? `${ADMIN_USERS_BASE}/${encodeURIComponent(view!.id)}` : ADMIN_USERS_BASE;
|
||||||
|
|
||||||
const fields: FieldConfig[] = [
|
const fields: FieldConfig[] = [
|
||||||
{ autocomplete: "email", icon: "i-mail", id: "email", label: "Email", name: "email", required: !editing, type: "email", value: email,
|
{ autocomplete: "email", icon: "i-mail", id: "email", label: t("admin.users.field.email"), name: "email", required: !editing, type: "email", value: email,
|
||||||
...(editing ? { hint: "The login identifier — can't be changed here.", readonly: true } : {}) },
|
...(editing ? { hint: t("admin.users.field.emailHint"), readonly: true } : {}) },
|
||||||
{ id: "first", label: "First name", name: "first", optional: true, value: np.first },
|
{ id: "first", label: t("admin.users.field.first"), name: "first", optional: true, value: np.first },
|
||||||
{ id: "last", label: "Last name", name: "last", optional: true, value: np.last },
|
{ 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 {
|
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 ? {
|
edit: editing ? {
|
||||||
deleteAction: `${idPath}/delete`,
|
deleteAction: `${idPath}/delete`,
|
||||||
id: view!.id,
|
id: view!.id,
|
||||||
nextLabel: view!.state === "inactive" ? "Reactivate" : "Deactivate",
|
nextLabel: view!.state === "inactive" ? t("admin.users.reactivate") : t("admin.users.deactivate"),
|
||||||
recoveryAction: `${idPath}/recovery`,
|
recoveryAction: `${idPath}/recovery`,
|
||||||
state: view!.state,
|
state: view!.state,
|
||||||
stateAction: `${idPath}/state`,
|
stateAction: `${idPath}/state`,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
error: opts.error,
|
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,
|
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) => {
|
return async (ctx) => {
|
||||||
const user = requireAdmin(ctx);
|
const user = requireAdmin(ctx);
|
||||||
const kratosAdmin = ctx.system?.kratosAdmin;
|
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 });
|
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 =>
|
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.
|
// GET /admin/users — the filtered/sorted/paged list.
|
||||||
export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
|
export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
|
||||||
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
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.
|
// 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 {
|
try {
|
||||||
await kratosAdmin.createIdentity(createIdentityPayload(input));
|
await kratosAdmin.createIdentity(createIdentityPayload(input));
|
||||||
} catch (err) {
|
} 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;
|
throw err;
|
||||||
}
|
}
|
||||||
ctx.log.info("admin: user created", { actor: user.id, email: input.email });
|
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 {
|
try {
|
||||||
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
|
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
|
||||||
} catch (err) {
|
} 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;
|
throw err;
|
||||||
}
|
}
|
||||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
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.
|
// 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) => {
|
export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
||||||
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
|
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";
|
const nextState = identity.state === "inactive" ? "active" : "inactive";
|
||||||
await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState));
|
await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState));
|
||||||
if (nextState === "inactive") revoke?.(id);
|
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.
|
// GET /admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
|
||||||
export const usersDeleteConfirm = withTarget((deps, identity, id) => {
|
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 back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
|
||||||
const view = toUserView(identity);
|
const view = toUserView(identity);
|
||||||
|
const tt = deps.ctx.t;
|
||||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }],
|
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: "Delete user",
|
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: tt("admin.users.delete"),
|
||||||
message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, title: "Delete user",
|
message: tt("admin.users.deleteMessage", { email: view.email }), title: tt("admin.users.delete"),
|
||||||
}) }, view: "confirm" });
|
}) }, view: "confirm" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
|
// 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) => {
|
export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
||||||
await guardedForm(ctx); // CSRF-verify the POST
|
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);
|
await kratosAdmin.deleteIdentity(id);
|
||||||
revoke?.(id);
|
revoke?.(id);
|
||||||
ctx.log.info("admin: user deleted", { actor: user.id, target: 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 });
|
return formResult(ctx, { identity, recovery });
|
||||||
});
|
});
|
||||||
|
|
||||||
function createError(err: KratosError): string {
|
function createError(err: KratosError, t: Translate): string {
|
||||||
return err.status === 409
|
return err.status === 409
|
||||||
? "A user with that email already exists."
|
? t("admin.users.error.duplicate")
|
||||||
: "Could not create the user — check the email and try again.";
|
: 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 sign-in 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;
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
|
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
|
||||||
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
||||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
|
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
|
||||||
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-roles.ts";
|
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-permissions.ts";
|
||||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||||
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
|
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export default definePlugin({
|
|||||||
|
|
||||||
nav: [ADMIN_NAV],
|
nav: [ADMIN_NAV],
|
||||||
|
|
||||||
permissions: [{ description: "Administer users, groups, roles, and OAuth2 clients", token: ADMIN_PERMISSION }],
|
permissions: [{ description: "Administer users, groups, permissions, and OAuth2 clients", name: ADMIN_PERMISSION }],
|
||||||
|
|
||||||
routes: [
|
routes: [
|
||||||
// Users
|
// Users
|
||||||
@@ -46,14 +46,14 @@ export default definePlugin({
|
|||||||
r("POST", "/groups/:name/delete", groupsDelete),
|
r("POST", "/groups/:name/delete", groupsDelete),
|
||||||
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
||||||
// Roles
|
// Roles
|
||||||
r("GET", "/roles", rolesList),
|
r("GET", "/permissions", rolesList),
|
||||||
r("POST", "/roles", rolesCreate),
|
r("POST", "/permissions", rolesCreate),
|
||||||
r("GET", "/roles/new", rolesNewForm),
|
r("GET", "/permissions/new", rolesNewForm),
|
||||||
r("GET", "/roles/:name", rolesDetail),
|
r("GET", "/permissions/:name", rolesDetail),
|
||||||
r("POST", "/roles/:name/members", rolesAddMember),
|
r("POST", "/permissions/:name/members", rolesAddMember),
|
||||||
r("GET", "/roles/:name/delete", rolesDeleteConfirm),
|
r("GET", "/permissions/:name/delete", rolesDeleteConfirm),
|
||||||
r("POST", "/roles/:name/delete", rolesDelete),
|
r("POST", "/permissions/:name/delete", rolesDelete),
|
||||||
r("POST", "/roles/:name/members/delete", rolesRemoveMember),
|
r("POST", "/permissions/:name/members/delete", rolesRemoveMember),
|
||||||
// OAuth2 clients
|
// OAuth2 clients
|
||||||
r("GET", "/clients", clientsList),
|
r("GET", "/clients", clientsList),
|
||||||
r("POST", "/clients", clientsCreate),
|
r("POST", "/clients", clientsCreate),
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<%#
|
<%#
|
||||||
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
|
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
|
||||||
the Roles screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
|
the Permissions screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const filters = include("partials/filter-bar", model.filterBar);
|
const filters = include("partials/filter-bar", model.filterBar);
|
||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
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", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
const filters = include("partials/filter-bar", model.filterBar);
|
const filters = include("partials/filter-bar", model.filterBar);
|
||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
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", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
|
|||||||
@@ -11,28 +11,28 @@
|
|||||||
-%>
|
-%>
|
||||||
<div class="form-page">
|
<div class="form-page">
|
||||||
<% if (locals.created) { -%>
|
<% if (locals.created) { -%>
|
||||||
<%- include("partials/alert", { text: "Client registered.", tone: "pos" }) %>
|
<%- include("partials/alert", { text: t("admin.clients.createdNotice"), tone: "pos" }) %>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
<% if (locals.secret) { -%>
|
<% if (locals.secret) { -%>
|
||||||
<section class="form-card" aria-labelledby="secret-h">
|
<section class="form-card" aria-labelledby="secret-h">
|
||||||
<h2 class="card-title" id="secret-h">Client secret</h2>
|
<h2 class="card-title" id="secret-h"><%= t("admin.clients.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>
|
<p class="field-hint"><%= t("admin.clients.secretHint") %></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="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">Client secret</label><input class="input" id="csecret" type="text" value="<%= locals.secret %>" 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>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
<section class="form-card" aria-labelledby="client-h">
|
<section class="form-card" aria-labelledby="client-h">
|
||||||
<h2 class="card-title" id="client-h"><%= c.name %></h2>
|
<h2 class="card-title" id="client-h"><%= c.name %></h2>
|
||||||
<dl class="detail-list">
|
<dl class="detail-list">
|
||||||
<dt>Client ID</dt><dd><%= c.id %></dd>
|
<dt><%= t("admin.clients.column.id") %></dt><dd><%= c.id %></dd>
|
||||||
<dt>Type</dt><dd><%= c.public ? "Public (PKCE)" : "Confidential" %></dd>
|
<dt><%= t("admin.clients.column.type") %></dt><dd><%= c.public ? t("admin.clients.publicPkce") : t("admin.clients.confidential") %></dd>
|
||||||
<dt>Consent</dt><dd><%= c.firstParty ? "First-party (auto-granted)" : "Shows the consent screen" %></dd>
|
<dt><%= t("admin.clients.consent.label") %></dt><dd><%= c.firstParty ? t("admin.clients.consent.firstParty") : t("admin.clients.consent.screen") %></dd>
|
||||||
<dt>Scopes</dt><dd><%= c.scopes.length ? c.scopes.join(" ") : "—" %></dd>
|
<dt><%= t("admin.clients.field.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.field.redirectUris") %></dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
<section class="form-card admin-actions" aria-label="Client actions">
|
<section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>">
|
||||||
<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>
|
<p class="field-hint"><%= t("admin.clients.rereg") %></p>
|
||||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete client</a>
|
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,20 +10,20 @@
|
|||||||
<% if (locals.error) { -%>
|
<% if (locals.error) { -%>
|
||||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
<%- 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 %>">
|
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||||
<%- include("partials/field", form.nameField) %>
|
<%- include("partials/field", form.nameField) %>
|
||||||
<div class="field">
|
<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>
|
<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>
|
</div>
|
||||||
<%- include("partials/field", form.scopeField) %>
|
<%- 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>
|
<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>
|
<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">
|
<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>
|
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
csrfToken
|
csrfToken
|
||||||
%>
|
%>
|
||||||
<div class="form-page">
|
<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>
|
<p><%= locals.message %></p>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<a class="btn" href="<%= locals.cancelHref %>">Cancel</a>
|
<a class="btn" href="<%= localeHref(locals.cancelHref) %>"><%= t("common.cancel") %></a>
|
||||||
<form method="post" action="<%= locals.confirm.action %>"><input type="hidden" name="_csrf" value="<%= locals.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= locals.confirm.label %></button></form>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<%#
|
<%#
|
||||||
Admin group membership body, captured into the shell content slot. Config:
|
Admin group membership body, captured into the shell content slot. Config:
|
||||||
group { name }
|
group { name }
|
||||||
members { action, rows: { kind:"group"|"user", label, subject }[] } action = remove-member endpoint
|
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = remove-member endpoint
|
||||||
add { action, options: {label,value}[] } action = add-member endpoint
|
add { action, options: {label,value}[] } action = add-member endpoint
|
||||||
del { action } delete the whole group
|
del { action } delete the whole group
|
||||||
csrfToken, error?
|
csrfToken, error?
|
||||||
@@ -17,26 +17,26 @@
|
|||||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
<section class="form-card" aria-labelledby="members-h">
|
<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) { -%>
|
<% 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) => { -%>
|
<% 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>
|
</tbody></table></div>
|
||||||
<% } else { -%>
|
<% } else { -%>
|
||||||
<p class="cell-muted">No members yet.</p>
|
<p class="cell-muted"><%= t("admin.groups.noMembers") %></p>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
</section>
|
</section>
|
||||||
<section class="form-card" aria-labelledby="add-h">
|
<section class="form-card" aria-labelledby="add-h">
|
||||||
<h2 class="card-title" id="add-h">Add a member</h2>
|
<h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2>
|
||||||
<% if (add.options.length) { -%>
|
<% if (add.options.length) { -%>
|
||||||
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>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 { -%>
|
<% } else { -%>
|
||||||
<p class="cell-muted">All users and groups are already members.</p>
|
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
</section>
|
</section>
|
||||||
<section class="form-card admin-actions" aria-label="Group actions">
|
<section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>">
|
||||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete group</a>
|
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,16 +10,16 @@
|
|||||||
<% if (locals.error) { -%>
|
<% if (locals.error) { -%>
|
||||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
<%- 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 %>">
|
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||||
<%- include("partials/field", form.nameField) %>
|
<%- include("partials/field", form.nameField) %>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="member">First member</label>
|
<label for="member"><%= t("admin.groups.firstMember") %></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="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 group exists once it has a member; add more after creating it.</span>
|
<span class="field-hint"><%= t("admin.groups.firstMemberHint") %></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<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>
|
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<%#
|
||||||
|
Admin permission detail body, captured into the shell content slot. Config:
|
||||||
|
permission { name }
|
||||||
|
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint
|
||||||
|
effective { label }[] users who hold the permission (expand)
|
||||||
|
add { action, options: {label,value}[] } action = assign endpoint
|
||||||
|
del { action } delete the whole permission
|
||||||
|
csrfToken, error?
|
||||||
|
%><%
|
||||||
|
const permission = locals.permission;
|
||||||
|
const members = locals.members;
|
||||||
|
const effective = locals.effective;
|
||||||
|
const add = locals.add;
|
||||||
|
const del = locals.del;
|
||||||
|
const csrf = locals.csrfToken;
|
||||||
|
-%>
|
||||||
|
<div class="form-page">
|
||||||
|
<% if (locals.error) { -%>
|
||||||
|
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||||
|
<% } -%>
|
||||||
|
<section class="form-card" aria-labelledby="members-h">
|
||||||
|
<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"><%= 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" ? 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"><%= t("admin.permissions.noMembers") %></p>
|
||||||
|
<% } -%>
|
||||||
|
</section>
|
||||||
|
<section class="form-card" aria-labelledby="effective-h">
|
||||||
|
<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) => { -%>
|
||||||
|
<li><span class="cell-strong"><%= u.label %></span></li>
|
||||||
|
<% }) -%>
|
||||||
|
</ul>
|
||||||
|
<% } else { -%>
|
||||||
|
<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"><%= t("admin.permissions.assign") %></h2>
|
||||||
|
<% if (add.options.length) { -%>
|
||||||
|
<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"><%= t("admin.permissions.allAssigned") %></p>
|
||||||
|
<% } -%>
|
||||||
|
</section>
|
||||||
|
<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>
|
||||||
+6
-6
@@ -1,5 +1,5 @@
|
|||||||
<%#
|
<%#
|
||||||
Admin role create form body, captured into the shell content slot. Config:
|
Admin permission create form body, captured into the shell content slot. Config:
|
||||||
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
|
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
|
||||||
memberOptions: {label,value}[], selectedMember }
|
memberOptions: {label,value}[], selectedMember }
|
||||||
error? string shown when a write was rejected
|
error? string shown when a write was rejected
|
||||||
@@ -10,16 +10,16 @@
|
|||||||
<% if (locals.error) { -%>
|
<% if (locals.error) { -%>
|
||||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
<%- 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 %>">
|
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||||
<%- include("partials/field", form.nameField) %>
|
<%- include("partials/field", form.nameField) %>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="member">Assign to</label>
|
<label for="member"><%= t("admin.permissions.assignTo") %></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>
|
<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 role exists once assigned; add more users or groups after creating it.</span>
|
<span class="field-hint">A permission exists once assigned; add more users or groups after creating it.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<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>
|
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<%#
|
|
||||||
Admin role detail body, captured into the shell content slot. Config:
|
|
||||||
role { name }
|
|
||||||
members { action, rows: { kind:"group"|"user", label, subject }[] } action = revoke endpoint
|
|
||||||
effective { label }[] users who hold the role (expand)
|
|
||||||
add { action, options: {label,value}[] } action = assign endpoint
|
|
||||||
del { action } delete the whole role
|
|
||||||
csrfToken, error?
|
|
||||||
%><%
|
|
||||||
const role = locals.role;
|
|
||||||
const members = locals.members;
|
|
||||||
const effective = locals.effective;
|
|
||||||
const add = locals.add;
|
|
||||||
const del = locals.del;
|
|
||||||
const csrf = locals.csrfToken;
|
|
||||||
-%>
|
|
||||||
<div class="form-page">
|
|
||||||
<% if (locals.error) { -%>
|
|
||||||
<%- 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>
|
|
||||||
<% if (members.rows.length) { -%>
|
|
||||||
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= role.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>
|
|
||||||
<% 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>
|
|
||||||
<% }) -%>
|
|
||||||
</tbody></table></div>
|
|
||||||
<% } else { -%>
|
|
||||||
<p class="cell-muted">Not assigned to anyone yet.</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 role — directly or through a group (resolved by Keto).</p>
|
|
||||||
<% if (effective.length) { -%>
|
|
||||||
<ul class="plain-list">
|
|
||||||
<% effective.forEach((u) => { -%>
|
|
||||||
<li><span class="cell-strong"><%= u.label %></span></li>
|
|
||||||
<% }) -%>
|
|
||||||
</ul>
|
|
||||||
<% } else { -%>
|
|
||||||
<p class="cell-muted">No users hold this role yet.</p>
|
|
||||||
<% } -%>
|
|
||||||
</section>
|
|
||||||
<section class="form-card" aria-labelledby="add-h">
|
|
||||||
<h2 class="card-title" id="add-h">Assign the role</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>
|
|
||||||
<% } else { -%>
|
|
||||||
<p class="cell-muted">All users and groups already have this role.</p>
|
|
||||||
<% } -%>
|
|
||||||
</section>
|
|
||||||
<section class="form-card admin-actions" aria-label="Role actions">
|
|
||||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete role</a>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
@@ -14,23 +14,23 @@
|
|||||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
<% if (recovery) { -%>
|
<% 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 %>">
|
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||||
<% form.fields.forEach((field) => { -%>
|
<% form.fields.forEach((field) => { -%>
|
||||||
<%- include("partials/field", field) %>
|
<%- include("partials/field", field) %>
|
||||||
<% }) -%>
|
<% }) -%>
|
||||||
<div class="form-actions">
|
<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>
|
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<% if (edit) { -%>
|
<% if (edit) { -%>
|
||||||
<section class="form-card admin-actions" aria-label="Account actions">
|
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
|
||||||
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg>Generate recovery code</button></form>
|
<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="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></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="<%= edit.deleteAction %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete user</a>
|
<a class="btn btn-danger" href="<%= localeHref(edit.deleteAction) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a>
|
||||||
</section>
|
</section>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<%#
|
||||||
|
Permission admin detail page: the permission-detail body (members · effective access) in the shell.
|
||||||
|
%><%
|
||||||
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
|
const body = include("partials/permission-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, permission: model.permission });
|
||||||
|
-%>
|
||||||
|
<%- include("partials/shell", {
|
||||||
|
body,
|
||||||
|
brand: chrome.brand,
|
||||||
|
breadcrumbs: model.breadcrumbs,
|
||||||
|
csrfToken: chrome.csrfToken,
|
||||||
|
nav,
|
||||||
|
theme: chrome.theme,
|
||||||
|
title: model.title,
|
||||||
|
user: chrome.user,
|
||||||
|
}) %>
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
<%#
|
<%#
|
||||||
Role admin create page: the role-form body captured into the app shell.
|
Permission admin create page: the permission-form body captured into the app shell.
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const body = include("partials/role-form-body", { error: model.error, form: model.form });
|
const body = include("partials/permission-form-body", { error: model.error, form: model.form });
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
body,
|
body,
|
||||||
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
<%#
|
<%#
|
||||||
Roles admin list: the same building blocks as the Groups screen, around the shell, backed
|
Permissions admin list: the same building blocks as the Groups screen, around the shell, backed
|
||||||
by live Keto Role subject sets (admin-roles.ts). Filter/sort/page round-trip the URL.
|
by live Keto Permission subject sets (admin-permissions.ts). Filter/sort/page round-trip the URL.
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const filters = include("partials/filter-bar", model.filterBar);
|
const filters = include("partials/filter-bar", model.filterBar);
|
||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
const pager = include("partials/pagination", model.pagination);
|
||||||
const actions = '<a class="btn btn-primary" href="/admin/roles/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add role</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", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<%#
|
|
||||||
Role admin detail page: the role-detail body (members · effective access) in the shell.
|
|
||||||
%><%
|
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
|
||||||
const body = include("partials/role-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, role: model.role });
|
|
||||||
-%>
|
|
||||||
<%- include("partials/shell", {
|
|
||||||
body,
|
|
||||||
brand: chrome.brand,
|
|
||||||
breadcrumbs: model.breadcrumbs,
|
|
||||||
csrfToken: chrome.csrfToken,
|
|
||||||
nav,
|
|
||||||
theme: chrome.theme,
|
|
||||||
title: model.title,
|
|
||||||
user: chrome.user,
|
|
||||||
}) %>
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
const filters = include("partials/filter-bar", model.filterBar);
|
const filters = include("partials/filter-bar", model.filterBar);
|
||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
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", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ What it demonstrates:
|
|||||||
reusing the core `field` partial.
|
reusing the core `field` partial.
|
||||||
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
|
- **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.
|
`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
|
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`).
|
`fetch` is injectable, so they unit-test as pure functions (`shifts.test.ts`).
|
||||||
@@ -46,6 +50,6 @@ cosmetically) — normalise to your backend's format there if it matters.
|
|||||||
|
|
||||||
## Granting access
|
## Granting access
|
||||||
|
|
||||||
A user sees Scheduling once they hold the `scheduling:read` role in Keto (and `scheduling:write`
|
A user sees Scheduling once they hold the `scheduling:read` permission in Keto (and `scheduling:write`
|
||||||
to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
||||||
`admin@plainpages.local` can use it immediately.
|
`admin@plainpages.local` can use it immediately.
|
||||||
|
|||||||
@@ -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,27 +17,28 @@ export default definePlugin({
|
|||||||
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
|
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
|
||||||
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") },
|
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
|
// 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).
|
// stays hidden until a reader signs in (a plugin may make a page + its menu option public).
|
||||||
nav: [{
|
nav: [{
|
||||||
children: [
|
children: [
|
||||||
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
|
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true },
|
||||||
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ },
|
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
|
||||||
],
|
],
|
||||||
icon: "i-cal",
|
icon: "i-cal",
|
||||||
id: "scheduling",
|
id: "scheduling",
|
||||||
label: "Scheduling",
|
label: "scheduling.nav.section",
|
||||||
}],
|
}],
|
||||||
|
|
||||||
// Tokens this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
||||||
permissions: [
|
permissions: [
|
||||||
{ description: "View shifts", token: READ },
|
{ description: "View shifts", name: READ },
|
||||||
{ description: "Create and edit shifts", token: WRITE },
|
{ description: "Create and edit shifts", name: WRITE },
|
||||||
],
|
],
|
||||||
|
|
||||||
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
|
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
|
||||||
// (anyone may reach /scheduling, signed in or not); the rest need a role.
|
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
|
||||||
routes: [
|
routes: [
|
||||||
{ handler: overview(), method: "GET", path: "/", public: true },
|
{ handler: overview(), method: "GET", path: "/", public: true },
|
||||||
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
||||||
|
|||||||
@@ -4,20 +4,23 @@ import { Readable } from "node:stream";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
// Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
// 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.
|
// 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 {
|
import {
|
||||||
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
||||||
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
||||||
} from "./shifts.ts";
|
} 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" } };
|
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
||||||
|
|
||||||
function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
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 url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||||
return {
|
return {
|
||||||
chrome: CHROME, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
chrome: CHROME, user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
||||||
roles: opts.roles ?? [], url, user: null, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
|
||||||
|
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +96,8 @@ test("readInput trims; validate requires title + assignee", () => {
|
|||||||
|
|
||||||
// ---- list handler ----
|
// ---- list handler ----
|
||||||
|
|
||||||
test("listShifts renders the upstream rows; q filters; canWrite reflects the role", async () => {
|
test("listShifts renders the upstream rows; q filters; canWrite reflects the permission", async () => {
|
||||||
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ roles: ["scheduling:write"] })));
|
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ permissions: ["scheduling:write"] })));
|
||||||
assert.equal(r.view, "shifts");
|
assert.equal(r.view, "shifts");
|
||||||
const table = r.data["table"] as { rows: { name: string }[] };
|
const table = r.data["table"] as { rows: { name: string }[] };
|
||||||
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
|
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
|
||||||
@@ -112,15 +115,15 @@ test("listShifts degrades to a recoverable error page when the upstream is down
|
|||||||
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
|
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- public overview handler (a page anyone can reach, gated data stays behind the role) ----
|
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
|
||||||
|
|
||||||
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
||||||
const anon = asView(await overview()(fakeCtx())); // user null, no roles
|
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
|
||||||
assert.equal(anon.view, "overview");
|
assert.equal(anon.view, "overview");
|
||||||
assert.equal(anon.data["chrome"], CHROME);
|
assert.equal(anon.data["chrome"], CHROME);
|
||||||
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
||||||
|
|
||||||
const reader = asView(await overview()(fakeCtx({ roles: ["scheduling:read"] })));
|
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
|
||||||
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,18 @@
|
|||||||
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
// 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).
|
// 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 SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
||||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||||
export const READ = "scheduling:read"; // permission token gating the list + nav
|
export const READ = "scheduling:read"; // the permission gating the list + nav
|
||||||
export const WRITE = "scheduling:write"; // permission token gating create
|
export const WRITE = "scheduling:write"; // the permission gating create
|
||||||
|
|
||||||
export interface Shift {
|
export interface Shift {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -87,58 +93,63 @@ function toShift(raw: unknown): Shift {
|
|||||||
|
|
||||||
// ---- view models (pure; the EJS views read these) -----------------------------------
|
// ---- 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 {
|
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,
|
canWrite: opts.canWrite,
|
||||||
chrome: opts.chrome,
|
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 } : {}),
|
...(opts.error ? { error: opts.error } : {}),
|
||||||
filterBar: {
|
filterBar: {
|
||||||
applyLabel: "Search",
|
applyLabel: t("filter.search"),
|
||||||
clearHref: SHIFTS_PATH,
|
clearHref: SHIFTS_PATH,
|
||||||
label: "Filter shifts",
|
label: t("scheduling.filter.label"),
|
||||||
pills: opts.q ? [{ label: "Search", remove: SHIFTS_PATH, value: opts.q }] : [],
|
pills: opts.q ? [{ label: t("filter.search"), remove: SHIFTS_PATH, value: opts.q }] : [],
|
||||||
rows: [[
|
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" },
|
{ type: "spacer" },
|
||||||
]],
|
]],
|
||||||
},
|
},
|
||||||
newHref: `${SHIFTS_PATH}/new`,
|
newHref: `${SHIFTS_PATH}/new`,
|
||||||
table: {
|
table: {
|
||||||
caption: "Shifts",
|
caption: t("scheduling.shifts.title"),
|
||||||
columns: [{ label: "Shift" }, { label: "Assignee" }, { label: "Start" }, { label: "End" }],
|
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) => ({
|
rows: opts.shifts.map((s) => ({
|
||||||
cells: [{ rowHeader: { text: s.title } }, s.assignee, s.start, s.end],
|
cells: [{ rowHeader: { text: s.title } }, s.assignee, s.start, s.end],
|
||||||
name: s.title,
|
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 v = opts.values ?? {};
|
||||||
const e = opts.errors ?? {};
|
const e = opts.errors ?? {};
|
||||||
const field = (cfg: { icon?: string; id: string; label: string; type?: string; value: string }) => ({
|
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 } : {}),
|
...cfg, name: cfg.id, ...(e[cfg.id] ? { error: e[cfg.id] } : {}), ...(cfg.id === "title" || cfg.id === "assignee" ? { required: true } : {}),
|
||||||
});
|
});
|
||||||
return {
|
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,
|
chrome: opts.chrome,
|
||||||
...(opts.formError ? { formError: opts.formError } : {}),
|
...(opts.formError ? { formError: opts.formError } : {}),
|
||||||
form: {
|
form: {
|
||||||
action: SHIFTS_PATH,
|
action: SHIFTS_PATH,
|
||||||
cancelHref: SHIFTS_PATH,
|
cancelHref: SHIFTS_PATH,
|
||||||
csrfToken: opts.chrome.csrfToken,
|
csrfToken: opts.chrome.csrfToken,
|
||||||
|
cancelLabel: t("common.cancel"),
|
||||||
fields: [
|
fields: [
|
||||||
field({ icon: "i-cal", id: "title", label: "Shift title", value: v.title ?? "" }),
|
field({ icon: "i-cal", id: "title", label: t("scheduling.field.title"), value: v.title ?? "" }),
|
||||||
field({ icon: "i-user", id: "assignee", label: "Assignee", value: v.assignee ?? "" }),
|
field({ icon: "i-user", id: "assignee", label: t("scheduling.field.assignee"), value: v.assignee ?? "" }),
|
||||||
field({ id: "start", label: "Start", type: "datetime-local", value: v.start ?? "" }),
|
field({ id: "start", label: t("scheduling.field.start"), type: "datetime-local", value: v.start ?? "" }),
|
||||||
field({ id: "end", label: "End", type: "datetime-local", value: v.end ?? "" }),
|
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
|
// 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.
|
// 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> = {};
|
const errors: Record<string, string> = {};
|
||||||
if (!input.title) errors["title"] = "A shift needs a title.";
|
if (!input.title) errors["title"] = t("scheduling.validation.title");
|
||||||
if (!input.assignee) errors["assignee"] = "Assign the shift to someone.";
|
if (!input.assignee) errors["assignee"] = t("scheduling.validation.assignee");
|
||||||
return Object.keys(errors).length ? errors : null;
|
return Object.keys(errors).length ? errors : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,25 +184,32 @@ export function listShifts(upstream: ShiftsUpstream): RouteHandler {
|
|||||||
shifts = await upstream.list();
|
shifts = await upstream.list();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); // plugin logging via ctx.log
|
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 needle = q.toLowerCase();
|
||||||
const rows = needle ? shifts.filter((s) => s.title.toLowerCase().includes(needle) || s.assignee.toLowerCase().includes(needle)) : shifts;
|
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 {
|
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
|
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
|
||||||
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
||||||
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
||||||
// else a prompt to sign in. ctx.user may be null here, so read the role via can() (zero I/O).
|
// 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 {
|
export function overview(): RouteHandler {
|
||||||
return (ctx) => ({
|
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",
|
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).
|
// 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");
|
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token");
|
||||||
const input = readInput(form);
|
const input = readInput(form);
|
||||||
const errors = validate(input);
|
const errors = validate(input, ctx.t);
|
||||||
if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, values: input }), status: 400, view: "shift-new" };
|
if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, t: ctx.t, values: input }), status: 400, view: "shift-new" };
|
||||||
try {
|
try {
|
||||||
await upstream.create(input);
|
await upstream.create(input);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.log.warn("scheduling shift create failed (upstream)", { error: String(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 });
|
ctx.log.info("scheduling shift created", { assignee: input.assignee, title: input.title });
|
||||||
return { redirect: SHIFTS_PATH }; // POST-redirect-GET
|
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
|
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
|
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.
|
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 navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const cta = canRead
|
const cta = canRead
|
||||||
? '<a class="btn btn-primary" href="' + shiftsHref + '">View shifts</a>'
|
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
|
||||||
: '<a class="btn btn-primary" href="/login?return_to=' + encodeURIComponent(shiftsHref) + '">Sign in to view shifts</a>';
|
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions: "",
|
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> role.</p>' + cta + '</div>',
|
body: '<div class="scheduling-page"><p>' + t("scheduling.overview.lead") + '</p>' + cta + '</div>',
|
||||||
brand: chrome.brand,
|
brand: chrome.brand,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
csrfToken: chrome.csrfToken,
|
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
|
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,
|
`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;
|
const form = locals.form;
|
||||||
-%>
|
-%>
|
||||||
@@ -9,13 +9,13 @@
|
|||||||
<% if (locals.formError) { -%>
|
<% if (locals.formError) { -%>
|
||||||
<%- include("partials/alert", { text: locals.formError, tone: "neg" }) %>
|
<%- 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 %>">
|
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||||
<% form.fields.forEach((field) => { -%>
|
<% form.fields.forEach((field) => { -%>
|
||||||
<%- include("partials/field", field) %>
|
<%- include("partials/field", field) %>
|
||||||
<% }) -%>
|
<% }) -%>
|
||||||
<div class="form-actions">
|
<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>
|
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,19 +3,19 @@
|
|||||||
service; this view renders them with the core building blocks inside the native app shell
|
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,
|
(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.
|
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 navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const filtersHtml = include("partials/filter-bar", filterBar);
|
const filtersHtml = include("partials/filter-bar", filterBar);
|
||||||
const tableHtml = include("partials/data-table", table);
|
const tableHtml = include("partials/data-table", table);
|
||||||
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
|
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
|
||||||
const actions = canWrite
|
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", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
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,
|
brand: chrome.brand,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
csrfToken: chrome.csrfToken,
|
csrfToken: chrome.csrfToken,
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# Ory Keto — authorization (ReBAC), the source of truth for roles/groups and the rare
|
# Ory Keto — authorization (ReBAC), the source of truth for permissions/groups and the rare
|
||||||
# fine-grained check (README: three tiers of "may I?"). The permission model lives in
|
# fine-grained check (README: three tiers of "may I?"). The permission model lives in
|
||||||
# namespaces.keto.ts (OPL); DSN comes from the env (the per-service keto DB). The web
|
# namespaces.keto.ts (OPL); DSN comes from the env (the per-service keto DB). The web
|
||||||
# app never connects directly — it calls the read (4466) / write (4467) APIs, the ports
|
# app never connects directly — it calls the read (4466) / write (4467) APIs, the ports
|
||||||
|
|||||||
@@ -4,28 +4,30 @@
|
|||||||
// identity ids (== the JWT `sub`).
|
// identity ids (== the JWT `sub`).
|
||||||
import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
|
import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
|
||||||
|
|
||||||
// A human identity. Subjects are written as `user:<kratos-identity-id>`.
|
// A person. Ory calls this an "identity" (Kratos owns the record); Plainpages says "user"
|
||||||
|
// throughout. Subjects are written as `user:<kratos-identity-id>`.
|
||||||
class User implements Namespace {}
|
class User implements Namespace {}
|
||||||
|
|
||||||
// A subject set: a named collection of users (and nested groups), resolved transitively.
|
// A named set of users (and nested groups), resolved transitively. The admin "Groups"
|
||||||
// The admin "Groups" screen manages membership; checks expand it automatically.
|
// screen manages membership; checks expand it automatically.
|
||||||
class Group implements Namespace {
|
class Group implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
members: (User | SubjectSet<Group, "members">)[]
|
members: (User | SubjectSet<Group, "members">)[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A coarse role — the source of truth for the JWT `roles` claim. At login the app reads
|
// A coarse permission — an operation a route or menu item gates on, and the source of truth
|
||||||
// `role:<name>#members@user:<id>` from Keto and projects the result into the token
|
// for the JWT `permissions` claim. At login the app reads `Permission:<name>#granted@user:<id>`
|
||||||
// (README: Login → session JWT). A group can hold a role, so members can be users or groups.
|
// from Keto and projects the result into the token (README: Login → session JWT). A group can
|
||||||
class Role implements Namespace {
|
// hold a permission, so grants go to a user or to a whole group.
|
||||||
|
class Permission implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
members: (User | SubjectSet<Group, "members">)[]
|
granted: (User | SubjectSet<Group, "members">)[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A fine-grained, relationship-checked resource — README's third "may I?" tier, the rare
|
// A fine-grained, relationship-checked resource — README's third "may I?" tier, the rare
|
||||||
// live Keto check (e.g. sharing/delegation). Permissions nest: owner ⊇ editor ⊇ viewer.
|
// live Keto check (e.g. sharing/delegation). Permits nest: owner ⊇ editor ⊇ viewer.
|
||||||
// Grants accept a user directly or any member of a group.
|
// Grants accept a user directly or any member of a group.
|
||||||
class Resource implements Namespace {
|
class Resource implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ selfservice:
|
|||||||
ui_url: http://localhost:3000/login
|
ui_url: http://localhost:3000/login
|
||||||
after:
|
after:
|
||||||
# After authenticating, land on our completion route — it mints the session JWT
|
# After authenticating, land on our completion route — it mints the session JWT
|
||||||
# (roles from Keto → metadata_public projection → tokenize) and sets our cookie.
|
# (permissions from Keto → metadata_public projection → tokenize) and sets our cookie.
|
||||||
default_browser_return_url: http://localhost:3000/auth/complete
|
default_browser_return_url: http://localhost:3000/auth/complete
|
||||||
registration:
|
registration:
|
||||||
ui_url: http://localhost:3000/registration
|
ui_url: http://localhost:3000/registration
|
||||||
@@ -94,7 +94,7 @@ session:
|
|||||||
same_site: Lax
|
same_site: Lax
|
||||||
# Session→JWT tokenizer: whoami(tokenize_as: plainpages) mints a short-lived,
|
# Session→JWT tokenizer: whoami(tokenize_as: plainpages) mints a short-lived,
|
||||||
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the
|
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the
|
||||||
# committed Jsonnet mapper (sub = identity id, email from traits, roles from the
|
# committed Jsonnet mapper (sub = identity id, email from traits, permissions from the
|
||||||
# metadata_public projection); signed with tokenizer/jwks.json.
|
# metadata_public projection); signed with tokenizer/jwks.json.
|
||||||
whoami:
|
whoami:
|
||||||
tokenizer:
|
tokenizer:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Session→JWT claims mapper for the `plainpages` tokenizer. Kratos exposes the
|
// Session→JWT claims mapper for the `plainpages` tokenizer. Kratos exposes the
|
||||||
// session as `session`; `sub` is set from the identity id (subject_source: id) and
|
// session as `session`; `sub` is set from the identity id (subject_source: id) and
|
||||||
// can't be overridden here. roles come from metadata_public — the per-login projection
|
// can't be overridden here. permissions come from metadata_public — the per-login projection
|
||||||
// of Keto roles the app refreshes at login (metadata_admin is NOT carried in the session
|
// of Keto permissions the app refreshes at login (metadata_admin is NOT carried in the session
|
||||||
// the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list.
|
// the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list.
|
||||||
local session = std.extVar('session');
|
local session = std.extVar('session');
|
||||||
local meta =
|
local meta =
|
||||||
@@ -12,6 +12,6 @@ local meta =
|
|||||||
{
|
{
|
||||||
claims: {
|
claims: {
|
||||||
email: session.identity.traits.email,
|
email: session.identity.traits.email,
|
||||||
roles: if std.objectHas(meta, 'roles') then meta.roles else [],
|
permissions: if std.objectHas(meta, 'permissions') then meta.permissions else [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-15
@@ -126,7 +126,7 @@ summary { list-style: none; cursor: pointer; }
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
.sidebar, .scrim, summary, .nav-item, .btn, .chip { transition: .15s ease; }
|
.sidebar, .scrim, summary, .nav-item, .btn, .chip, .profile, .kebab { transition: .15s ease; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.sr-only {
|
.sr-only {
|
||||||
@@ -282,7 +282,6 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
outline: 2px solid var(--focus); outline-offset: 1px;
|
outline: 2px solid var(--focus); outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* profile / settings row */
|
|
||||||
.footer-actions { display: flex; align-items: center; gap: 4px; }
|
.footer-actions { display: flex; align-items: center; gap: 4px; }
|
||||||
.profile {
|
.profile {
|
||||||
display: flex; align-items: center; gap: 9px; flex: 1 1 auto;
|
display: flex; align-items: center; gap: 9px; flex: 1 1 auto;
|
||||||
@@ -476,19 +475,35 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
margin: 0; cursor: pointer; }
|
margin: 0; cursor: pointer; }
|
||||||
.check:hover, .radio:hover { color: var(--text); }
|
.check:hover, .radio:hover { color: var(--text); }
|
||||||
|
|
||||||
/* popover menu (column settings, kebab, etc.) — pure <details> */
|
/* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
|
||||||
.menu { position: relative; display: inline-flex; }
|
panel, wrapped so the pair is one element in any layout. The browser owns open/close, and the top
|
||||||
.menu > summary { display: inline-flex; }
|
layer keeps the panel clear of a scrolling ancestor's overflow. `position-anchor: auto` binds it to
|
||||||
.menu > summary::after { content: none; }
|
the button that opened it; a bare anchor() without it resolves to nothing in every engine. `color`
|
||||||
|
is set because the UA sheet gives [popover] a CanvasText of its own, which would ignore the theme. */
|
||||||
|
.menu { display: inline-flex; }
|
||||||
.menu-pop {
|
.menu-pop {
|
||||||
position: absolute; top: calc(100% + 6px); right: 0; z-index: 40;
|
position: absolute; inset: auto; margin: 6px 0 0;
|
||||||
|
position-anchor: auto;
|
||||||
|
position-try-fallbacks: flip-block, flip-inline;
|
||||||
|
top: anchor(bottom); right: anchor(right);
|
||||||
min-width: 210px; padding: 6px;
|
min-width: 210px; padding: 6px;
|
||||||
background: var(--surface); border: 1px solid var(--border-2);
|
background: var(--surface); color: var(--text);
|
||||||
border-radius: var(--radius);
|
border: 1px solid var(--border-2); border-radius: var(--radius);
|
||||||
box-shadow: 0 8px 28px rgba(0,0,0,.16);
|
box-shadow: 0 8px 28px rgba(0,0,0,.16);
|
||||||
}
|
}
|
||||||
.menu-pop.left { right: auto; left: 0; }
|
.menu-pop.left { right: auto; left: anchor(left); }
|
||||||
.menu-pop.up { top: auto; bottom: calc(100% + 6px); }
|
.menu-pop.up { top: auto; bottom: anchor(top); margin: 0 0 6px; }
|
||||||
|
/* A browser too old for the Popover API leaves the trigger inert, so let each panel flow under its
|
||||||
|
trigger rather than stranding Sign out and the language picker behind a button that does nothing.
|
||||||
|
The wrapper turns block so the pair stacks instead of sitting side by side, and min-width goes —
|
||||||
|
it would otherwise push the sidebar and a 44px action cell far past their width. Cramped but
|
||||||
|
reachable, and unreachable by any test: a browser with both features cannot render this path.
|
||||||
|
Both are guarded — an engine with popover but no anchor positioning would otherwise leave the
|
||||||
|
panel absolutely positioned with every anchor() dropped, detached from its trigger. */
|
||||||
|
@supports not (selector([popover]:popover-open) and (position-anchor: auto)) {
|
||||||
|
.menu { display: block; }
|
||||||
|
.menu-pop, .menu-pop.up { position: static; min-width: 0; margin: 6px 0 0; }
|
||||||
|
}
|
||||||
.menu-head { font-size: var(--fz-xs); text-transform: uppercase;
|
.menu-head { font-size: var(--fz-xs); text-transform: uppercase;
|
||||||
letter-spacing: .05em; color: var(--text-faint); font-weight: 600;
|
letter-spacing: .05em; color: var(--text-faint); font-weight: 600;
|
||||||
padding: 5px 8px; }
|
padding: 5px 8px; }
|
||||||
@@ -500,6 +515,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-form { display: contents; } /* form wraps the Sign-out button without changing layout */
|
||||||
.menu-item:hover { background: var(--surface-2); }
|
.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.danger { color: var(--neg); }
|
||||||
.menu-item .ico { color: var(--text-faint); }
|
.menu-item .ico { color: var(--text-faint); }
|
||||||
.menu-item.danger .ico { color: var(--neg); }
|
.menu-item.danger .ico { color: var(--neg); }
|
||||||
@@ -610,10 +626,13 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
|
|
||||||
/* row kebab */
|
/* row kebab */
|
||||||
.col-actions { width: 44px; text-align: center; }
|
.col-actions { width: 44px; text-align: center; }
|
||||||
.kebab summary { width: 26px; height: 26px; border-radius: var(--radius);
|
.kebab { width: 26px; height: 26px; border-radius: var(--radius);
|
||||||
display: grid; place-items: center; color: var(--text-faint); margin: 0 auto; }
|
display: grid; place-items: center; color: var(--text-faint); margin: 0 auto;
|
||||||
.kebab summary:hover { background: var(--surface-2); color: var(--text); }
|
background: transparent; border: 0; padding: 0; cursor: pointer; }
|
||||||
.kebab[open] summary { background: var(--surface-2); color: var(--text); }
|
.kebab:hover { background: var(--surface-2); color: var(--text); }
|
||||||
|
/* Held open — a trigger's panel is its next sibling. Its own rule: :has() is non-forgiving, so an
|
||||||
|
engine that doesn't know :popover-open would drop the hover style along with it. */
|
||||||
|
.kebab:has(+ .menu-pop:popover-open) { background: var(--surface-2); color: var(--text); }
|
||||||
|
|
||||||
/* ---------- 10. PAGINATION --------------------------------- */
|
/* ---------- 10. PAGINATION --------------------------------- */
|
||||||
.pager {
|
.pager {
|
||||||
|
|||||||
+24
-24
@@ -1,11 +1,11 @@
|
|||||||
// One-command bootstrap: idempotent first-boot seeding. Guards the pure payload
|
// One-command bootstrap: idempotent first-boot seeding. Guards the pure payload
|
||||||
// builders (Kratos create-identity body + Keto role tuple), the idempotent seedAdmin
|
// builders (Kratos create-identity body + Keto permission tuple), the idempotent seedAdmin
|
||||||
// orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent
|
// orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent
|
||||||
// safety net. Live boot is verified by running the stack; these catch contract drift.
|
// safety net. Live boot is verified by running the stack; these catch contract drift.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin, seedRoles } from "./bootstrap.ts";
|
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, seedAdmin, seedPermissions } from "./bootstrap.ts";
|
||||||
|
|
||||||
const json = (status: number, body?: unknown) =>
|
const json = (status: number, body?: unknown) =>
|
||||||
new Response(body === undefined ? null : JSON.stringify(body), {
|
new Response(body === undefined ? null : JSON.stringify(body), {
|
||||||
@@ -20,27 +20,27 @@ test("identityPayload is a valid Kratos create-identity body with a password cre
|
|||||||
assert.equal(body.credentials.password.config.password, "admin");
|
assert.equal(body.credentials.password.config.password, "admin");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("roleTuple grants a role to user:<id> in the Role namespace", () => {
|
test("permissionTuple grants a permission to user:<id> in the Permission namespace", () => {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
assert.deepEqual(roleTuple(id, "admin"), {
|
assert.deepEqual(permissionTuple(id, "admin"), {
|
||||||
namespace: "Role",
|
namespace: "Permission",
|
||||||
object: "admin",
|
object: "admin",
|
||||||
relation: "members",
|
relation: "granted",
|
||||||
subject_id: `user:${id}`,
|
subject_id: `user:${id}`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared tokens", () => {
|
test("seedPermissions unions ADMIN_PERMISSIONS (default 'admin') with the discovered plugins' declared permissions", () => {
|
||||||
// Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin
|
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two tokens → the demo admin
|
||||||
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
|
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
|
||||||
assert.deepEqual(seedRoles(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
|
assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
|
||||||
assert.deepEqual(seedRoles(undefined, []), ["admin"]); // no plugins → just the base admin role
|
assert.deepEqual(seedPermissions(undefined, []), ["admin"]); // no plugins → just the base admin permission
|
||||||
assert.deepEqual(seedRoles("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
|
assert.deepEqual(seedPermissions("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
|
||||||
assert.deepEqual(seedRoles("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
|
assert.deepEqual(seedPermissions("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
|
||||||
assert.deepEqual(seedRoles("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
|
assert.deepEqual(seedPermissions("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => {
|
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const calls: { method: string; url: string; body?: unknown }[] = [];
|
const calls: { method: string; url: string; body?: unknown }[] = [];
|
||||||
const fetchImpl = (async (url, init) => {
|
const fetchImpl = (async (url, init) => {
|
||||||
@@ -57,20 +57,20 @@ test("seedAdmin on a fresh stack creates the identity and grants every role (one
|
|||||||
ketoWriteUrl: "http://keto:4467",
|
ketoWriteUrl: "http://keto:4467",
|
||||||
kratosAdminUrl: "http://kratos:4434",
|
kratosAdminUrl: "http://kratos:4434",
|
||||||
password: "admin",
|
password: "admin",
|
||||||
roles: ["admin", "scheduling:read"],
|
permissions: ["admin", "scheduling:read"],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result, { created: true, id, roles: ["admin", "scheduling:read"] });
|
assert.deepEqual(result, { created: true, id, permissions: ["admin", "scheduling:read"] });
|
||||||
const puts = calls.filter((c) => c.url.includes("relation-tuples"));
|
const puts = calls.filter((c) => c.url.includes("relation-tuples"));
|
||||||
assert.equal(puts.length, 2); // one grant per role
|
assert.equal(puts.length, 2); // one grant per permission
|
||||||
assert.ok(puts.every((p) => p.method === "PUT"));
|
assert.ok(puts.every((p) => p.method === "PUT"));
|
||||||
assert.deepEqual(puts.map((p) => p.body), [
|
assert.deepEqual(puts.map((p) => p.body), [
|
||||||
{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` },
|
{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` },
|
||||||
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `user:${id}` },
|
{ namespace: "Permission", object: "scheduling:read", relation: "granted", subject_id: `user:${id}` },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the role", async () => {
|
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the permission", async () => {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
let granted: unknown;
|
let granted: unknown;
|
||||||
const fetchImpl = (async (url, init) => {
|
const fetchImpl = (async (url, init) => {
|
||||||
@@ -90,11 +90,11 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
|
|||||||
ketoWriteUrl: "http://keto:4467",
|
ketoWriteUrl: "http://keto:4467",
|
||||||
kratosAdminUrl: "http://kratos:4434",
|
kratosAdminUrl: "http://kratos:4434",
|
||||||
password: "admin",
|
password: "admin",
|
||||||
roles: ["admin"],
|
permissions: ["admin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result, { created: false, id, roles: ["admin"] });
|
assert.deepEqual(result, { created: false, id, permissions: ["admin"] });
|
||||||
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` });
|
assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
||||||
@@ -106,7 +106,7 @@ test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
|||||||
ketoWriteUrl: "http://keto:4467",
|
ketoWriteUrl: "http://keto:4467",
|
||||||
kratosAdminUrl: "http://kratos:4434",
|
kratosAdminUrl: "http://kratos:4434",
|
||||||
password: "admin",
|
password: "admin",
|
||||||
roles: ["admin"],
|
permissions: ["admin"],
|
||||||
}),
|
}),
|
||||||
/Kratos/,
|
/Kratos/,
|
||||||
);
|
);
|
||||||
|
|||||||
+24
-24
@@ -2,8 +2,8 @@
|
|||||||
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
|
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
|
||||||
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
|
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
|
||||||
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
|
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
|
||||||
// 3. grant it its roles in Keto so menu/permission checks resolve out of the box — `admin` plus
|
// 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — `admin` plus
|
||||||
// every discovered plugin's declared permission tokens, so a dropped-in plugin is usable by
|
// every discovered plugin's declared permission names, so a dropped-in plugin is usable by
|
||||||
// the demo admin with no host config edit (the host stays plugin-agnostic).
|
// the demo admin with no host config edit (the host stays plugin-agnostic).
|
||||||
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
||||||
import { existsSync, writeFileSync } from "node:fs";
|
import { existsSync, writeFileSync } from "node:fs";
|
||||||
@@ -22,19 +22,19 @@ export function identityPayload(email: string, password: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coarse-role grant: `Role:<role>#members@user:<id>`. Subject ids are `user:<kratos-id>`
|
// Coarse-permission grant: `Permission:<permission>#members@user:<id>`. Subject ids are `user:<kratos-id>`
|
||||||
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles.
|
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT permissions.
|
||||||
export function roleTuple(identityId: string, role: string) {
|
export function permissionTuple(userId: string, permission: string) {
|
||||||
return { namespace: "Role", object: role, relation: "members", subject_id: `user:${identityId}` };
|
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
|
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`)
|
||||||
// unioned with every discovered plugin's declared permission tokens (a route/nav `permission` is a
|
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
|
||||||
// coarse role — granted as a Keto `Role:<token>#members` tuple). So the host names no plugin, yet a
|
// coarse permission — granted as a Keto `Permission:<token>#members` tuple). So the host names no plugin, yet a
|
||||||
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped.
|
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped.
|
||||||
export function seedRoles(adminRolesEnv: string | undefined, declaredTokens: string[]): string[] {
|
export function seedPermissions(adminRolesEnv: string | undefined, declaredPermissions: string[]): string[] {
|
||||||
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
||||||
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredTokens)])];
|
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredPermissions)])];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- JWKS safety net -----------------------------------------------------------------
|
// --- JWKS safety net -----------------------------------------------------------------
|
||||||
@@ -63,13 +63,13 @@ export interface SeedOptions {
|
|||||||
ketoWriteUrl: string;
|
ketoWriteUrl: string;
|
||||||
kratosAdminUrl: string;
|
kratosAdminUrl: string;
|
||||||
password: string;
|
password: string;
|
||||||
roles: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeedResult {
|
export interface SeedResult {
|
||||||
created: boolean;
|
created: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
roles: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
||||||
@@ -93,17 +93,17 @@ export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
|||||||
throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`);
|
throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grant each role in Keto. PUT is idempotent — re-running just re-asserts the tuple.
|
// Grant each permission in Keto. PUT is idempotent — re-running just re-asserts the tuple.
|
||||||
for (const role of opts.roles) {
|
for (const permission of opts.permissions) {
|
||||||
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
|
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
|
||||||
body: JSON.stringify(roleTuple(id, role)),
|
body: JSON.stringify(permissionTuple(id, permission)),
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
});
|
});
|
||||||
if (!grant.ok) throw new Error(`bootstrap: Keto grant role "${role}" failed (${grant.status}): ${await grant.text()}`);
|
if (!grant.ok) throw new Error(`bootstrap: Keto grant permission "${permission}" failed (${grant.status}): ${await grant.text()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created, id, roles: opts.roles };
|
return { created, id, permissions: opts.permissions };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
|
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
|
||||||
@@ -122,7 +122,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
|
|||||||
const rule = "─".repeat(58);
|
const rule = "─".repeat(58);
|
||||||
return [
|
return [
|
||||||
`┌${rule}`,
|
`┌${rule}`,
|
||||||
`│ Plainpages is ready — log in at ${opts.appUrl}`,
|
`│ Plainpages is ready — sign in at ${opts.appUrl}`,
|
||||||
`│ email: ${opts.email}`,
|
`│ email: ${opts.email}`,
|
||||||
`│ password: ${opts.password}`,
|
`│ password: ${opts.password}`,
|
||||||
`│ ⚠ Demo admin credentials — change them before production.`,
|
`│ ⚠ Demo admin credentials — change them before production.`,
|
||||||
@@ -143,10 +143,10 @@ async function main() {
|
|||||||
await runWithLog(log, async () => {
|
await runWithLog(log, async () => {
|
||||||
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
|
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
|
||||||
|
|
||||||
// Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared permission tokens, so the
|
// Seed `admin` (or ADMIN_PERMISSIONS) + every discovered plugin's declared permission names, so the
|
||||||
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
||||||
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.token));
|
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.name));
|
||||||
const roles = seedRoles(env["ADMIN_ROLES"], declared);
|
const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
|
||||||
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
||||||
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
||||||
const result = await seedAdmin({
|
const result = await seedAdmin({
|
||||||
@@ -155,9 +155,9 @@ async function main() {
|
|||||||
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
|
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
|
||||||
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
|
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
|
||||||
password,
|
password,
|
||||||
roles,
|
permissions,
|
||||||
});
|
});
|
||||||
log.info("admin seeded", { created: result.created, id: result.id, roles: result.roles.join(", ") });
|
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
|
||||||
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
|
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
|
||||||
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
|
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// Optional revocation denylist: instant role/session revoke without putting Keto
|
// Optional revocation denylist: instant permission/session revoke without putting Keto
|
||||||
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
|
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
|
||||||
//
|
//
|
||||||
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked role or a
|
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission or a
|
||||||
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
|
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
|
||||||
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
|
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
|
||||||
// account) that lag is too long. An admin action records the subject as revoked-now and the
|
// account) that lag is too long. An admin action records the subject as revoked-now and the
|
||||||
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
|
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
|
||||||
// re-reads roles from Keto, or clears a now-dead session).
|
// re-reads permissions from Keto, or clears a now-dead session).
|
||||||
//
|
//
|
||||||
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
|
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
|
||||||
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
|
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ test("maps a password login flow: csrf hidden, themed email/password fields, a s
|
|||||||
assert.equal(view.method, "post");
|
assert.equal(view.method, "post");
|
||||||
assert.deepEqual(view.hidden, [{ name: "csrf_token", value: "tok123" }]);
|
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.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]?.icon, "i-lock");
|
||||||
assert.equal(view.fields[1]?.type, "password");
|
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);
|
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(
|
const view = buildFlowView(
|
||||||
flow(
|
flow(
|
||||||
[
|
[
|
||||||
@@ -65,13 +67,15 @@ test("maps field errors and flow-level messages by tone", () => {
|
|||||||
"login",
|
"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.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, [
|
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" },
|
{ 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
|
// 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.
|
// 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";
|
import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
|
||||||
|
|
||||||
export interface FlowField {
|
export interface FlowField {
|
||||||
@@ -66,14 +68,39 @@ export const AUTH_FLOWS: Record<string, FlowType> = {
|
|||||||
"/verification": "verification",
|
"/verification": "verification",
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHROME: Record<FlowType, FlowChrome> = {
|
// Where each flow's card links; its words come from the catalog under `auth.<flow>.*`.
|
||||||
login: { alt: { href: "/registration", label: "Create one", text: "Don't have an account?" }, sub: "Welcome back. Enter your details to continue.", title: "Sign in" },
|
const LINKS: Record<FlowType, { alt?: string; back?: boolean }> = {
|
||||||
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" },
|
login: { alt: "/registration" },
|
||||||
registration: { alt: { href: "/login", label: "Sign in", text: "Already have an account?" }, sub: "Get started — it only takes a minute.", title: "Create account" },
|
recovery: { alt: "/login", back: true },
|
||||||
settings: { sub: "Update your account details.", title: "Account settings" },
|
registration: { alt: "/login" },
|
||||||
verification: { back: { href: "/login", label: "Back to sign in" }, sub: "Enter the code we sent you.", title: "Verify your email" },
|
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);
|
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
||||||
|
|
||||||
// Themed input icon by field semantics; undefined ⇒ no icon.
|
// 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();
|
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"]);
|
const value = str(node.attributes["value"]);
|
||||||
// The recovery/verification one-time code: numeric, and Kratos doesn't trim it, so a stray pasted
|
// 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
|
// 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");
|
const errorMsg = node.messages.find((m) => m.type === "error");
|
||||||
return {
|
return {
|
||||||
id: "field-" + name.replace(/[^a-z0-9]+/gi, "-"),
|
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,
|
name,
|
||||||
type,
|
type,
|
||||||
...(autocomplete ? { autocomplete } : {}),
|
...(autocomplete ? { autocomplete } : {}),
|
||||||
...(errorMsg ? { error: { text: errorMsg.text } } : {}),
|
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
|
||||||
...(icon ? { icon } : {}),
|
...(icon ? { icon } : {}),
|
||||||
...(isCode ? { inputmode: "numeric", pattern: "[0-9]*" } : {}),
|
...(isCode ? { inputmode: "numeric", pattern: "[0-9]*" } : {}),
|
||||||
...(node.attributes["required"] === true ? { required: true } : {}),
|
...(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 hidden: { name: string; value: string }[] = [];
|
||||||
const fields: FlowField[] = [];
|
const fields: FlowField[] = [];
|
||||||
const buttons: FlowButton[] = [];
|
const buttons: FlowButton[] = [];
|
||||||
@@ -136,9 +165,10 @@ export function buildFlowView(flow: Flow, type: FlowType): FlowView {
|
|||||||
hidden.push({ name, value: str(node.attributes["value"]) ?? "" });
|
hidden.push({ name, value: str(node.attributes["value"]) ?? "" });
|
||||||
} else if (inputType === "submit" || inputType === "button") {
|
} else if (inputType === "submit" || inputType === "button") {
|
||||||
const value = str(node.attributes["value"]);
|
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 {
|
} 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,
|
buttons,
|
||||||
fields,
|
fields,
|
||||||
hidden,
|
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",
|
method: flow.ui.method || "post",
|
||||||
sso,
|
sso,
|
||||||
...(type === "login" ? { recoverHref: "/recovery" } : {}),
|
...(type === "login" ? { recoverHref: "/recovery" } : {}),
|
||||||
...CHROME[type],
|
...chromeFor(type, t),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ test("rotateJwks --prune keeps only the newest (first) key, dropping superseded
|
|||||||
test("a JWS signed with a generated key verifies via our own verifier (reads what Kratos signs)", () => {
|
test("a JWS signed with a generated key verifies via our own verifier (reads what Kratos signs)", () => {
|
||||||
const key = generateJwks().keys[0]!;
|
const key = generateJwks().keys[0]!;
|
||||||
const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid }));
|
const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid }));
|
||||||
const body = b64url(JSON.stringify({ email: "a@b.c", roles: [], sub: key.kid }));
|
const body = b64url(JSON.stringify({ email: "a@b.c", permissions: [], sub: key.kid }));
|
||||||
const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { dsaEncoding: "ieee-p1363", key: createPrivateKey({ key: key as unknown as JsonWebKey, format: "jwk" }) });
|
const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { dsaEncoding: "ieee-p1363", key: createPrivateKey({ key: key as unknown as JsonWebKey, format: "jwk" }) });
|
||||||
const token = `${head}.${body}.${sig.toString("base64url")}`;
|
const token = `${head}.${body}.${sig.toString("base64url")}`;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function ctxFor(user: User | null, url = "/"): RequestContext {
|
|||||||
return buildContext(req, new ServerResponse(req), { user });
|
return buildContext(req, new ServerResponse(req), { user });
|
||||||
}
|
}
|
||||||
|
|
||||||
const alice: User = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] };
|
const alice: User = { email: "a@b.c", id: "u1", permissions: ["admin", "scheduling:read"] };
|
||||||
|
|
||||||
test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => {
|
test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => {
|
||||||
assert.equal(requireSession(ctxFor(alice)), alice);
|
assert.equal(requireSession(ctxFor(alice)), alice);
|
||||||
@@ -30,7 +30,7 @@ test("requireSession returns the user, or throws GuardError(401)→/login (prese
|
|||||||
err instanceof GuardError && err.location === "/login?return_to=%2Fscheduling%2Fshifts%3Fq%3D1");
|
err instanceof GuardError && err.location === "/login?return_to=%2Fscheduling%2Fshifts%3Fq%3D1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can reads a coarse role from the JWT claims; anonymous has none", () => {
|
test("can reads a coarse permission from the JWT claims; anonymous has none", () => {
|
||||||
assert.equal(can(ctxFor(alice), "admin"), true);
|
assert.equal(can(ctxFor(alice), "admin"), true);
|
||||||
assert.equal(can(ctxFor(alice), "billing:write"), false);
|
assert.equal(can(ctxFor(alice), "billing:write"), false);
|
||||||
assert.equal(can(ctxFor(null), "admin"), false);
|
assert.equal(can(ctxFor(null), "admin"), false);
|
||||||
|
|||||||
+3
-3
@@ -37,9 +37,9 @@ export function requireSession(ctx: RequestContext): User {
|
|||||||
return ctx.user;
|
return ctx.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coarse role check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
|
// Coarse permission check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
|
||||||
export function can(ctx: RequestContext, role: string): boolean {
|
export function can(ctx: RequestContext, permission: string): boolean {
|
||||||
return ctx.roles.includes(role);
|
return ctx.permissions.includes(permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live Keto relationship check at the point of action. The subject is the current user;
|
// Live Keto relationship check at the point of action. The subject is the current user;
|
||||||
|
|||||||
@@ -22,15 +22,17 @@ const jwk2: JsonWebKey = { ...(k2.publicKey.export({ format: "jwk" }) as JsonWeb
|
|||||||
const jwks = staticJwks([jwk1, jwk2]); // rotated set: two live keys
|
const jwks = staticJwks([jwk1, jwk2]); // rotated set: two live keys
|
||||||
|
|
||||||
const NOW = 1_700_000_000; // fixed clock for deterministic exp/nbf checks
|
const NOW = 1_700_000_000; // fixed clock for deterministic exp/nbf checks
|
||||||
const valid = { email: "a@b.c", exp: NOW + 600, roles: ["admin"], sub: "u1" };
|
const valid = { email: "a@b.c", exp: NOW + 600, permissions: ["admin"], sub: "u1" };
|
||||||
|
|
||||||
test("verifyToken: a valid token → User, selecting the verify key by kid across a rotated set", async () => {
|
test("verifyToken: a valid token → User, selecting the verify key by kid across a rotated set", async () => {
|
||||||
const user = await verifyToken(mint(k2.privateKey, "k2", valid), jwks, { now: NOW });
|
const user = await verifyToken(mint(k2.privateKey, "k2", valid), jwks, { now: NOW });
|
||||||
assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] });
|
assert.deepEqual(user, { email: "a@b.c", id: "u1", permissions: ["admin"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("verifyToken rejects expiry and future nbf, with clock-skew leeway", async () => {
|
test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => {
|
||||||
const opts = { clockSkewSec: 60, now: NOW };
|
const opts = { clockSkewSec: 60, now: NOW };
|
||||||
|
// No exp ⇒ rejected outright: an exp-less token must never read as eternal.
|
||||||
|
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: undefined }), jwks, opts), /missing exp/);
|
||||||
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 120 }), jwks, opts), /expired/);
|
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 120 }), jwks, opts), /expired/);
|
||||||
// exp 30s in the past but inside the 60s skew → still accepted.
|
// exp 30s in the past but inside the 60s skew → still accepted.
|
||||||
await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts);
|
await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts);
|
||||||
@@ -57,18 +59,18 @@ test("verifyToken rejects a bad signature and an unknown kid", async () => {
|
|||||||
await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/);
|
await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("claimsToUser requires sub + email, defaults roles to [], keeps only string roles", () => {
|
test("claimsToUser requires sub + email, defaults permissions to [], keeps only string permissions", () => {
|
||||||
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW }), /sub/);
|
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW }), /sub/);
|
||||||
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
|
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
|
||||||
assert.throws(() => claimsToUser({ exp: NOW, sub: "u" }), /email/);
|
assert.throws(() => claimsToUser({ exp: NOW, sub: "u" }), /email/);
|
||||||
assert.throws(() => claimsToUser({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
|
assert.throws(() => claimsToUser({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
|
||||||
assert.deepEqual(claimsToUser({ email: "a@b.c", sub: "u" }).roles, []); // roles absent
|
assert.deepEqual(claimsToUser({ email: "a@b.c", sub: "u" }).permissions, []); // permissions absent
|
||||||
assert.deepEqual(claimsToUser({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
|
assert.deepEqual(claimsToUser({ email: "a@b.c", permissions: ["a", 1, "b"], sub: "u" }).permissions, ["a", "b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolveSession classifies the cookie; authenticate is its fail-closed user projection", async () => {
|
test("resolveSession classifies the cookie; authenticate is its fail-closed user projection", async () => {
|
||||||
const cookie = (extra: Record<string, unknown> = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`;
|
const cookie = (extra: Record<string, unknown> = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`;
|
||||||
const user = { email: "a@b.c", id: "u1", roles: ["admin"] };
|
const user = { email: "a@b.c", id: "u1", permissions: ["admin"] };
|
||||||
|
|
||||||
// A valid token → the user, not expired.
|
// A valid token → the user, not expired.
|
||||||
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, user });
|
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, user });
|
||||||
@@ -94,6 +96,6 @@ test("verifyToken honours an optional denylist: a revoked subject's token reject
|
|||||||
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/);
|
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/);
|
||||||
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, user: null });
|
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, user: null });
|
||||||
// A token minted after the revoke (fresh login) is accepted; a different subject is untouched.
|
// A token minted after the revoke (fresh login) is accepted; a different subject is untouched.
|
||||||
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", roles: ["admin"] });
|
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", permissions: ["admin"] });
|
||||||
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
|
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,15 +59,15 @@ export function validateClaims(payload: Record<string, unknown>, options: Verify
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map verified claims → the request User. sub/email are required and non-empty (the tokenizer
|
// Map verified claims → the request User. sub/email are required and non-empty (the tokenizer
|
||||||
// always sets them; an empty email would read as anonymous in the shell); roles defaults to [] and
|
// always sets them; an empty email would read as anonymous in the shell); permissions defaults to [] and
|
||||||
// keeps only string entries (defensive).
|
// keeps only string entries (defensive).
|
||||||
export function claimsToUser(payload: Record<string, unknown>): User {
|
export function claimsToUser(payload: Record<string, unknown>): User {
|
||||||
const sub = payload["sub"];
|
const sub = payload["sub"];
|
||||||
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
|
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
|
||||||
const email = payload["email"];
|
const email = payload["email"];
|
||||||
if (typeof email !== "string" || email === "") throw new TokenError("token missing email");
|
if (typeof email !== "string" || email === "") throw new TokenError("token missing email");
|
||||||
const roles = payload["roles"];
|
const permissions = payload["permissions"];
|
||||||
return { email, id: sub, roles: Array.isArray(roles) ? roles.filter((r): r is string => typeof r === "string") : [] };
|
return { email, id: sub, permissions: Array.isArray(permissions) ? permissions.filter((r): r is string => typeof r === "string") : [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
|
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
|
||||||
@@ -80,7 +80,7 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
|
|||||||
validateClaims(verified.payload, options);
|
validateClaims(verified.payload, options);
|
||||||
const user = claimsToUser(verified.payload);
|
const user = claimsToUser(verified.payload);
|
||||||
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
|
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
|
||||||
// resolveSession routes it through the re-mint (fresh roles from Keto, or a cleared session).
|
// resolveSession routes it through the re-mint (fresh permissions from Keto, or a cleared session).
|
||||||
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
|
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ const rsaJwk = rsa.publicKey.export({ format: "jwk" }) as JsonWebKey;
|
|||||||
const ecJwk = ec.publicKey.export({ format: "jwk" }) as JsonWebKey;
|
const ecJwk = ec.publicKey.export({ format: "jwk" }) as JsonWebKey;
|
||||||
|
|
||||||
test("verifies an RS256 token, returning the decoded header + payload", () => {
|
test("verifies an RS256 token, returning the decoded header + payload", () => {
|
||||||
const token = makeJws("RS256", rsa.privateKey, { roles: ["admin"], sub: "u" });
|
const token = makeJws("RS256", rsa.privateKey, { permissions: ["admin"], sub: "u" });
|
||||||
const verified = verifyJws(token, rsaJwk);
|
const verified = verifyJws(token, rsaJwk);
|
||||||
assert.equal(verified.header.alg, "RS256");
|
assert.equal(verified.header.alg, "RS256");
|
||||||
assert.deepEqual(verified.payload, { roles: ["admin"], sub: "u" });
|
assert.deepEqual(verified.payload, { permissions: ["admin"], sub: "u" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("verifies an ES256 token (raw r‖s signature)", () => {
|
test("verifies an ES256 token (raw r‖s signature)", () => {
|
||||||
@@ -35,10 +35,10 @@ test("verifies an ES256 token (raw r‖s signature)", () => {
|
|||||||
|
|
||||||
// All three reach and fail the signature check itself, not an earlier structural guard.
|
// All three reach and fail the signature check itself, not an earlier structural guard.
|
||||||
test("rejects a signature that fails verification (tampered payload, wrong key, empty)", () => {
|
test("rejects a signature that fails verification (tampered payload, wrong key, empty)", () => {
|
||||||
const token = makeJws("RS256", rsa.privateKey, { roles: ["user"], sub: "u" });
|
const token = makeJws("RS256", rsa.privateKey, { permissions: ["user"], sub: "u" });
|
||||||
const [header, payload, signature] = token.split(".");
|
const [header, payload, signature] = token.split(".");
|
||||||
|
|
||||||
const forged = `${header}.${b64url(JSON.stringify({ roles: ["admin"], sub: "u" }))}.${signature}`;
|
const forged = `${header}.${b64url(JSON.stringify({ permissions: ["admin"], sub: "u" }))}.${signature}`;
|
||||||
assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/);
|
assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/);
|
||||||
|
|
||||||
const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey;
|
const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey;
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ const keto = (fetchImpl: typeof fetch) => createKetoClient({ fetchImpl, readUrl:
|
|||||||
|
|
||||||
test("check GETs the read API and returns the allowed boolean (true and false)", async () => {
|
test("check GETs the read API and returns the allowed boolean (true and false)", async () => {
|
||||||
const allow = recorder(() => res(200, { allowed: true }));
|
const allow = recorder(() => res(200, { allowed: true }));
|
||||||
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }), true);
|
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }), true);
|
||||||
assert.match(allow.calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/check\?/);
|
assert.match(allow.calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/check\?/);
|
||||||
assert.match(allow.calls[0]!.url, /namespace=Role&object=admin&relation=members/);
|
assert.match(allow.calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
|
||||||
assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`));
|
assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`));
|
||||||
// A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict.
|
// A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict.
|
||||||
const deny = recorder(() => res(403, { allowed: false }));
|
const deny = recorder(() => res(403, { allowed: false }));
|
||||||
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: "user:nobody" }), false);
|
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "user:nobody" }), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
|
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
|
||||||
@@ -51,20 +51,20 @@ test("check on a subject_set builds subject_set.* params and forwards max-depth"
|
|||||||
|
|
||||||
test("check throws a KetoError carrying the status on an unexpected response", async () => {
|
test("check throws a KetoError carrying the status on an unexpected response", async () => {
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }),
|
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }),
|
||||||
(e: unknown) => e instanceof KetoError && e.status === 400,
|
(e: unknown) => e instanceof KetoError && e.status === 400,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("listRelations builds the filter query + pagination and parses next_page_token", async () => {
|
test("listRelations builds the filter query + pagination and parses next_page_token", async () => {
|
||||||
const tuples = [{ namespace: "Role", object: "admin", relation: "members", subject_id: USER }];
|
const tuples = [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }];
|
||||||
const { calls, fetchImpl } = recorder(() => res(200, { next_page_token: "NEXT", relation_tuples: tuples }));
|
const { calls, fetchImpl } = recorder(() => res(200, { next_page_token: "NEXT", relation_tuples: tuples }));
|
||||||
const out = await keto(fetchImpl).listRelations({ namespace: "Role", object: "admin", pageSize: 10, pageToken: "CUR", relation: "members" });
|
const out = await keto(fetchImpl).listRelations({ namespace: "Permission", object: "admin", pageSize: 10, pageToken: "CUR", relation: "granted" });
|
||||||
assert.deepEqual(out.tuples, tuples);
|
assert.deepEqual(out.tuples, tuples);
|
||||||
assert.equal(out.nextPageToken, "NEXT");
|
assert.equal(out.nextPageToken, "NEXT");
|
||||||
const url = calls[0]!.url;
|
const url = calls[0]!.url;
|
||||||
assert.match(url, /^http:\/\/keto:4466\/relation-tuples\?/);
|
assert.match(url, /^http:\/\/keto:4466\/relation-tuples\?/);
|
||||||
assert.match(url, /namespace=Role&object=admin&relation=members/);
|
assert.match(url, /namespace=Permission&object=admin&relation=granted/);
|
||||||
assert.match(url, /page_size=10&page_token=CUR/);
|
assert.match(url, /page_size=10&page_token=CUR/);
|
||||||
// No Link header / token in the body ⇒ null, empty list ⇒ [].
|
// No Link header / token in the body ⇒ null, empty list ⇒ [].
|
||||||
const empty = await keto((async () => res(200, {})) as typeof fetch).listRelations();
|
const empty = await keto((async () => res(200, {})) as typeof fetch).listRelations();
|
||||||
@@ -72,16 +72,16 @@ test("listRelations builds the filter query + pagination and parses next_page_to
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("expand GETs the read API for a subject set and returns the tree (with max-depth)", async () => {
|
test("expand GETs the read API for a subject set and returns the tree (with max-depth)", async () => {
|
||||||
const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } }, type: "union" };
|
const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } }, type: "union" };
|
||||||
const { calls, fetchImpl } = recorder(() => res(200, tree));
|
const { calls, fetchImpl } = recorder(() => res(200, tree));
|
||||||
const out = await keto(fetchImpl).expand({ namespace: "Role", object: "admin", relation: "members" }, { maxDepth: 3 });
|
const out = await keto(fetchImpl).expand({ namespace: "Permission", object: "admin", relation: "granted" }, { maxDepth: 3 });
|
||||||
assert.deepEqual(out, tree);
|
assert.deepEqual(out, tree);
|
||||||
assert.match(calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/expand\?/);
|
assert.match(calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/expand\?/);
|
||||||
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members&max-depth=3/);
|
assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted&max-depth=3/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx throws)", async () => {
|
test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx throws)", async () => {
|
||||||
const tuple = { namespace: "Role", object: "admin", relation: "members", subject_id: USER };
|
const tuple = { namespace: "Permission", object: "admin", relation: "granted", subject_id: USER };
|
||||||
const { calls, fetchImpl } = recorder(() => res(201, tuple));
|
const { calls, fetchImpl } = recorder(() => res(201, tuple));
|
||||||
await keto(fetchImpl).writeTuple(tuple);
|
await keto(fetchImpl).writeTuple(tuple);
|
||||||
assert.equal(calls[0]!.method, "PUT");
|
assert.equal(calls[0]!.method, "PUT");
|
||||||
@@ -95,12 +95,12 @@ test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx th
|
|||||||
|
|
||||||
test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 throws)", async () => {
|
test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 throws)", async () => {
|
||||||
const { calls, fetchImpl } = recorder(() => res(204));
|
const { calls, fetchImpl } = recorder(() => res(204));
|
||||||
await keto(fetchImpl).deleteTuple({ namespace: "Role", object: "admin", relation: "members", subject_id: USER });
|
await keto(fetchImpl).deleteTuple({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER });
|
||||||
assert.equal(calls[0]!.method, "DELETE");
|
assert.equal(calls[0]!.method, "DELETE");
|
||||||
assert.match(calls[0]!.url, /^http:\/\/keto:4467\/admin\/relation-tuples\?/);
|
assert.match(calls[0]!.url, /^http:\/\/keto:4467\/admin\/relation-tuples\?/);
|
||||||
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members/);
|
assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Role", object: "x", relation: "members", subject_id: USER }),
|
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Permission", object: "x", relation: "granted", subject_id: USER }),
|
||||||
(e: unknown) => e instanceof KetoError && e.status === 404,
|
(e: unknown) => e instanceof KetoError && e.status === 404,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface SubjectSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A relationship tuple — the wire shape for writes and the filter shape for reads. Subject
|
// A relationship tuple — the wire shape for writes and the filter shape for reads. Subject
|
||||||
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's roleTuple.
|
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's permissionTuple.
|
||||||
export interface RelationTuple {
|
export interface RelationTuple {
|
||||||
namespace: string;
|
namespace: string;
|
||||||
object: string;
|
object: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints —
|
// Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints —
|
||||||
// identity CRUD + the surgical metadata_public update the login flow projects roles into.
|
// identity CRUD + the surgical metadata_public update the login flow projects permissions into.
|
||||||
// Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the
|
// Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the
|
||||||
// result mapping (201/200/404/4xx). Live wiring is verified by login completion.
|
// result mapping (201/200/404/4xx). Live wiring is verified by login completion.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
@@ -90,13 +90,13 @@ test("updateIdentity PUTs the full body to /admin/identities/<id> and returns th
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => {
|
test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => {
|
||||||
const identity = { id: ID, metadata_public: { roles: ["admin"] } };
|
const identity = { id: ID, metadata_public: { permissions: ["admin"] } };
|
||||||
const { calls, fetchImpl } = recorder(() => res(200, identity));
|
const { calls, fetchImpl } = recorder(() => res(200, identity));
|
||||||
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { roles: ["admin"] });
|
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { permissions: ["admin"] });
|
||||||
assert.deepEqual(out, identity);
|
assert.deepEqual(out, identity);
|
||||||
assert.equal(calls[0]!.method, "PATCH");
|
assert.equal(calls[0]!.method, "PATCH");
|
||||||
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
|
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
|
||||||
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { roles: ["admin"] } }]);
|
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { permissions: ["admin"] } }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
|
test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints
|
// Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints
|
||||||
// (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login
|
// (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login
|
||||||
// completion projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
|
// completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
|
||||||
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
|
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
|
||||||
import { KratosError } from "./kratos-public.ts";
|
import { KratosError } from "./kratos-public.ts";
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export interface RecoveryCode {
|
|||||||
|
|
||||||
export interface KratosAdmin {
|
export interface KratosAdmin {
|
||||||
createIdentity(payload: unknown): Promise<Identity>;
|
createIdentity(payload: unknown): Promise<Identity>;
|
||||||
createRecoveryCode(identityId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>;
|
createRecoveryCode(userId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>;
|
||||||
deleteIdentity(id: string): Promise<void>;
|
deleteIdentity(id: string): Promise<void>;
|
||||||
getIdentity(id: string): Promise<Identity | null>;
|
getIdentity(id: string): Promise<Identity | null>;
|
||||||
listIdentities(opts?: ListOptions): Promise<IdentityList>;
|
listIdentities(opts?: ListOptions): Promise<IdentityList>;
|
||||||
@@ -67,8 +67,8 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
|
|||||||
|
|
||||||
// Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the
|
// Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the
|
||||||
// user by Kratos; the code/link are also returned so an operator can hand them over directly.
|
// user by Kratos; the code/link are also returned so an operator can hand them over directly.
|
||||||
async createRecoveryCode(identityId, opts = {}) {
|
async createRecoveryCode(userId, opts = {}) {
|
||||||
const body: Record<string, unknown> = { identity_id: identityId };
|
const body: Record<string, unknown> = { identity_id: userId };
|
||||||
if (opts.expiresIn) body.expires_in = opts.expiresIn;
|
if (opts.expiresIn) body.expires_in = opts.expiresIn;
|
||||||
const res = await http(`${base}/admin/recovery/code`, { body: JSON.stringify(body), headers: json, method: "POST" });
|
const res = await http(`${base}/admin/recovery/code`, { body: JSON.stringify(body), headers: json, method: "POST" });
|
||||||
if (res.status !== 200 && res.status !== 201) return fail("create recovery code", res);
|
if (res.status !== 200 && res.status !== 201) return fail("create recovery code", res);
|
||||||
@@ -106,7 +106,7 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
|
|||||||
},
|
},
|
||||||
|
|
||||||
// JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and
|
// JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and
|
||||||
// touches nothing else — so the login role projection never clobbers traits/state.
|
// touches nothing else — so the login permission projection never clobbers traits/state.
|
||||||
// (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.)
|
// (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.)
|
||||||
async updateMetadataPublic(id, metadata) {
|
async updateMetadataPublic(id, metadata) {
|
||||||
const patch = [{ op: "add", path: "/metadata_public", value: metadata }];
|
const patch = [{ op: "add", path: "/metadata_public", value: metadata }];
|
||||||
|
|||||||
+20
-20
@@ -1,4 +1,4 @@
|
|||||||
// Login completion: turn a Kratos session into our session JWT — read roles from Keto,
|
// Login completion: turn a Kratos session into our session JWT — read permissions from Keto,
|
||||||
// project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients;
|
// project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients;
|
||||||
// the live, full-stack login is verified by the Playwright E2E.
|
// the live, full-stack login is verified by the Playwright E2E.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
@@ -6,10 +6,10 @@ import assert from "node:assert/strict";
|
|||||||
import type { KetoClient, RelationTuple } from "./keto-client.ts";
|
import type { KetoClient, RelationTuple } from "./keto-client.ts";
|
||||||
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
|
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
|
||||||
import type { KratosPublic, Session } from "./kratos-public.ts";
|
import type { KratosPublic, Session } from "./kratos-public.ts";
|
||||||
import { completeLogin, readRoles, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
|
import { completeLogin, readPermissions, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
|
||||||
|
|
||||||
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
||||||
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `user:${ID}` });
|
const permissionTuple = (object: string): RelationTuple => ({ namespace: "Permission", object, relation: "granted", subject_id: `user:${ID}` });
|
||||||
|
|
||||||
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
|
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
|
||||||
check: async () => false,
|
check: async () => false,
|
||||||
@@ -40,32 +40,32 @@ const publicStub = (over: Partial<KratosPublic> = {}): KratosPublic => ({
|
|||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
test("readRoles returns roles held directly OR transitively (enumerate defined roles → Keto-check each)", async () => {
|
test("readPermissions returns permissions held directly OR transitively (enumerate defined permissions → Keto-check each)", async () => {
|
||||||
const listQ: unknown[] = [];
|
const listQ: unknown[] = [];
|
||||||
const checked: string[] = [];
|
const checked: string[] = [];
|
||||||
const role = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Role", object, relation: "members", ...subject });
|
const permission = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Permission", object, relation: "granted", ...subject });
|
||||||
const keto = ketoStub({
|
const keto = ketoStub({
|
||||||
// Enumerate every Role tuple (paged, no subject filter) to find the distinct role names —
|
// Enumerate every Permission tuple (paged, no subject filter) to find the distinct permission names —
|
||||||
// subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
|
// subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
|
||||||
listRelations: async (q) => {
|
listRelations: async (q) => {
|
||||||
listQ.push(q);
|
listQ.push(q);
|
||||||
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "user:other" })] };
|
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [permission("editor", { subject_id: "user:other" })] };
|
||||||
return { nextPageToken: "p2", tuples: [
|
return { nextPageToken: "p2", tuples: [
|
||||||
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
|
permission("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
|
||||||
role("admin", { subject_id: `user:${ID}` }),
|
permission("admin", { subject_id: `user:${ID}` }),
|
||||||
role("viewer", { subject_id: "user:stranger" }),
|
permission("viewer", { subject_id: "user:stranger" }),
|
||||||
] };
|
] };
|
||||||
},
|
},
|
||||||
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
|
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
|
||||||
check: async (t) => { checked.push(t.object); return t.object === "admin" || t.object === "editor"; },
|
check: async (t) => { checked.push(t.object); return t.object === "admin" || t.object === "editor"; },
|
||||||
});
|
});
|
||||||
assert.deepEqual(await readRoles(keto, ID), ["admin", "editor"]);
|
assert.deepEqual(await readPermissions(keto, ID), ["admin", "editor"]);
|
||||||
assert.deepEqual(listQ[0], { namespace: "Role", relation: "members" }); // enumerate, not subject-filtered
|
assert.deepEqual(listQ[0], { namespace: "Permission", relation: "granted" }); // enumerate, not subject-filtered
|
||||||
assert.equal((listQ[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor
|
assert.equal((listQ[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor
|
||||||
assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct role checked for the user
|
assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct permission checked for the user
|
||||||
});
|
});
|
||||||
|
|
||||||
test("completeLogin: read roles → project onto metadata_public → tokenize → JWT (in that order)", async () => {
|
test("completeLogin: read permissions → project onto metadata_public → tokenize → JWT (in that order)", async () => {
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
let projected: unknown;
|
let projected: unknown;
|
||||||
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||||
@@ -76,11 +76,11 @@ test("completeLogin: read roles → project onto metadata_public → tokenize
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } });
|
const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } });
|
||||||
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
|
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
|
||||||
|
|
||||||
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
|
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
|
||||||
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", roles: ["admin"] });
|
assert.deepEqual(out, { email: "admin@plainpages.local", userId: ID, jwt: "h.p.s", permissions: ["admin"] });
|
||||||
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles, projected for the tokenizer
|
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions, projected for the tokenizer
|
||||||
assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize
|
assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,11 +101,11 @@ test("completeLogin maps a missing email trait to null and throws if the tokeniz
|
|||||||
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
|
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
|
||||||
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||||
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
|
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
|
||||||
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
|
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
|
||||||
|
|
||||||
// TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie.
|
// TTL lapsed but the Kratos session lives → re-read permissions from Keto, re-tokenize, fresh cookie.
|
||||||
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
|
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
|
||||||
assert.deepEqual(live.user, { email: "admin@plainpages.local", id: ID, roles: ["admin"] });
|
assert.deepEqual(live.user, { email: "admin@plainpages.local", id: ID, permissions: ["admin"] });
|
||||||
assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/);
|
assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/);
|
||||||
|
|
||||||
// Kratos session also gone → clear the stale JWT so the next request falls through to anonymous.
|
// Kratos session also gone → clear the stale JWT so the next request falls through to anonymous.
|
||||||
|
|||||||
+21
-21
@@ -1,9 +1,9 @@
|
|||||||
// Login completion: turn a fresh Kratos session into our locally-verifiable
|
// Login completion: turn a fresh Kratos session into our locally-verifiable
|
||||||
// session JWT — the one moment Ory is on the path (README: Login → session JWT):
|
// session JWT — the one moment Ory is on the path (README: Login → session JWT):
|
||||||
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
|
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
|
||||||
// 2. read roles from Keto → the source of truth for the `roles` claim
|
// 2. read permissions from Keto → the source of truth for the `permissions` claim
|
||||||
// 3. project onto metadata_public (admin API) so the tokenizer's mapper can read them
|
// 3. project onto metadata_public (admin API) so the tokenizer's mapper can read them
|
||||||
// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie
|
// 4. whoami(tokenize_as) → the signed JWT { sub, email, permissions }, stored as our cookie
|
||||||
// Order matters: the projection is written before tokenizing, because the claims mapper
|
// Order matters: the projection is written before tokenizing, because the claims mapper
|
||||||
// reads only the identity, never Keto.
|
// reads only the identity, never Keto.
|
||||||
import type { User } from "../http/context.ts";
|
import type { User } from "../http/context.ts";
|
||||||
@@ -32,46 +32,46 @@ export interface LoginDeps {
|
|||||||
|
|
||||||
export interface CompletedLogin {
|
export interface CompletedLogin {
|
||||||
email: string | null;
|
email: string | null;
|
||||||
identityId: string;
|
userId: string;
|
||||||
jwt: string;
|
jwt: string;
|
||||||
roles: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// The coarse roles a user holds — directly (`Role:<name>#members@user:<id>`) or transitively via a
|
// The coarse permissions a user holds — directly (`Permission:<name>#members@user:<id>`) or transitively via a
|
||||||
// group that is a member of the role. Enumerates the defined roles (the distinct objects in the Role
|
// group that is a member of the permission. Enumerates the defined permissions (the distinct objects in the Permission
|
||||||
// namespace) and asks Keto to resolve each membership, so a role granted to a group reaches the JWT —
|
// namespace) and asks Keto to resolve each membership, so a permission granted to a group reaches the JWT —
|
||||||
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
|
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
|
||||||
// request; role count is small, so the per-role checks are cheap and run in parallel.
|
// request; permission count is small, so the per-permission checks are cheap and run in parallel.
|
||||||
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
|
export async function readPermissions(keto: KetoClient, userId: string): Promise<string[]> {
|
||||||
const subject_id = `user:${identityId}`;
|
const subject_id = `user:${userId}`;
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
let pageToken: string | undefined;
|
let pageToken: string | undefined;
|
||||||
do {
|
do {
|
||||||
const page = await keto.listRelations({ namespace: "Role", relation: "members", ...(pageToken ? { pageToken } : {}) });
|
const page = await keto.listRelations({ namespace: "Permission", relation: "granted", ...(pageToken ? { pageToken } : {}) });
|
||||||
for (const t of page.tuples) names.add(t.object);
|
for (const t of page.tuples) names.add(t.object);
|
||||||
pageToken = page.nextPageToken ?? undefined;
|
pageToken = page.nextPageToken ?? undefined;
|
||||||
} while (pageToken);
|
} while (pageToken);
|
||||||
const roles = [...names];
|
const permissions = [...names];
|
||||||
const held = await Promise.all(roles.map((object) => keto.check({ namespace: "Role", object, relation: "members", subject_id })));
|
const held = await Promise.all(permissions.map((object) => keto.check({ namespace: "Permission", object, relation: "granted", subject_id })));
|
||||||
return roles.filter((_, i) => held[i]).sort();
|
return permissions.filter((_, i) => held[i]).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
|
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
|
||||||
const session = await deps.kratosPublic.whoami(cookie ? { cookie } : {});
|
const session = await deps.kratosPublic.whoami(cookie ? { cookie } : {});
|
||||||
if (!session?.identity) return null;
|
if (!session?.identity) return null;
|
||||||
const identityId = session.identity.id;
|
const userId = session.identity.id;
|
||||||
const emailTrait = session.identity.traits?.["email"];
|
const emailTrait = session.identity.traits?.["email"];
|
||||||
const email = typeof emailTrait === "string" ? emailTrait : null;
|
const email = typeof emailTrait === "string" ? emailTrait : null;
|
||||||
|
|
||||||
const roles = await readRoles(deps.keto, identityId);
|
const permissions = await readPermissions(deps.keto, userId);
|
||||||
await deps.kratosAdmin.updateMetadataPublic(identityId, { roles });
|
await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
|
||||||
|
|
||||||
const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS });
|
const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS });
|
||||||
const jwt = tokenized?.tokenized;
|
const jwt = tokenized?.tokenized;
|
||||||
if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT");
|
if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT");
|
||||||
|
|
||||||
currentLog()?.info("session minted", { roles: roles.join(","), sub: identityId }); // login or TTL re-mint
|
currentLog()?.info("session minted", { permissions: permissions.join(","), sub: userId }); // login or TTL re-mint
|
||||||
return { email, identityId, jwt, roles };
|
return { email, userId, jwt, permissions };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Reminted {
|
export interface Reminted {
|
||||||
@@ -80,14 +80,14 @@ export interface Reminted {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but
|
// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but
|
||||||
// the long-lived Kratos session may still be live. A live session ⇒ re-read roles from Keto,
|
// the long-lived Kratos session may still be live. A live session ⇒ re-read permissions from Keto,
|
||||||
// re-tokenize, fresh cookie + the refreshed user (the one moment authz recomputes). A dead
|
// re-tokenize, fresh cookie + the refreshed user (the one moment authz recomputes). A dead
|
||||||
// session ⇒ a cookie that *clears* the stale JWT, so later requests fall straight through to
|
// session ⇒ a cookie that *clears* the stale JWT, so later requests fall straight through to
|
||||||
// anonymous instead of re-hitting Ory on every one.
|
// anonymous instead of re-hitting Ory on every one.
|
||||||
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
|
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
|
||||||
const completed = await completeLogin(deps, cookie);
|
const completed = await completeLogin(deps, cookie);
|
||||||
if (!completed) return { setCookie: clearSessionCookie(options), user: null };
|
if (!completed) return { setCookie: clearSessionCookie(options), user: null };
|
||||||
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
|
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.userId, permissions: completed.permissions } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one
|
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one
|
||||||
// Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the
|
// Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the
|
||||||
// themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come
|
// themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come
|
||||||
// from the Kratos identity. OAuth2-provider role only — no first-party page needs this (README).
|
// from the Kratos identity. OAuth2-provider permission only — no first-party page needs this (README).
|
||||||
import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts";
|
import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts";
|
||||||
import type { KratosPublic } from "./kratos-public.ts";
|
import type { KratosPublic } from "./kratos-public.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We
|
// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We
|
||||||
// authenticate the user with their existing Kratos session and accept the request; Hydra then
|
// authenticate the user with their existing Kratos session and accept the request; Hydra then
|
||||||
// proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2
|
// proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2
|
||||||
// provider role only (README).
|
// provider permission only (README).
|
||||||
import type { HydraAdmin } from "./hydra-admin.ts";
|
import type { HydraAdmin } from "./hydra-admin.ts";
|
||||||
import type { KratosPublic } from "./kratos-public.ts";
|
import type { KratosPublic } from "./kratos-public.ts";
|
||||||
|
|
||||||
|
|||||||
+20
-13
@@ -7,6 +7,7 @@ import { readFormBody } from "../http/body.ts";
|
|||||||
import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts";
|
import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts";
|
||||||
import type { RequestContext } from "../http/context.ts";
|
import type { RequestContext } from "../http/context.ts";
|
||||||
import { CSRF_FIELD } from "./csrf.ts";
|
import { CSRF_FIELD } from "./csrf.ts";
|
||||||
|
import { chosenLocale } from "../i18n/locale.ts";
|
||||||
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
|
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
|
||||||
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
|
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
|
||||||
import type { KetoClient } from "./keto-client.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 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
|
// 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
|
// 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".
|
// as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com".
|
||||||
const raw = ctx.url.searchParams.get("return_to");
|
const raw = ctx.url.searchParams.get("return_to");
|
||||||
const local = localPath(raw);
|
const local = localPath(raw);
|
||||||
|
const chosen = chosenLocale(ctx);
|
||||||
let returnTo: string | undefined;
|
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`);
|
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();
|
returnTo = complete.toString();
|
||||||
} else if (raw) returnTo = raw;
|
} else if (raw) returnTo = raw;
|
||||||
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
|
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.
|
// documented, so render an honest 503 rather than the catch-all "error on our end" 500.
|
||||||
if (!(err instanceof KratosError) || err.status >= 500) {
|
if (!(err instanceof KratosError) || err.status >= 500) {
|
||||||
ctx.log.warn("auth flow failed (Ory unreachable?)", { error: String(err), path: pathname });
|
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)
|
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
|
// 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.
|
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
|
||||||
csrf.setCookie();
|
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
|
// 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.
|
// user-reachable: tell them to restart rather than 500. A 5xx (Hydra down) rethrows → 500.
|
||||||
if (err instanceof HydraError && err.status < 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;
|
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).
|
// 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) {
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -143,7 +150,7 @@ function consentScreen(deps: { hydra: HydraAdmin; kratos: KratosPublic }, brand:
|
|||||||
csrf.setCookie();
|
csrf.setCookie();
|
||||||
return { data: { brand, consent: view, csrfField: CSRF_FIELD, csrfToken: csrf.token }, view: "oauth-consent" };
|
return { data: { brand, consent: view, csrfField: CSRF_FIELD, csrfToken: csrf.token }, view: "oauth-consent" };
|
||||||
} catch (err) {
|
} 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);
|
: await rejectConsent(deps, challenge);
|
||||||
return { redirect };
|
return { redirect };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return consentError(err);
|
return consentError(err, ctx);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -184,7 +191,7 @@ function oauthLogout(hydra: HydraAdmin): BuiltinRoute["handler"] {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500.
|
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500.
|
||||||
if (err instanceof HydraError && err.status < 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;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -192,7 +199,7 @@ function oauthLogout(hydra: HydraAdmin): BuiltinRoute["handler"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Login completion: where Kratos lands the browser after authenticating (kratos.yml). Mint our
|
// Login completion: where Kratos lands the browser after authenticating (kratos.yml). Mint our
|
||||||
// session JWT — read roles from Keto, project onto the identity, tokenize — and store it as the
|
// session JWT — read permissions from Keto, project onto the identity, tokenize — and store it as the
|
||||||
// cookie; no active session bounces back to sign in.
|
// cookie; no active session bounces back to sign in.
|
||||||
function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] {
|
function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] {
|
||||||
return async (ctx: RequestContext): Promise<RouteResult> => {
|
return async (ctx: RequestContext): Promise<RouteResult> => {
|
||||||
@@ -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
|
// 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.
|
// honest fallback for any genuine flow error. The id is shown only for support reference.
|
||||||
const errorSink = (ctx: RequestContext): RouteResult =>
|
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[] {
|
export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }: AuthRouteDeps): BuiltinRoute[] {
|
||||||
const routes: BuiltinRoute[] = [];
|
const routes: BuiltinRoute[] = [];
|
||||||
|
|||||||
+2
-2
@@ -31,7 +31,7 @@ export interface Config {
|
|||||||
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
|
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
|
||||||
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
|
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
|
||||||
port: number;
|
port: number;
|
||||||
revocationDenylist: boolean; // enable the optional instant role/session revoke denylist
|
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
|
||||||
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
||||||
secureCookies: boolean;
|
secureCookies: boolean;
|
||||||
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
|
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
|
||||||
@@ -157,7 +157,7 @@ export function loadConfig(env: Env = process.env): Config {
|
|||||||
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
|
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
|
||||||
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
|
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
|
||||||
port: readPort(env),
|
port: readPort(env),
|
||||||
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or role
|
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
|
||||||
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
|
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
|
||||||
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
|
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
|
||||||
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
|
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
|
||||||
|
|||||||
+208
-70
@@ -22,6 +22,9 @@ import { SESSION_COOKIE } from "../auth/login.ts";
|
|||||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
|
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
|
||||||
import adminManifest from "../../examples/plugins/admin/plugin.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");
|
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
|
// The admin screens ship as a drop-in example plugin; the HTTP-level admin tests mount it via
|
||||||
@@ -40,9 +43,9 @@ function mintJwt(payload: Record<string, unknown>): string {
|
|||||||
const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`;
|
const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`;
|
||||||
return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`;
|
return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`;
|
||||||
}
|
}
|
||||||
// A session cookie carrying `roles`, valid for 10 min — the auth most tests need to reach a gated page.
|
// A session cookie carrying `permissions`, valid for 10 min — the auth most tests need to reach a gated page.
|
||||||
const session = (roles: string[] = []): string =>
|
const session = (permissions: string[] = []): string =>
|
||||||
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles, sub: "u1" })}`;
|
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions, sub: "u1" })}`;
|
||||||
|
|
||||||
const server = createApp({ jwks: staticJwks([ecJwk]) });
|
const server = createApp({ jwks: staticJwks([ecJwk]) });
|
||||||
let base = "";
|
let base = "";
|
||||||
@@ -83,7 +86,7 @@ test("/ is the public landing: anonymous → 200 with intro + sign-in/register l
|
|||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
assert.match(html, /href="\/login"/); // a prominent path to sign in
|
assert.match(html, /href="\/login"/); // a prominent path to sign in
|
||||||
assert.match(html, /href="\/registration"/); // and to register
|
assert.match(html, /href="\/registration"/); // and to register
|
||||||
// the same app shell every page renders — the menu shows even when signed out (role-filtered).
|
// the same app shell every page renders — the menu shows even when signed out (permission-filtered).
|
||||||
assert.match(html, /<aside class="sidebar"/);
|
assert.match(html, /<aside class="sidebar"/);
|
||||||
assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1>
|
assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1>
|
||||||
});
|
});
|
||||||
@@ -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 () => {
|
test("renders the 500 HTML page when a handler throws", async () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "pp-views-"));
|
const dir = mkdtempSync(join(tmpdir(), "pp-views-"));
|
||||||
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // the dashboard view
|
cpSync(viewsDir, dir, { recursive: true }); // the real views: 500.ejs includes the language picker
|
||||||
cpSync(join(viewsDir, "500.ejs"), join(dir, "500.ejs"));
|
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws
|
||||||
const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir });
|
const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir });
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve) => app.listen(0, resolve));
|
await new Promise<void>((resolve) => app.listen(0, resolve));
|
||||||
@@ -516,9 +519,9 @@ test("a plugin view renders the native chrome; its forms are CSRF-guarded via ct
|
|||||||
assert.equal(ok.status, 303);
|
assert.equal(ok.status, 303);
|
||||||
});
|
});
|
||||||
|
|
||||||
// JWT middleware: a verified session cookie populates ctx.user/roles, which the gate reads.
|
// JWT middleware: a verified session cookie populates ctx.user/permissions, which the gate reads.
|
||||||
// The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
|
// The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
|
||||||
test("a verified session JWT authorizes a role-gated route; no cookie / expired token → sign in", async (t) => {
|
test("a verified session JWT authorizes a permission-gated route; no cookie / expired token → sign in", async (t) => {
|
||||||
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
|
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
|
||||||
await new Promise<void>((r) => app.listen(0, r));
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
@@ -526,8 +529,8 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
|
|||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const secret = (cookie?: string) => fetch(url + "/demo/secret", { redirect: "manual", ...(cookie ? { headers: { cookie } } : {}) });
|
const secret = (cookie?: string) => fetch(url + "/demo/secret", { redirect: "manual", ...(cookie ? { headers: { cookie } } : {}) });
|
||||||
|
|
||||||
// Token carrying the gating role → the handler runs (200).
|
// Token carrying the gating permission → the handler runs (200).
|
||||||
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" })}`);
|
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" })}`);
|
||||||
assert.equal(ok.status, 200);
|
assert.equal(ok.status, 200);
|
||||||
assert.equal(await ok.text(), "secret");
|
assert.equal(await ok.text(), "secret");
|
||||||
|
|
||||||
@@ -536,12 +539,12 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
|
|||||||
const noCookie = await secret();
|
const noCookie = await secret();
|
||||||
assert.equal(noCookie.status, 303);
|
assert.equal(noCookie.status, 303);
|
||||||
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
||||||
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 303);
|
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}`)).status, 303);
|
||||||
|
|
||||||
// The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any
|
// The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any
|
||||||
// page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav
|
// page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav
|
||||||
// composition + role-filtering is covered in the admin-screen tests below.
|
// composition + permission-filtering is covered in the admin-screen tests below.
|
||||||
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
|
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["admin"], sub: "u1" })}` } });
|
||||||
assert.equal(dash.status, 200);
|
assert.equal(dash.status, 200);
|
||||||
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
|
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
|
||||||
assert.equal(anonDash.status, 303);
|
assert.equal(anonDash.status, 303);
|
||||||
@@ -555,7 +558,7 @@ test("revocation denylist: a revoked subject's token stops authorizing on the ho
|
|||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, roles: ["demo:read"], sub: "u1" })}` } });
|
const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, permissions: ["demo:read"], sub: "u1" })}` } });
|
||||||
|
|
||||||
assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes
|
assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes
|
||||||
|
|
||||||
@@ -567,10 +570,10 @@ test("revocation denylist: a revoked subject's token stops authorizing on the ho
|
|||||||
test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => {
|
test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => {
|
||||||
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
|
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" });
|
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" });
|
||||||
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
|
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
|
||||||
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "user:u1" }] }) });
|
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "user:u1" }] }) });
|
||||||
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
|
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
|
||||||
|
|
||||||
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
|
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
|
||||||
const app = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: live, kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
|
const app = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: live, kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
|
||||||
@@ -618,7 +621,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
|||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const auth = (roles: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles, sub: "u1" })}` } });
|
const auth = (permissions: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions, sub: "u1" })}` } });
|
||||||
|
|
||||||
// requireSession: anonymous bounces to /login (remembering the page); a signed-in user reaches the handler.
|
// requireSession: anonymous bounces to /login (remembering the page); a signed-in user reaches the handler.
|
||||||
const anon = await fetch(url + "/guarded/me", { redirect: "manual" });
|
const anon = await fetch(url + "/guarded/me", { redirect: "manual" });
|
||||||
@@ -628,7 +631,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
|||||||
assert.equal(me.status, 200);
|
assert.equal(me.status, 200);
|
||||||
assert.match(await me.text(), /hi a@b\.c/);
|
assert.match(await me.text(), /hi a@b\.c/);
|
||||||
|
|
||||||
// can: signed-in but lacking the role → 403 page; carrying it → 200.
|
// can: signed-in but lacking the permission → 403 page; carrying it → 200.
|
||||||
assert.equal((await fetch(url + "/guarded/admin-only", auth([]))).status, 403);
|
assert.equal((await fetch(url + "/guarded/admin-only", auth([]))).status, 403);
|
||||||
assert.equal((await fetch(url + "/guarded/admin-only", auth(["admin"]))).status, 200);
|
assert.equal((await fetch(url + "/guarded/admin-only", auth(["admin"]))).status, 200);
|
||||||
|
|
||||||
@@ -636,7 +639,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
|||||||
assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200);
|
assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200);
|
||||||
assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403);
|
assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403);
|
||||||
|
|
||||||
// declarative route `permission` gate: anonymous → sign in, signed-in-without-role → the 403 page, with → 200.
|
// declarative route `permission` gate: anonymous → sign in, signed-in-without-permission → the 403 page, with → 200.
|
||||||
const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" });
|
const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" });
|
||||||
assert.equal(gAnon.status, 303);
|
assert.equal(gAnon.status, 303);
|
||||||
assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated");
|
assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated");
|
||||||
@@ -717,7 +720,7 @@ test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a s
|
|||||||
assert.equal(stale.headers.get("location"), "/login");
|
assert.equal(stale.headers.get("location"), "/login");
|
||||||
|
|
||||||
// Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable.
|
// Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable.
|
||||||
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles: [], sub: "u1" })}` }, redirect: "manual" as const };
|
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions: [], sub: "u1" })}` }, redirect: "manual" as const };
|
||||||
for (const path of ["/login", "/registration"]) {
|
for (const path of ["/login", "/registration"]) {
|
||||||
const res = await fetch(url + path, signedIn);
|
const res = await fetch(url + path, signedIn);
|
||||||
assert.equal(res.status, 303, `${path} while signed in → 303`);
|
assert.equal(res.status, 303, `${path} while signed in → 303`);
|
||||||
@@ -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);
|
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.
|
// The flow-level error renders as an alert.
|
||||||
assert.match(html, /class="alert alert-neg"/);
|
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.
|
// Login completion: /auth/complete is where Kratos lands the browser after login.
|
||||||
@@ -856,23 +859,25 @@ const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}):
|
|||||||
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
|
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
|
||||||
|
|
||||||
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
|
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
|
||||||
// CSRF cookie. get(path, roles)/post(path, body) carry them; `token` is the matching CSRF field.
|
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
|
||||||
const ADMIN_CSRF = "admin-secret";
|
const ADMIN_CSRF = "admin-secret";
|
||||||
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
|
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));
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
const token = issueCsrfToken(ADMIN_CSRF);
|
const token = issueCsrfToken(ADMIN_CSRF);
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const cookie = (roles: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, roles, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
|
const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
|
||||||
const get = (path: string, roles: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(roles) }, redirect: "manual" });
|
const get = (path: string, permissions: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
|
||||||
const post = (path: string, body: string) =>
|
const post = (path: string, body: string) =>
|
||||||
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
|
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
|
||||||
return { get, post, token, url };
|
return { get, post, token, url };
|
||||||
}
|
}
|
||||||
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
|
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
|
||||||
async function assertAdminGate(url: string, get: (path: string, roles?: string[]) => Promise<Response>, path: string) {
|
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
|
||||||
const anon = await fetch(url + path, { redirect: "manual" });
|
const anon = await fetch(url + path, { redirect: "manual" });
|
||||||
assert.equal(anon.status, 303);
|
assert.equal(anon.status, 303);
|
||||||
assert.equal(anon.headers.get("location"), `/login?return_to=${encodeURIComponent(path)}`); // remembers the page
|
assert.equal(anon.headers.get("location"), `/login?return_to=${encodeURIComponent(path)}`); // remembers the page
|
||||||
@@ -884,7 +889,7 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
|
|||||||
let projected: unknown;
|
let projected: unknown;
|
||||||
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
|
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
|
||||||
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
|
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
|
||||||
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${identity.id}` }] }) });
|
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${identity.id}` }] }) });
|
||||||
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
|
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
|
||||||
await new Promise<void>((r) => app.listen(0, r));
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
@@ -892,12 +897,12 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
|
|||||||
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
|
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Live Kratos session: roles from Keto → projection → tokenize → JWT cookie, land on the dashboard.
|
// Live Kratos session: permissions from Keto → projection → tokenize → JWT cookie, land on the dashboard.
|
||||||
const ok = await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s");
|
const ok = await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s");
|
||||||
assert.equal(ok.status, 303);
|
assert.equal(ok.status, 303);
|
||||||
assert.equal(ok.headers.get("location"), "/dashboard");
|
assert.equal(ok.headers.get("location"), "/dashboard");
|
||||||
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
|
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
|
||||||
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles projected onto the identity for the tokenizer
|
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions projected onto the identity for the tokenizer
|
||||||
|
|
||||||
// return_to: a safe host-relative target lands the user back where they were headed; an
|
// return_to: a safe host-relative target lands the user back where they were headed; an
|
||||||
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
|
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
|
||||||
@@ -1164,7 +1169,7 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
|||||||
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
|
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
|
||||||
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
|
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
|
||||||
|
|
||||||
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/roles/clients.
|
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
|
||||||
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
|
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
|
||||||
assert.equal((await get("/admin/users/%ZZ")).status, 404);
|
assert.equal((await get("/admin/users/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
@@ -1225,7 +1230,7 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
|||||||
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Built-in Roles & permissions admin screen: gate + list/create/assign/revoke/delete over HTTP
|
// Built-in Roles admin screen: gate + list/create/assign/revoke/delete over HTTP
|
||||||
// against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the
|
// against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the
|
||||||
// effective-access view surfaces a user reachable only through a group.
|
// effective-access view surfaces a user reachable only through a group.
|
||||||
test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => {
|
test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => {
|
||||||
@@ -1235,10 +1240,10 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
|||||||
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
|
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
|
||||||
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
||||||
];
|
];
|
||||||
// grace is in the `eng` group; `editor` is an existing role whose only direct member is ada.
|
// grace is in the `eng` group; `editor` is an existing permission whose only direct member is ada.
|
||||||
const tuples: RelationTuple[] = [
|
const tuples: RelationTuple[] = [
|
||||||
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` },
|
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` },
|
||||||
{ namespace: "Role", object: "editor", relation: "members", subject_id: `user:${ada}` },
|
{ namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${ada}` },
|
||||||
];
|
];
|
||||||
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
|
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
|
||||||
const expandSet = (set: SubjectSet): ExpandTree => ({
|
const expandSet = (set: SubjectSet): ExpandTree => ({
|
||||||
@@ -1250,70 +1255,70 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
|||||||
});
|
});
|
||||||
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
|
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
|
||||||
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const denylist = createDenylist(); // granting/revoking a *user's* role revokes their live tokens (a group change is transitive → left to lag)
|
const denylist = createDenylist(); // granting/revoking a *user's* permission revokes their live tokens (a group change is transitive → left to lag)
|
||||||
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||||
|
|
||||||
await assertAdminGate(url, get, "/admin/roles");
|
await assertAdminGate(url, get, "/admin/permissions");
|
||||||
|
|
||||||
// List: the existing role shows + the "add" link.
|
// List: the existing permission shows + the "add" link.
|
||||||
const listHtml = await (await get("/admin/roles")).text();
|
const listHtml = await (await get("/admin/permissions")).text();
|
||||||
assert.match(listHtml, /href="\/admin\/roles\/editor"/);
|
assert.match(listHtml, /href="\/admin\/permissions\/editor"/);
|
||||||
assert.match(listHtml, /href="\/admin\/roles\/new"/);
|
assert.match(listHtml, /href="\/admin\/permissions\/new"/);
|
||||||
|
|
||||||
// Create: a valid post writes the first-member tuple and redirects to the detail.
|
// Create: a valid post writes the first-member tuple and redirects to the detail.
|
||||||
assert.match(await (await get("/admin/roles/new")).text(), /Create role/);
|
assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/);
|
||||||
const created = await post("/admin/roles", `_csrf=${token}&name=viewer&member=user:${ada}`);
|
const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=user:${ada}`);
|
||||||
assert.equal(created.status, 303);
|
assert.equal(created.status, 303);
|
||||||
assert.equal(created.headers.get("location"), "/admin/roles/viewer");
|
assert.equal(created.headers.get("location"), "/admin/permissions/viewer");
|
||||||
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "viewer" && tp.subject_id === `user:${ada}`));
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "viewer" && tp.subject_id === `user:${ada}`));
|
||||||
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a role to a user revokes their stale token so the grant lands now
|
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a permission to a user revokes their stale token so the grant lands now
|
||||||
|
|
||||||
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
||||||
const before = tuples.length;
|
const before = tuples.length;
|
||||||
assert.equal((await post("/admin/roles", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400);
|
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400);
|
||||||
assert.equal((await post("/admin/roles", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists
|
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists
|
||||||
assert.equal((await post("/admin/roles", `name=x&member=user:${ada}`)).status, 403);
|
assert.equal((await post("/admin/permissions", `name=x&member=user:${ada}`)).status, 403);
|
||||||
assert.equal(tuples.length, before);
|
assert.equal(tuples.length, before);
|
||||||
|
|
||||||
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
|
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
|
||||||
// yet — though grace appears elsewhere as an assignable candidate, so target the effective <li>.
|
// yet — though grace appears elsewhere as an assignable candidate, so target the effective <li>.
|
||||||
const effectiveLi = (email: string) => new RegExp(`<li><span class="cell-strong">${email.replace(".", "\\.")}`);
|
const effectiveLi = (email: string) => new RegExp(`<li><span class="cell-strong">${email.replace(".", "\\.")}`);
|
||||||
const detail = await (await get("/admin/roles/editor")).text();
|
const detail = await (await get("/admin/permissions/editor")).text();
|
||||||
assert.match(detail, effectiveLi("ada@example.com"));
|
assert.match(detail, effectiveLi("ada@example.com"));
|
||||||
assert.doesNotMatch(detail, effectiveLi("grace@example.com"));
|
assert.doesNotMatch(detail, effectiveLi("grace@example.com"));
|
||||||
|
|
||||||
// Assign the `eng` group to the role → grace now holds it transitively (effective access via expand).
|
// Assign the `eng` group to the permission → grace now holds it transitively (effective access via expand).
|
||||||
await post("/admin/roles/editor/members", `_csrf=${token}&member=group:eng`);
|
await post("/admin/permissions/editor/members", `_csrf=${token}&member=group:eng`);
|
||||||
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
||||||
const withGroup = await (await get("/admin/roles/editor")).text();
|
const withGroup = await (await get("/admin/permissions/editor")).text();
|
||||||
assert.match(withGroup, effectiveLi("grace@example.com"));
|
assert.match(withGroup, effectiveLi("grace@example.com"));
|
||||||
|
|
||||||
// Revoke the group membership.
|
// Revoke the group membership.
|
||||||
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`);
|
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=group:eng`);
|
||||||
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
||||||
|
|
||||||
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
|
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
|
||||||
await post("/admin/roles/editor/members", `_csrf=${token}&member=user:${grace}`);
|
await post("/admin/permissions/editor/members", `_csrf=${token}&member=user:${grace}`);
|
||||||
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
|
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
|
||||||
assert.equal(denylist.isRevoked(grace, 0), true);
|
assert.equal(denylist.isRevoked(grace, 0), true);
|
||||||
|
|
||||||
// Delete the role: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
// Delete the permission: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||||
assert.match(await (await get("/admin/roles/editor/delete")).text(), /Cancel/);
|
assert.match(await (await get("/admin/permissions/editor/delete")).text(), /Cancel/);
|
||||||
const del = await post("/admin/roles/editor/delete", `_csrf=${token}`);
|
const del = await post("/admin/permissions/editor/delete", `_csrf=${token}`);
|
||||||
assert.equal(del.status, 303);
|
assert.equal(del.status, 303);
|
||||||
assert.equal(del.headers.get("location"), "/admin/roles");
|
assert.equal(del.headers.get("location"), "/admin/permissions");
|
||||||
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor"));
|
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor"));
|
||||||
|
|
||||||
// Self-protection: the admin role can't be deleted, nor can you revoke your own admin (sub admin1).
|
// Self-protection: the admin permission can't be deleted, nor can you revoke your own admin (sub admin1).
|
||||||
tuples.push({ namespace: "Role", object: "admin", relation: "members", subject_id: "user:admin1" });
|
tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "user:admin1" });
|
||||||
assert.equal((await post("/admin/roles/admin/delete", `_csrf=${token}`)).status, 400);
|
assert.equal((await post("/admin/permissions/admin/delete", `_csrf=${token}`)).status, 400);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "admin"));
|
assert.ok(tuples.some((tp) => tp.object === "admin"));
|
||||||
assert.equal((await post("/admin/roles/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
|
assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1"));
|
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1"));
|
||||||
|
|
||||||
// An invalid role name in the path → 404; malformed %-encoding doesn't 500.
|
// An invalid permission name in the path → 404; malformed %-encoding doesn't 500.
|
||||||
assert.equal((await get("/admin/roles/Bad%20Name")).status, 404);
|
assert.equal((await get("/admin/permissions/Bad%20Name")).status, 404);
|
||||||
assert.equal((await get("/admin/roles/%ZZ")).status, 404);
|
assert.equal((await get("/admin/permissions/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
|
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
|
||||||
@@ -1343,7 +1348,7 @@ test("admin OAuth2 clients screen: gate, list, register (one-time secret), detai
|
|||||||
// client and shows the one-time secret + id.
|
// client and shows the one-time secret + id.
|
||||||
const formHtml = await (await get("/admin/clients/new")).text();
|
const formHtml = await (await get("/admin/clients/new")).text();
|
||||||
assert.match(formHtml, /Register client/);
|
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`);
|
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
|
assert.equal(created.status, 200); // not a redirect — the secret is shown once
|
||||||
const createdHtml = await created.text();
|
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("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
|
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"/);
|
||||||
|
});
|
||||||
|
|||||||
+131
-32
@@ -3,7 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import ejs from "ejs";
|
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 { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
||||||
import { buildContext, type RequestContext, type User } from "./context.ts";
|
import { buildContext, type RequestContext, type User } from "./context.ts";
|
||||||
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.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 { buildDashboardModel } from "../ui/dashboard.ts";
|
||||||
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
|
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
|
||||||
import { GuardError, loginRedirect } from "../auth/guards.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 { runRequestHooks, runResponseHooks } from "../plugin-host/hooks.ts";
|
||||||
import type { HydraAdmin } from "../auth/hydra-admin.ts";
|
import type { HydraAdmin } from "../auth/hydra-admin.ts";
|
||||||
import type { JwksProvider } from "../auth/jwks.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 { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
||||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||||
import { securityHeaders } from "./security-headers.ts";
|
import { securityHeaders } from "./security-headers.ts";
|
||||||
|
import { localPath } from "./safe-url.ts";
|
||||||
import { routePublic, serveStatic } from "./static.ts";
|
import { routePublic, serveStatic } from "./static.ts";
|
||||||
import { renderPluginView } from "../plugin-host/view-resolver.ts";
|
import { renderPluginView } from "../plugin-host/view-resolver.ts";
|
||||||
|
|
||||||
@@ -40,7 +45,10 @@ export interface AppOptions {
|
|||||||
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
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
|
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
|
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
|
||||||
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles; absent ⇒ always anonymous
|
// 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
|
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
|
||||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
|
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
|
||||||
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
|
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
|
||||||
@@ -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 csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
|
||||||
const secureCookies = options.secureCookies ?? false;
|
const secureCookies = options.secureCookies ?? false;
|
||||||
const hydra = options.hydra;
|
const hydra = options.hydra;
|
||||||
|
const i18n = options.i18n ?? ENGLISH_I18N;
|
||||||
const jwks = options.jwks;
|
const jwks = options.jwks;
|
||||||
const keto = options.keto;
|
const keto = options.keto;
|
||||||
const kratos = options.kratos;
|
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.
|
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
|
||||||
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
|
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 => {
|
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
||||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||||
res.end(html);
|
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
|
// (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
|
// 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).
|
// (`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();
|
csrf.setCookie();
|
||||||
if (homePlugin) {
|
if (homePlugin) {
|
||||||
const result = (await homePlugin.home(ctx)) ?? null;
|
// The plugin owns this page, so it runs on its own context — its catalog first, then core.
|
||||||
if (anyResponseHooks) await runResponseHooks(plugins, ctx, result);
|
const pluginCtx = contextFor(homePlugin.id);
|
||||||
await sendResult(ctx.res, result, (view, data) => renderView(homePlugin.id, view, data));
|
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 null;
|
||||||
}
|
}
|
||||||
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
|
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
|
// 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
|
// 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.
|
// 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 };
|
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.
|
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
||||||
csrf.setCookie();
|
csrf.setCookie();
|
||||||
if (dashboardPlugin) {
|
if (dashboardPlugin) {
|
||||||
const result = (await dashboardPlugin.dashboard(ctx)) ?? null;
|
const pluginCtx = contextFor(dashboardPlugin.id); // as serveHome: the owner's own translator
|
||||||
if (anyResponseHooks) await runResponseHooks(plugins, ctx, result);
|
const result = (await dashboardPlugin.dashboard(pluginCtx)) ?? null;
|
||||||
await sendResult(ctx.res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
|
if (anyResponseHooks) await runResponseHooks(plugins, contextFor, result);
|
||||||
|
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, nav: ctx.chrome.nav, user: ctx.user }) }, 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/
|
// 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
|
// 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.
|
// and correlation with no logger threaded through their signatures.
|
||||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
|
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 {
|
try {
|
||||||
const method = req.method ?? "GET";
|
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
|
// 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).
|
// (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;
|
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
|
// 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
|
// 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 —
|
// the browser, the themed forms, and the cross-origin Kratos POST all share one cookie host —
|
||||||
@@ -186,9 +239,16 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the session JWT once (cached JWKS) → ctx.user/roles; none/invalid ⇒ anonymous.
|
// 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
|
// 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 roles from Keto, re-tokenize,
|
// clients), silently re-mint it — "stay signed in": re-read permissions from Keto, re-tokenize,
|
||||||
// and set the fresh cookie via setHeader so it rides whatever response this request produces
|
// and set the fresh cookie via setHeader so it rides whatever response this request produces
|
||||||
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
|
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
|
||||||
let user: User | null = null;
|
let user: User | null = null;
|
||||||
@@ -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,
|
// 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).
|
// or the public "/" with a standalone home, never composes the menu).
|
||||||
let chromeMemo: PageChrome | undefined;
|
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.
|
// The i18n half of every context: the locale, its translator, and the link carrier. A plugin
|
||||||
const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
|
// 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.
|
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||||
if (anyRequestHooks) {
|
if (anyRequestHooks) {
|
||||||
const short = await runRequestHooks(plugins, ctx);
|
const short = await runRequestHooks(plugins, contextFor);
|
||||||
if (short) {
|
if (short) {
|
||||||
// Set the fresh CSRF cookie like every other page-emitting path, so a form the hook
|
// 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.
|
// renders (its token is in ctx.chrome.csrfToken) has the matching double-submit cookie.
|
||||||
csrfMint.setCookie();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,19 +319,21 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// CSRF cookie is set so those forms have a valid double-submit token.
|
// CSRF cookie is set so those forms have a valid double-submit token.
|
||||||
const match = matchRoute(plugins, method, pathname);
|
const match = matchRoute(plugins, method, pathname);
|
||||||
if (match) {
|
if (match) {
|
||||||
const routeCtx = buildContext(req, res, { chrome, log: reqLog, params: match.params, user, verifyCsrf, ...(system ? { system } : {}) });
|
const routeCtx = contextFor(match.plugin.id, match.params);
|
||||||
if (!isAuthorized(match.route, routeCtx.roles)) {
|
if (!isAuthorized(match.route, routeCtx.permissions)) {
|
||||||
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
|
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
|
||||||
// return_to; a signed-in user who simply lacks the role gets the 403 page.
|
// 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; }
|
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
||||||
reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
csrfMint.setCookie();
|
csrfMint.setCookie();
|
||||||
const result = (await match.route.handler(routeCtx)) ?? null;
|
const result = (await match.route.handler(routeCtx)) ?? null;
|
||||||
if (anyResponseHooks) await runResponseHooks(plugins, routeCtx, result); // observers; a throw → 500
|
// The responding plugin observes its own route, params and all; the others get a plain
|
||||||
await sendResult(res, result, (view, data) => renderView(match.plugin.id, view, data));
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +342,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// null means the handler wrote to ctx.res itself.
|
// null means the handler wrote to ctx.res itself.
|
||||||
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
||||||
if (builtin) {
|
if (builtin) {
|
||||||
await sendResult(res, await builtin.handler(ctx, csrfMint), render);
|
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
||||||
return;
|
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");
|
res.writeHead(405, { allow: allow.join(", "), "content-type": "text/plain; charset=utf-8" }).end("Method Not Allowed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendHtml(res, 404, await render("404", { title: "Not found" }));
|
sendHtml(res, 404, await renderPage("404", {}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A guard thrown anywhere in handling maps to a response (not a 500): a `location` ⇒ a
|
// 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.
|
// redirect (requireSession → /login), otherwise the status renders the error page.
|
||||||
if (err instanceof GuardError) {
|
if (err instanceof GuardError) {
|
||||||
if (res.headersSent) return void res.end();
|
if (res.headersSent) return void res.end();
|
||||||
if (err.location) return void res.writeHead(303, { location: err.location }).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) });
|
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
|
if (res.headersSent) return void res.end(); // a partial body is already on the wire
|
||||||
try {
|
try {
|
||||||
// Render before writing: if the 500 page itself throws, headers stay unsent
|
// 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.
|
// 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) {
|
} catch (renderErr) {
|
||||||
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(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");
|
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>;
|
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`
|
// 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.
|
// 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 (result == null || res.writableEnded) return;
|
||||||
if ("redirect" in result) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if ("json" in result) {
|
if ("json" in result) {
|
||||||
|
|||||||
@@ -14,10 +14,15 @@ export interface RequestCsrf {
|
|||||||
token: string;
|
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 {
|
export interface BuiltinRoute {
|
||||||
// Returns a RouteResult, or null when the handler wrote to ctx.res itself
|
// 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).
|
// (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
|
method: "GET" | "POST"; // a GET route also answers HEAD, like plugin routes
|
||||||
path: string; // exact pathname
|
path: string; // exact pathname
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ test("buildContext parses the URL, exposes query, and defaults to an anonymous u
|
|||||||
assert.equal(ctx.query.get("q"), "ann");
|
assert.equal(ctx.query.get("q"), "ann");
|
||||||
assert.equal(ctx.query.get("page"), "2");
|
assert.equal(ctx.query.get("page"), "2");
|
||||||
assert.equal(ctx.user, null);
|
assert.equal(ctx.user, null);
|
||||||
assert.deepEqual(ctx.roles, []);
|
assert.deepEqual(ctx.permissions, []);
|
||||||
assert.deepEqual(ctx.params, {});
|
assert.deepEqual(ctx.params, {});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,12 +33,12 @@ test("buildContext threads path params supplied by the router", () => {
|
|||||||
assert.equal(ctx.params.id, "42");
|
assert.equal(ctx.params.id, "42");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildContext threads the user and derives roles from it", () => {
|
test("buildContext threads the user and derives permissions from it", () => {
|
||||||
const { req, res } = reqRes("/");
|
const { req, res } = reqRes("/");
|
||||||
const user: User = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] };
|
const user: User = { email: "a@b.c", id: "u1", permissions: ["admin", "editor"] };
|
||||||
const ctx = buildContext(req, res, { user });
|
const ctx = buildContext(req, res, { user });
|
||||||
assert.equal(ctx.user, user);
|
assert.equal(ctx.user, user);
|
||||||
assert.equal(ctx.roles, user.roles); // same reference, never a divergent copy — buildContext is the only writer
|
assert.equal(ctx.permissions, user.permissions); // same reference, never a divergent copy — buildContext is the only writer
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildContext defaults a missing request URL to /", () => {
|
test("buildContext defaults a missing request URL to /", () => {
|
||||||
|
|||||||
+34
-8
@@ -1,38 +1,56 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
|
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 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";
|
import { createLogger, type Log } from "../logger.ts";
|
||||||
|
|
||||||
// The request context threaded to every route handler (plugin + built-in), built once
|
// The request context threaded to every route handler (plugin + built-in), built once
|
||||||
// per request by `buildContext`: the router supplies matched path `params`, the JWT
|
// per request by `buildContext`: the router supplies matched path `params`, the JWT
|
||||||
// middleware supplies `user` (null until then). The host's single handler argument.
|
// middleware supplies `user` (null until then). The host's single handler argument.
|
||||||
|
|
||||||
// The authenticated user, projected from verified session JWT claims:
|
// The signed-in user, projected from verified session JWT claims. Ory calls this record an
|
||||||
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
|
// "identity" (see README); Plainpages says user throughout.
|
||||||
|
// `id` = `sub`, plus `email` and the coarse `permissions` carried in the token.
|
||||||
export interface User {
|
export interface User {
|
||||||
email: string;
|
email: string;
|
||||||
id: string;
|
id: string;
|
||||||
roles: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RequestContext {
|
export interface RequestContext {
|
||||||
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
|
// 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).
|
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
|
||||||
chrome: PageChrome;
|
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
|
// 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
|
// 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.
|
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
|
||||||
log: Log;
|
log: Log;
|
||||||
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
||||||
|
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
|
||||||
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
||||||
req: IncomingMessage;
|
req: IncomingMessage;
|
||||||
res: ServerResponse;
|
res: ServerResponse;
|
||||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
|
||||||
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
|
// 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.
|
// unless the host wired them; every field optional. Ordinary domain plugins ignore it.
|
||||||
system?: SystemCapabilities;
|
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;
|
url: URL;
|
||||||
user: User | null;
|
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
|
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
|
||||||
// cookie (double-submit). The host binds the secret; a plugin calls it after reading its body.
|
// cookie (double-submit). The host binds the secret; a plugin calls it after reading its body.
|
||||||
verifyCsrf(submitted: string | null | undefined): boolean;
|
verifyCsrf(submitted: string | null | undefined): boolean;
|
||||||
@@ -43,10 +61,14 @@ export interface BuildContextOptions {
|
|||||||
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
|
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
|
||||||
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
||||||
chrome?: () => PageChrome;
|
chrome?: () => PageChrome;
|
||||||
|
user?: User | null;
|
||||||
|
locale?: string;
|
||||||
|
localeHref?: (href: string) => string;
|
||||||
|
locales?: string[];
|
||||||
log?: Log;
|
log?: Log;
|
||||||
params?: Record<string, string>;
|
params?: Record<string, string>;
|
||||||
system?: SystemCapabilities;
|
system?: SystemCapabilities;
|
||||||
user?: User | null;
|
t?: Translate;
|
||||||
verifyCsrf?: (submitted: string | null | undefined) => boolean;
|
verifyCsrf?: (submitted: string | null | undefined) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,15 +89,19 @@ export function buildContext(
|
|||||||
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
||||||
return {
|
return {
|
||||||
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
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,
|
log: options.log ?? SILENT_LOG,
|
||||||
params: options.params ?? {},
|
params: options.params ?? {},
|
||||||
query: url.searchParams,
|
query: url.searchParams,
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
roles: user?.roles ?? [],
|
permissions: user?.permissions ?? [],
|
||||||
...(options.system ? { system: options.system } : {}),
|
...(options.system ? { system: options.system } : {}),
|
||||||
|
t: options.t ?? ENGLISH,
|
||||||
url,
|
url,
|
||||||
user,
|
|
||||||
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
|
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,178 @@
|
|||||||
|
// 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": "Create account",
|
||||||
|
"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": "Sign 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 sign-out 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.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,160 @@
|
|||||||
|
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.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"));
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user