Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f0956dd58 | |||
| edcd9fefc8 | |||
| 1787754781 | |||
| 765f349007 | |||
| 29d654c012 | |||
| fb4382be9d | |||
| 90cbc47607 | |||
| 27fee5f8a3 | |||
| 9412b90946 | |||
| 225569b08a | |||
| 32154fbbc0 | |||
| 690d67728b | |||
| e808f87fbd | |||
| e5bdc15262 | |||
| 2fb5e695e1 | |||
| 3ebd1fa507 | |||
| 81043edfc6 | |||
| 6df90f3746 | |||
| b1b01f8bdc | |||
| c54f5e2c51 | |||
| 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,10 +9,13 @@ 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
|
||||||
|
fetch-tags: true # load-bearing for --prune below: no local tags would delete every remote one
|
||||||
|
# --prune so a tag deleted here doesn't live on at GitHub forever. It only removes refs a
|
||||||
|
# refspec DESTINATION matches — so tags; main is a non-glob dst, other branches match nothing.
|
||||||
- run: |
|
- run: |
|
||||||
git push --force \
|
git push --force --prune \
|
||||||
"https://x-access-token:${{ secrets.MIRROR_GITHUB_TOKEN }}@github.com/larvit/plainpages.git" \
|
"https://x-access-token:${{ secrets.MIRROR_GITHUB_TOKEN }}@github.com/larvit/plainpages.git" \
|
||||||
refs/remotes/origin/main:refs/heads/main 'refs/tags/*:refs/tags/*'
|
refs/remotes/origin/main:refs/heads/main 'refs/tags/*:refs/tags/*'
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -30,11 +30,14 @@ jobs:
|
|||||||
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
|
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
|
||||||
# pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the
|
# pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the
|
||||||
# already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
|
# already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
|
||||||
|
# Off until the Actions variable AUTO_RELEASE is set to 'true': Plainpages is pre-announcement and
|
||||||
|
# deliberately carries no tags, so an automated bump would only invent a version nobody consumes.
|
||||||
auto-release:
|
auto-release:
|
||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
needs: renovate
|
needs: renovate
|
||||||
|
if: vars.AUTO_RELEASE == 'true'
|
||||||
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 +57,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,144 @@ 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.
|
||||||
|
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
|
||||||
|
word names *who someone is* — a role — and roles are groups here; the old catch-all `admin`
|
||||||
|
permission was exactly that mistake, split into `users:`/`groups:`/`permissions:`/`oauth2-clients:`
|
||||||
|
× `read`/`write` 2026-08-05. **Enforced at discovery** (`isValidPermissionName` in
|
||||||
|
`plugin-host/plugin.ts`, checked by `shapeError` over every route/nav `permission` and every
|
||||||
|
declared name), fail-loud like every other manifest rule — not only in the admin GUI, which an
|
||||||
|
operator removes by not copying the example in. Decisions around it:
|
||||||
|
- **Names are authored in plugin code; only grants live in Keto.** The host collects every
|
||||||
|
installed plugin's declarations into one catalog (`declaredPermissions` → `ctx.declaredPermissions`),
|
||||||
|
and that catalog *is* the fixed list the admin screens offer. So there is **no Permissions admin
|
||||||
|
screen**: nothing in a GUI invents a name, and holding one is a property of a user or a group,
|
||||||
|
edited as a checkbox list on those two screens. A tuple in Keto naming something no installed
|
||||||
|
plugin declares gates nothing and is not offered — and a save never revokes it, since the picker
|
||||||
|
only speaks for what it showed. Decided with the maintainer 2026-08-05, replacing the CRUD
|
||||||
|
Permissions screen.
|
||||||
|
- `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`). Deliberate
|
||||||
|
cross-plugin sharing is a goal, so the pre-2026-08-05 `<id>:<action>` guidance was wrong: users
|
||||||
|
are the *host's*, not the admin plugin's. Cost: collision-freedom became a convention rather than
|
||||||
|
structural. Accepted — the alternative penalizes the sharing case.
|
||||||
|
- **Declaring a permission stays optional.** Requiring every gated route to declare its permission
|
||||||
|
would make `findConflicts` see all overlaps, but would then warn on exactly the legitimate
|
||||||
|
sharing case above. Shape is enforced; declaration is not.
|
||||||
|
- **There is no name-minting path in the GUI at all**, which is what makes the discovery check the
|
||||||
|
whole story: the only way a name comes into being is a plugin declaring it, and discovery refuses
|
||||||
|
a badly-shaped declaration at boot. An earlier revision of this branch enforced the rule in the
|
||||||
|
Permissions screen's create form instead and needed a second guard for the assign form, which
|
||||||
|
could also mint one — deleting the screen removed both.
|
||||||
|
- `ADMIN_PERMISSIONS` **defaults to empty**: every permission is owned by the plugin that gates on
|
||||||
|
it, and a host-invented default would gate nothing. This makes the seed a function of what
|
||||||
|
`bootstrap` discovers, and a plugin dropped in after first boot therefore needs
|
||||||
|
`docker compose up -d` (which re-runs the one-shot), not `restart web`. The base file gives
|
||||||
|
`bootstrap` and `web` the same baked `plugins/`; only `compose.override.yml`'s dev-only `.:/app`
|
||||||
|
makes `web` diverge onto the host tree, so the matching `./plugins` mount for `bootstrap` lives
|
||||||
|
**there and only there** — in the base file it would desynchronise prod and collide with the e2e
|
||||||
|
stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a read-only
|
||||||
|
parent is EROFS and the container never starts). Valid while bootstrap is the only writer of
|
||||||
|
grants.
|
||||||
|
- **`actionForMethod` is plugin-local and must not migrate into `#plugin-api`.** Inside the admin
|
||||||
|
example it buys one thing: the route table and the in-handler guard derive from one function, so
|
||||||
|
29 routes × 2 gate sites cannot drift. As a general mechanism it would make authorization a
|
||||||
|
function of the transport verb, and a route table must answer "what does this need?" on its own.
|
||||||
|
- **A `:read`-only holder must never be shown a write affordance.** The split created a real read-only
|
||||||
|
operator (a helpdesk account with `users:read`), and the host's 403 is the backstop, not the UX: the
|
||||||
|
list/detail models carry `canWrite` and the views drop create/save/delete/add/remove, while the
|
||||||
|
permission picker still renders — disabled — because *seeing* who holds what is the point of `:read`.
|
||||||
|
A *write-intent GET* — a create form or a delete-confirm page — is the exception to
|
||||||
|
`actionForMethod`: it gates on `:write` (declared in the route table and passed to the handler's
|
||||||
|
guard, so the two still agree), because a page whose only purpose is to start a write should refuse
|
||||||
|
a reader rather than render a form whose submit 403s.
|
||||||
|
Two grant-specific guards go with it, both restoring behaviour the deleted Permissions screen had:
|
||||||
|
you cannot revoke your own **direct** grants on the Users screen (self-lockout would need a `curl`
|
||||||
|
against Keto to undo, which the operator persona can't do — same shape as the self-deactivate/
|
||||||
|
self-delete guards), and a permission held *through a group* renders ticked-but-disabled rather than
|
||||||
|
unticked, because showing it unticked stated the opposite of the truth and unticking it wrote
|
||||||
|
nothing while looking like a successful revoke. **Known gap, same scope the deleted screen had:**
|
||||||
|
the group paths are unguarded — unticking a permission on a group you belong to, removing yourself
|
||||||
|
from it, or deleting it can all still strip your own effective access. The robust "last effective
|
||||||
|
holder" check needs a reverse Keto query and is deferred. Raised by the architecture + product +
|
||||||
|
stability reviews 2026-08-05.
|
||||||
|
- **`users:write` and `groups:write` are equivalent to full administrative access**, and the split
|
||||||
|
does not change that: `groups:write` adds you to any group, including one holding every permission;
|
||||||
|
`users:write` mints a recovery code for any account. The containment the split buys is real on the
|
||||||
|
**read** half only (`users:read` is a safe helpdesk grant). Don't let the per-resource naming imply
|
||||||
|
otherwise in docs. Raised by the architecture review 2026-08-05.
|
||||||
|
- **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 +232,73 @@ 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.
|
||||||
|
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** No test, build step or
|
||||||
|
workflow reads a markdown file (`README-dockerhub.md` is pasted into Docker Hub by hand), so a
|
||||||
|
nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it
|
||||||
|
would spend the full gate on one. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`:
|
||||||
|
rename detection names only the destination, so `git mv src/app.ts notes.md` otherwise read as docs
|
||||||
|
and skipped the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a
|
||||||
|
*text* guard — the test image (`node:24.19.0-alpine3.24`) ships neither `git` nor `bash`, so it
|
||||||
|
cannot exercise the function; behaviour was verified against a scratch repo across ten scenarios.
|
||||||
|
Revisit if a `.md` ever becomes load-bearing. Decided 2026-08-05.
|
||||||
|
- **Plainpages is pre-announcement: no tags, no releases.** The repo carried tags up to `v0.2.2` from
|
||||||
|
the `auto-release` job; all of them — and the semver container tags — were deleted 2026-08-05, and
|
||||||
|
the job is gated behind the `AUTO_RELEASE` Actions variable (unset ⇒ skipped, the fail-safe
|
||||||
|
direction on every unknown-`vars` path). A version only communicates to consumers, and there are
|
||||||
|
none; same reasoning that freezes `HOST_API_VERSION` at 1.0.0. Note the coupling:
|
||||||
|
`registry-cleanup` keeps a hash image only while its commit is a branch head *or* release-tagged,
|
||||||
|
so with zero tags only branch heads survive the nightly prune — a hand-cut tag must sit on `main`'s
|
||||||
|
tip. `mirror.yml` pushes tags with `--prune` so the deletions actually reach the public GitHub
|
||||||
|
mirror; that makes the runner's tag view load-bearing (hence `fetch-tags: true`) and means a tag
|
||||||
|
or Release created on GitHub is swept away, so releases are cut on Gitea only. Valid until the
|
||||||
|
maintainer says Plainpages is ready to show people.
|
||||||
|
- **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 popover 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`).
|
||||||
|
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
|
||||||
|
`e2e-tests/console-guard.ts`, which watches every page a test opens: a console error or warning, or
|
||||||
|
an uncaught exception, fails that test. A zero-JS app has nothing to say in the console, so the bar
|
||||||
|
is *zero* rather than a curated list of tolerated noise — and the two exceptions are explicit and
|
||||||
|
narrow: one module-level allowance for the COOP header Chromium drops because the e2e stacks serve
|
||||||
|
plain http over container hostnames (a deployment serves https, where it applies), and
|
||||||
|
`allowConsole(re)` for a test whose own page provokes a message on purpose — the 404 spec, whose
|
||||||
|
navigation Chromium and WebKit log. Each record carries the message's origin URL, so that allowance
|
||||||
|
can name the page under test and still see a sub-resource of it 404. `src/e2e-console-guard.test.ts`
|
||||||
|
locks the wiring in the *unit* gate: a spec importing `test` straight from Playwright — or minting a
|
||||||
|
page with a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. The buffer
|
||||||
|
clears at teardown rather than setup so a `beforeAll` is watched too (full-flow runs a whole login in
|
||||||
|
one); the accepted cost is that a page outliving its test, as a serial describe's does, can log late
|
||||||
|
and fail the next test instead of its own. Verified by negative control in all three engines.
|
||||||
|
- **The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.**
|
||||||
|
`visual.spec.ts` + `language.spec.ts` are side-effect-free, so three parallel runs don't collide,
|
||||||
|
and a console message only appears in the engine that renders the page — the reason the per-test
|
||||||
|
`@engines` tag is gone: the whole Ory-free suite is the engine matrix now (`ORY_FREE` in
|
||||||
|
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
|
||||||
|
where a second engine's run would race the first, so widening them means giving each engine its own
|
||||||
|
stack. Screenshots are written per project name for the same reason. Decided 2026-08-05.
|
||||||
|
|
||||||
## Docker only — no host tooling
|
## Docker only — no host tooling
|
||||||
|
|
||||||
@@ -120,10 +326,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 +371,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 +392,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
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,17 @@ step() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
|
|||||||
# Docs-only fast path: nothing but *.md changed since main, so there is nothing here to break.
|
# Docs-only fast path: nothing but *.md changed since main, so there is nothing here to break.
|
||||||
# The working tree counts too — a dirty tree carrying real code must never skip. Anything
|
# The working tree counts too — a dirty tree carrying real code must never skip. Anything
|
||||||
# undeterminable (no git, no reachable main, no merge-base) falls through to the gate, never a skip.
|
# undeterminable (no git, no reachable main, no merge-base) falls through to the gate, never a skip.
|
||||||
|
# --no-renames on both channels: rename detection names only the destination, so `git mv src/app.ts
|
||||||
|
# notes.md` reads as a lone *.md — under --porcelain as one `R src/app.ts -> notes.md` line still
|
||||||
|
# ending in .md after cut -c4- — and the gate would skip over a source file that is gone.
|
||||||
docs_only() {
|
docs_only() {
|
||||||
local base changed
|
local base changed
|
||||||
git rev-parse --git-dir >/dev/null 2>&1 || return 1
|
git rev-parse --git-dir >/dev/null 2>&1 || return 1
|
||||||
git fetch --no-tags --quiet origin +refs/heads/main:refs/remotes/origin/main 2>/dev/null || true
|
git fetch --no-tags --quiet origin +refs/heads/main:refs/remotes/origin/main 2>/dev/null || true
|
||||||
base=$(git merge-base refs/remotes/origin/main HEAD 2>/dev/null) || return 1
|
base=$(git merge-base refs/remotes/origin/main HEAD 2>/dev/null) || return 1
|
||||||
changed=$(
|
changed=$(
|
||||||
{ git diff --name-only "$base" HEAD && git status --porcelain --untracked-files=all | cut -c4-; } 2>/dev/null
|
{ git diff --name-only --no-renames "$base" HEAD \
|
||||||
|
&& git status --porcelain --no-renames --untracked-files=all | cut -c4-; } 2>/dev/null
|
||||||
) || return 1
|
) || return 1
|
||||||
[ -n "$changed" ] || return 1
|
[ -n "$changed" ] || return 1
|
||||||
! printf '%s\n' "$changed" | grep -qvE '\.md$'
|
! printf '%s\n' "$changed" | grep -qvE '\.md$'
|
||||||
|
|||||||
+10
-1
@@ -22,12 +22,21 @@ services:
|
|||||||
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
|
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
|
||||||
# - ./config:/app/config:ro # your config/menu.ts — see examples/config/menu.ts for a template
|
# - ./config:/app/config:ro # your config/menu.ts — see examples/config/menu.ts for a template
|
||||||
|
|
||||||
|
# The seed grants what the installed plugins declare, so bootstrap must discover the same plugins
|
||||||
|
# as web. Only dev needs saying: the base file gives both services the image's baked plugins/, and
|
||||||
|
# it is the `.:/app` above — dev-only — that makes web diverge onto the host tree. Mirror it here
|
||||||
|
# rather than in the base file, where it would instead desynchronise them (and collide with the
|
||||||
|
# e2e stacks, which mount individual plugins *inside* this path).
|
||||||
|
bootstrap:
|
||||||
|
volumes:
|
||||||
|
- ./plugins:/app/plugins:ro
|
||||||
|
|
||||||
# Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so
|
# Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so
|
||||||
# the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this
|
# the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this
|
||||||
# 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:-}
|
||||||
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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "./console-guard.ts";
|
||||||
|
|
||||||
// Full-stack auth E2E: token timeout + silent re-mint ("stay signed in"). Runs against the
|
// Full-stack auth E2E: token timeout + silent re-mint ("stay signed in"). Runs against the
|
||||||
// real Ory stack via e2e-tests/compose.auth.yml, where the session→JWT TTL is shortened to 8s and the
|
// real Ory stack via e2e-tests/compose.auth.yml, where the session→JWT TTL is shortened to 8s and the
|
||||||
@@ -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("users:read");
|
||||||
|
|
||||||
// 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("users:read");
|
||||||
|
|
||||||
// 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" });
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ services:
|
|||||||
timeout: 4s
|
timeout: 4s
|
||||||
retries: 30
|
retries: 30
|
||||||
|
|
||||||
|
# This stack mounts no plugins, so nothing declares a permission for the bootstrap to seed — and
|
||||||
|
# the suite asserts that Keto's grants reach the JWT claim. Name one explicitly so there is
|
||||||
|
# something to project.
|
||||||
|
bootstrap:
|
||||||
|
environment:
|
||||||
|
ADMIN_PERMISSIONS: users:read
|
||||||
|
|
||||||
# Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml),
|
# Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml),
|
||||||
# merged after the base config.
|
# merged after the base config.
|
||||||
kratos:
|
kratos:
|
||||||
|
|||||||
@@ -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/OAuth2-clients CRUD + permission granting, 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
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { expect, test as base, type BrowserContext, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
// The `test` every spec imports: it fails a test whose browser logged a console error or warning,
|
||||||
|
// or threw, at any step — in whichever engine ran it. A zero-JS app has nothing to say in the
|
||||||
|
// console, so anything there is a defect (a broken sub-resource, a rejected attribute, an engine
|
||||||
|
// refusing a feature) that no assertion looks for.
|
||||||
|
//
|
||||||
|
// One module-level buffer is enough: a Playwright worker runs one test at a time. It is cleared at
|
||||||
|
// teardown, not at setup, so what a `beforeAll` provoked — full-flow's whole login runs in one —
|
||||||
|
// still lands on the first test rather than being wiped before it. The cost of the same choice: a
|
||||||
|
// page that outlives its test (a serial describe's) can log late and fail the next test instead.
|
||||||
|
const problems: string[] = [];
|
||||||
|
const allowed: RegExp[] = [];
|
||||||
|
|
||||||
|
// The one message the stack itself provokes: the runner reaches `web`/`proxy` by container name over
|
||||||
|
// plain http, and only a `localhost` origin is trustworthy without TLS — so Chromium drops the COOP
|
||||||
|
// header the app sends and says so on every page. Over https, where a deployment serves, it applies.
|
||||||
|
const EXPECTED = [/^console\.error: The Cross-Origin-Opener-Policy header has been ignored/];
|
||||||
|
|
||||||
|
// Allow a message for the current test only, when the page under test provokes it on purpose.
|
||||||
|
export function allowConsole(...patterns: RegExp[]): void {
|
||||||
|
allowed.push(...patterns);
|
||||||
|
}
|
||||||
|
|
||||||
|
function watch(page: Page): void {
|
||||||
|
page.on("console", (msg) => {
|
||||||
|
const type = msg.type();
|
||||||
|
// The origin is part of the record: a 404 reads the same whether it was the page or its
|
||||||
|
// stylesheet, and a failure nobody can locate is half a failure.
|
||||||
|
if (type === "error" || type === "warning") problems.push(`console.${type}: ${msg.text()} @ ${msg.location().url}`);
|
||||||
|
});
|
||||||
|
page.on("pageerror", (err) => problems.push(`pageerror: ${err.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every page of the context, however it is opened — `context.newPage()` fires this event too, so
|
||||||
|
// watching the context is the whole job and a page must never be watched a second time on top.
|
||||||
|
function watchContext(context: BrowserContext): BrowserContext {
|
||||||
|
context.on("page", watch);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A spec that opens its own context — a page shared across a serial describe — goes through this.
|
||||||
|
export function watchedPage(context: BrowserContext): Promise<Page> {
|
||||||
|
return watchContext(context).newPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test = base.extend<{ consoleGuard: void }>({
|
||||||
|
context: async ({ context }, use) => { await use(watchContext(context)); },
|
||||||
|
consoleGuard: [async ({}, use) => {
|
||||||
|
await use();
|
||||||
|
const unexpected = problems.filter((p) => ![...EXPECTED, ...allowed].some((re) => re.test(p)));
|
||||||
|
problems.length = 0;
|
||||||
|
allowed.length = 0;
|
||||||
|
expect(unexpected, "the browser logged nothing while this test ran").toEqual([]);
|
||||||
|
}, { auto: true }],
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect };
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "./console-guard.ts";
|
||||||
|
|
||||||
// Regression: the from-scratch dev experience the README/banner advertises must work. `docker compose
|
// Regression: the from-scratch dev experience the README/banner advertises must work. `docker compose
|
||||||
// up`, open the printed login URL (http://localhost:3000), sign in as the seeded admin → you land on
|
// up`, open the printed login URL (http://localhost:3000), sign in as the seeded admin → you land on
|
||||||
@@ -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.
|
||||||
|
|||||||
+93
-16
@@ -1,4 +1,5 @@
|
|||||||
import { type Browser, type Page, expect, test } from "@playwright/test";
|
import type { Browser, Page } from "@playwright/test";
|
||||||
|
import { expect, test, watchedPage } from "./console-guard.ts";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
// Full browser E2E: the real Playwright UI against the live stack via the same-origin
|
// Full browser E2E: the real Playwright UI against the live stack via the same-origin
|
||||||
@@ -9,7 +10,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,21 +25,86 @@ 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 watchedPage(await browser.newContext());
|
||||||
|
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;
|
||||||
|
|
||||||
test.beforeAll(async ({ browser: b }) => {
|
test.beforeAll(async ({ browser: b }) => {
|
||||||
browser = b;
|
browser = b;
|
||||||
page = await (await browser.newContext()).newPage();
|
page = await watchedPage(await browser.newContext());
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
await loginPassword(page);
|
await loginPassword(page);
|
||||||
});
|
});
|
||||||
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
|
||||||
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
|
// a chosen language used to get dropped — the core building blocks carry it now.
|
||||||
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
|
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 every permission the two mounted plugins declare (the bootstrap
|
||||||
|
// seeds exactly those), so both gated sections are present in the menu (collapsed by default →
|
||||||
|
// assert they're in the DOM, not necessarily visible).
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1);
|
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1);
|
||||||
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
|
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
|
||||||
@@ -56,6 +122,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 +136,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 CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", 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 +147,17 @@ 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}`;
|
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed
|
||||||
await page.goto("/admin/roles/new");
|
// checkbox list rather than a create form — there is no Permissions screen to visit.
|
||||||
await page.fill('input[name="name"]', role);
|
await page.goto(`/admin/groups/${group}`);
|
||||||
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
|
||||||
await page.locator('.form-card button[type="submit"]').click();
|
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
|
||||||
await expect(page).toHaveURL(/\/admin\/roles(\?|\/|$)/);
|
await expect(scheduling).not.toBeChecked();
|
||||||
await expect(page.locator("main")).toContainText(role);
|
await scheduling.check();
|
||||||
|
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
|
||||||
|
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
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 +197,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 +230,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,81 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { createPrivateKey, sign } from "node:crypto";
|
||||||
|
import { expect, test, watchedPage } from "./console-guard.ts";
|
||||||
|
|
||||||
|
// 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";
|
||||||
|
|
||||||
|
// 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 page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/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 watchedPage(context);
|
||||||
|
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");
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "./console-guard.ts";
|
||||||
|
|
||||||
// Full-stack OAuth2 login + consent E2E: another app logs in *through* plainpages. Hydra
|
// Full-stack OAuth2 login + consent E2E: another app logs in *through* plainpages. Hydra
|
||||||
// starts an authorization flow and hands the browser to web's /oauth2/login; web resolves it via
|
// starts an authorization flow and hands the browser to web's /oauth2/login; web resolves it via
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
// Visual + functional checks against the live app (the `web` compose service, BASE_URL). Run via
|
// Visual + functional checks against the live app (the `web` compose service, BASE_URL). Run via
|
||||||
// e2e-tests/compose.visual.yml. Parallel per the project's E2E principle; deterministic colorScheme/viewport
|
// e2e-tests/compose.visual.yml. Parallel per the project's E2E principle; deterministic colorScheme/viewport
|
||||||
// so the rendered design is stable across runs.
|
// so the rendered design is stable across runs.
|
||||||
|
|
||||||
|
const ORY_FREE = /\/(visual|language)\.spec\.ts$/;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: ".",
|
testDir: ".",
|
||||||
outputDir: "artifacts/test-output",
|
outputDir: "artifacts/test-output",
|
||||||
@@ -15,5 +18,14 @@ 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"] } }],
|
// The Ory-free suites run in all three engines: the console guard (console-guard.ts) only sees an
|
||||||
|
// engine's warnings when that engine renders the page, and the newest platform features in the app
|
||||||
|
// (popover, CSS anchor positioning, `:has()`) are exactly where engines disagree. They stay
|
||||||
|
// side-effect-free, so three parallel runs of them don't collide. The Ory-backed suites write to
|
||||||
|
// one shared backend and stay on chromium.
|
||||||
|
projects: [
|
||||||
|
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||||
|
{ name: "firefox", testMatch: ORY_FREE, use: { ...devices["Desktop Firefox"] } },
|
||||||
|
{ name: "webkit", testMatch: ORY_FREE, use: { ...devices["Desktop Safari"] } },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
+43
-13
@@ -1,31 +1,29 @@
|
|||||||
import { createPrivateKey, sign } from "node:crypto";
|
import { createPrivateKey, sign } from "node:crypto";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { mkdir } from "node:fs/promises";
|
import type { Page } from "@playwright/test";
|
||||||
import { expect, test, type Page } from "@playwright/test";
|
import { allowConsole, expect, test } from "./console-guard.ts";
|
||||||
|
|
||||||
const SHOTS = "artifacts/screenshots";
|
|
||||||
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
|
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
|
||||||
const SESSION_COOKIE = "plainpages_jwt"; // src/auth/login.ts — web verifies it against the committed dev JWKS
|
const SESSION_COOKIE = "plainpages_jwt"; // src/auth/login.ts — web verifies it against the committed dev JWKS
|
||||||
|
|
||||||
|
// Per engine: the three projects run this suite in parallel and would otherwise write one file.
|
||||||
const shot = (page: Page, name: string): Promise<Buffer> =>
|
const shot = (page: Page, name: string): Promise<Buffer> =>
|
||||||
page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` });
|
page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/${name}.png` });
|
||||||
|
|
||||||
// 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 }); });
|
|
||||||
|
|
||||||
// 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 +68,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", 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,14 +126,17 @@ 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");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
|
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
|
||||||
|
allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it
|
||||||
const res = await page.goto("/no-such-page");
|
const res = await page.goto("/no-such-page");
|
||||||
expect(res?.status()).toBe(404);
|
expect(res?.status()).toBe(404);
|
||||||
await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible();
|
||||||
@@ -136,7 +166,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", "groups"] }],
|
||||||
// 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,17 +1,22 @@
|
|||||||
# 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 / 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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -r examples/plugins/admin plugins/admin
|
cp -r examples/plugins/admin plugins/admin
|
||||||
docker compose restart web
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
The seeded `admin@plainpages.local` already holds the `admin` role, so the section appears in the
|
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so the
|
||||||
menu and the screens work immediately.
|
section appears in the menu and the screens work immediately.
|
||||||
|
|
||||||
|
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`) — the nav
|
||||||
|
labels included, which are catalog keys in `admin-shared.ts`. Each pure view-model builder takes an
|
||||||
|
optional `t`; the handlers pass `ctx.t`, and the default is the plugin's own English so a unit test
|
||||||
|
reads in words rather than keys. (README → [Languages](../../../README.md#languages-i18n).)
|
||||||
|
|
||||||
## What it demonstrates — a *system* plugin
|
## What it demonstrates — a *system* plugin
|
||||||
|
|
||||||
@@ -20,29 +25,42 @@ 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 (group membership, permission grants).
|
||||||
- **`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
|
||||||
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
|
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
|
||||||
gated per route by `permission: "admin"`, rendering the core building blocks in `views/`.
|
gated per route by its screen's `<resource>:<action>` permission, rendering the core building blocks
|
||||||
|
in `views/`.
|
||||||
|
|
||||||
|
Each screen is its own resource — `users`, `groups`, `oauth2-clients` — and each splits into `:read`
|
||||||
|
and `:write`, so a helpdesk account can be given `users:read` alone. The nav is filtered by the same
|
||||||
|
permissions: holding none of the three hides the Admin section entirely.
|
||||||
|
|
||||||
|
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
|
||||||
|
GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a
|
||||||
|
property of a user or a group, edited as a checkbox list on those two screens (`admin-grants.ts`).
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission token, and the
|
- `plugin.ts` — the manifest: the Admin nav fragment, the six permissions the plugin declares, and
|
||||||
route table — one thin handler per method+path, all gated by `permission: "admin"`.
|
the route table — one thin handler per method+path, gated via `permissionName(resource, actionForMethod(method))`
|
||||||
- `admin-users.ts` · `admin-groups.ts` · `admin-roles.ts` · `admin-clients.ts` — each a set of pure
|
so a GET needs `:read` and a POST `:write`.
|
||||||
|
- `admin-grants.ts` — the permission picker and the grant diff, shared by the Users and Groups
|
||||||
|
screens: what a submitted checkbox set grants and revokes, against the host's declared catalog.
|
||||||
|
- `admin-users.ts` · `admin-groups.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.
|
screen's permission gate + the needed `ctx.system` clients once.
|
||||||
- `admin-shared.ts` — the shared gate (`requireAdmin`), CSRF form reader (`guardedForm`), confirm
|
- `admin-shared.ts` — the permission naming (`permissionName` / `actionForMethod`), the shared gate
|
||||||
|
(`requirePermission`), CSRF form reader (`guardedForm`), confirm
|
||||||
model, nav fragment, and the not-found / unavailable helpers.
|
model, nav fragment, and the not-found / unavailable helpers.
|
||||||
- `views/` — the screens' EJS, plus the admin-specific body partials under `views/partials/`. They
|
- `views/` — the screens' EJS, plus the admin-specific body partials under `views/partials/`. They
|
||||||
`include()` the core building-block partials (shell, data-table, filter-bar, field, …).
|
`include()` the core building-block partials (shell, data-table, filter-bar, field, …).
|
||||||
|
|
||||||
The four screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
|
The three screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
|
||||||
unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in
|
unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in
|
||||||
`src/http/app.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
|
`src/http/app.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
|
||||||
|
|||||||
@@ -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 { can, 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, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, 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;
|
||||||
@@ -100,10 +100,13 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildClientsListModel(opts: {
|
export function buildClientsListModel(opts: {
|
||||||
|
canWrite?: boolean;
|
||||||
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 +119,57 @@ 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),
|
canWrite: opts.canWrite !== false,
|
||||||
pagination: listPagination(state, page),
|
filterBar: listFilterBar(state, t),
|
||||||
table: listTable(rows),
|
pagination: listPagination(state, page, t),
|
||||||
title: "OAuth2 clients",
|
table: listTable(rows, t),
|
||||||
|
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 +179,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,27 +203,31 @@ 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"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildClientDetailModel(opts: {
|
export function buildClientDetailModel(opts: {
|
||||||
|
canWrite?: boolean;
|
||||||
client: ClientView;
|
client: ClientView;
|
||||||
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 }],
|
||||||
|
canWrite: opts.canWrite !== false,
|
||||||
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,47 +247,49 @@ function readClientInput(form: URLSearchParams): ClientInput {
|
|||||||
// Hydra capability (else a themed 503). Each route below is a thin handler over these.
|
// Hydra capability (else a themed 503). Each route below is a thin handler over these.
|
||||||
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; }
|
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; }
|
||||||
|
|
||||||
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteHandler {
|
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
|
||||||
return async (ctx) => {
|
return async (ctx) => {
|
||||||
const user = requireAdmin(ctx);
|
const user = requirePermission(ctx, "oauth2-clients", action);
|
||||||
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 });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same, plus the target client from ctx.params.id (unknown → themed 404).
|
// Same, plus the target client from ctx.params.id (unknown → themed 404).
|
||||||
function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise<RouteResult>): RouteHandler {
|
function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
|
||||||
return withClients(async (deps) => {
|
return withClients(async (deps) => {
|
||||||
const id = deps.ctx.params["id"] ?? "";
|
const id = deps.ctx.params["id"] ?? "";
|
||||||
const client = await deps.hydra.getClient(id);
|
const client = await deps.hydra.getClient(id);
|
||||||
if (!client) return notFound(deps.ctx);
|
if (!client) return notFound(deps.ctx);
|
||||||
return inner(deps, client, id);
|
return inner(deps, client, id);
|
||||||
});
|
}, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 canWriteClients = (ctx: RequestContext): boolean => can(ctx, permissionName("oauth2-clients", "write"));
|
||||||
|
|
||||||
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({ canWrite: canWriteClients(ctx), 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({ canWrite: canWriteClients(ctx), 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 ?? "" });
|
||||||
@@ -285,7 +297,7 @@ export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/clients/new — the register form.
|
// GET /admin/clients/new — the register form.
|
||||||
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})));
|
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})), "write");
|
||||||
|
|
||||||
// GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
|
// GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
|
||||||
export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client)));
|
export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client)));
|
||||||
@@ -294,12 +306,13 @@ 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" });
|
||||||
});
|
}, "write");
|
||||||
|
|
||||||
// POST /admin/clients/:id/delete — perform it.
|
// POST /admin/clients/:id/delete — perform it.
|
||||||
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
|
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// The pure half of permission granting: what a submitted checkbox set changes, and the picker the
|
||||||
|
// two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import type { PermissionDecl } from "#plugin-api";
|
||||||
|
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
|
||||||
|
|
||||||
|
const declared: PermissionDecl[] = [
|
||||||
|
{ description: "View users", name: "users:read" },
|
||||||
|
{ description: "Edit users", name: "users:write" },
|
||||||
|
{ name: "groups:read" },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("grantTuple targets a user by subject_id and a group by subject_set", () => {
|
||||||
|
assert.deepEqual(grantTuple("users:read", userSubject("u1")), { namespace: "Permission", object: "users:read", relation: "granted", subject_id: "user:u1" });
|
||||||
|
assert.deepEqual(grantTuple("users:read", groupSubject("eng")), {
|
||||||
|
namespace: "Permission", object: "users:read", relation: "granted",
|
||||||
|
subject_set: { namespace: "Group", object: "eng", relation: "members" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grantDiff: the submitted set is the desired state — tick grants, untick revokes, unchanged is a no-op", () => {
|
||||||
|
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read", "users:write"]), { grant: ["users:write"], revoke: [] });
|
||||||
|
assert.deepEqual(grantDiff(declared, ["users:read", "users:write"], ["users:read"]), { grant: [], revoke: ["users:write"] });
|
||||||
|
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read"]), { grant: [], revoke: [] });
|
||||||
|
assert.deepEqual(grantDiff(declared, ["users:read"], []), { grant: [], revoke: ["users:read"] }); // every box cleared
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grantDiff ignores anything the plugins don't declare, in both directions", () => {
|
||||||
|
// A crafted POST can't grant a name no plugin gates on…
|
||||||
|
assert.deepEqual(grantDiff(declared, [], ["superuser:all"]), { grant: [], revoke: [] });
|
||||||
|
// …and a held name that is no longer declared (its plugin was uninstalled) is left alone rather
|
||||||
|
// than silently revoked by an unrelated save — this screen only speaks for what it offered.
|
||||||
|
assert.deepEqual(grantDiff(declared, ["legacy:thing"], ["users:read"]), { grant: ["users:read"], revoke: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
|
||||||
|
const picker = buildPermissionPicker({ action: "/admin/users/u1/permissions", declared, direct: ["users:write"] });
|
||||||
|
assert.equal(picker.action, "/admin/users/u1/permissions");
|
||||||
|
assert.deepEqual(picker.choices.map((c) => c.name), ["users:read", "users:write", "groups:read"]);
|
||||||
|
assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]);
|
||||||
|
assert.equal(picker.choices[0]?.description, "View users");
|
||||||
|
assert.equal(picker.choices[2]?.description, ""); // a declaration may omit one
|
||||||
|
assert.equal(picker.empty, undefined);
|
||||||
|
assert.equal(picker.readOnly, false);
|
||||||
|
assert.equal(picker.inheritedNote, undefined); // nothing is group-held here
|
||||||
|
});
|
||||||
|
|
||||||
|
// The failure this prevents: a permission held through a group used to render unticked, so the page
|
||||||
|
// said "not held" about a grant that reaches the JWT — and unticking it wrote nothing, which read as
|
||||||
|
// a successful revoke. Inherited rows are ticked, disabled, and never posted.
|
||||||
|
test("buildPermissionPicker distinguishes a direct grant from one inherited through a group", () => {
|
||||||
|
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:write"], effective: ["users:read", "users:write"] });
|
||||||
|
assert.deepEqual(picker.choices.map((c) => [c.name, c.checked, c.inherited]), [
|
||||||
|
["users:read", true, true], // effective but not direct → shown as held, not editable here
|
||||||
|
["users:write", true, false], // direct → editable
|
||||||
|
["groups:read", false, false],
|
||||||
|
]);
|
||||||
|
assert.ok(picker.inheritedNote, "the disabled row needs an explanation");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildPermissionPicker in read-only mode still shows the state, and marks itself unwritable", () => {
|
||||||
|
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:read"], effective: ["users:read", "groups:read"], readOnly: true });
|
||||||
|
assert.equal(picker.readOnly, true);
|
||||||
|
assert.deepEqual(picker.choices.map((c) => c.checked), [true, false, true]); // a reader still sees who holds what
|
||||||
|
// Every row renders disabled for a reader, so the writable copy would be wrong twice over: "tick to
|
||||||
|
// grant" is false, and "greyed-out means group-held" would misattribute the direct grant.
|
||||||
|
assert.equal(picker.inheritedNote, undefined);
|
||||||
|
assert.notEqual(picker.hint, buildPermissionPicker({ action: "/x", declared, direct: [] }).hint);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildPermissionPicker notes the transitive lag for a group, and stays quiet for a user", () => {
|
||||||
|
// A group's members inherit, so the change reaches them at their next re-mint; a user's own grant
|
||||||
|
// change revokes their live tokens, so there is nothing to warn about.
|
||||||
|
assert.ok(buildPermissionPicker({ action: "/x", declared, direct: [], transitive: true }).pending);
|
||||||
|
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: [] }).pending, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildPermissionPicker says so when no plugin declares a permission, rather than rendering an empty box", () => {
|
||||||
|
const picker = buildPermissionPicker({ action: "/x", declared: [], direct: [] });
|
||||||
|
assert.deepEqual(picker.choices, []);
|
||||||
|
assert.ok(picker.empty);
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Permission grants, shared by the Users and Groups screens. A permission is held by a user
|
||||||
|
// (`Permission:<name>#granted@user:<id>`) or by a whole group (`…@Group:<name>#members`), and Keto
|
||||||
|
// resolves a group's grant transitively at login.
|
||||||
|
//
|
||||||
|
// The set of permissions that *exist* is `ctx.declaredPermissions` — the host's catalog, built from
|
||||||
|
// what the installed plugins declare in code. Nothing here invents a name, which is why the old
|
||||||
|
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
|
||||||
|
|
||||||
|
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "#plugin-api";
|
||||||
|
|
||||||
|
const PERMISSION_NS = "Permission";
|
||||||
|
const GRANTED = "granted";
|
||||||
|
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
|
||||||
|
|
||||||
|
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
|
||||||
|
|
||||||
|
export const userSubject = (id: string): GrantSubject => ({ subject_id: `user:${id}` });
|
||||||
|
export const groupSubject = (name: string): GrantSubject => ({ subject_set: { namespace: "Group", object: name, relation: "members" } });
|
||||||
|
|
||||||
|
export function grantTuple(permission: string, subject: GrantSubject): RelationTuple {
|
||||||
|
return { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The permissions this subject holds *directly* — one Keto read filtered by the subject, not one per
|
||||||
|
// declared name. This is the edge the picker edits; `effectivePermissions` adds what a group confers.
|
||||||
|
export async function heldPermissions(keto: KetoClient, subject: GrantSubject): Promise<string[]> {
|
||||||
|
const held = new Set<string>();
|
||||||
|
let pageToken: string | undefined;
|
||||||
|
do {
|
||||||
|
const page = await keto.listRelations({ namespace: PERMISSION_NS, relation: GRANTED, ...subject, ...(pageToken ? { pageToken } : {}) });
|
||||||
|
for (const tuple of page.tuples) held.add(tuple.object);
|
||||||
|
pageToken = page.nextPageToken ?? undefined;
|
||||||
|
} while (pageToken);
|
||||||
|
return [...held].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every declared permission the subject effectively holds — direct grants *plus* anything reached
|
||||||
|
// through a group, which is what actually lands in their JWT. One Keto check per declared name;
|
||||||
|
// the catalog is small and this is an admin screen (login does the same walk).
|
||||||
|
export async function effectivePermissions(keto: KetoClient, subject: GrantSubject, declared: readonly PermissionDecl[]): Promise<string[]> {
|
||||||
|
const held = await Promise.all(declared.map((decl) => keto.check({ namespace: PERMISSION_NS, object: decl.name, relation: GRANTED, ...subject })));
|
||||||
|
return declared.filter((_, i) => held[i]).map((decl) => decl.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionChoice {
|
||||||
|
checked: boolean; // held directly — the only state this form can change
|
||||||
|
description: string;
|
||||||
|
// Effective through a group, not granted directly. Rendered ticked but disabled: the grant is real
|
||||||
|
// (it reaches the JWT), and it is removed by editing the group, not this subject.
|
||||||
|
inherited: boolean;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionPicker {
|
||||||
|
action: string;
|
||||||
|
choices: PermissionChoice[];
|
||||||
|
empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer
|
||||||
|
error?: string; // a rejected save (e.g. the self-revoke guard), rendered above the list
|
||||||
|
field: string;
|
||||||
|
hint: string;
|
||||||
|
inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row
|
||||||
|
legend: string;
|
||||||
|
// Set for a group: its members hold these transitively, so a change reaches them at their next
|
||||||
|
// re-mint rather than at once. The user picker revokes live tokens, so it says nothing.
|
||||||
|
pending: string | undefined;
|
||||||
|
readOnly: boolean; // the viewer holds :read but not :write — show the state, offer no save
|
||||||
|
submit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The checkbox list: every declared permission, ticked where this subject holds it. A fixed list
|
||||||
|
// means the form is the whole truth — what it posts back *is* the desired set of *direct* grants
|
||||||
|
// (grantDiff). An inherited row is disabled, so it never posts and can never be diffed into a revoke.
|
||||||
|
export function buildPermissionPicker(opts: {
|
||||||
|
action: string;
|
||||||
|
declared: readonly PermissionDecl[];
|
||||||
|
direct: string[];
|
||||||
|
effective?: string[]; // omit when the caller can't resolve group-held grants; then only direct shows
|
||||||
|
readOnly?: boolean;
|
||||||
|
t?: Translate;
|
||||||
|
transitive?: boolean; // a group: its members inherit, so the change lands at their next re-mint
|
||||||
|
}): PermissionPicker {
|
||||||
|
const t = opts.t ?? ((k: string) => k);
|
||||||
|
const directSet = new Set(opts.direct);
|
||||||
|
const effectiveSet = new Set(opts.effective ?? opts.direct);
|
||||||
|
const choices = opts.declared.map((decl) => ({
|
||||||
|
checked: directSet.has(decl.name) || effectiveSet.has(decl.name),
|
||||||
|
description: decl.description ?? "",
|
||||||
|
inherited: !directSet.has(decl.name) && effectiveSet.has(decl.name),
|
||||||
|
name: decl.name,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
action: opts.action,
|
||||||
|
choices,
|
||||||
|
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
|
||||||
|
field: PERMISSIONS_FIELD,
|
||||||
|
// A reader sees every row disabled, so "tick to grant" is false and "greyed-out means group-held"
|
||||||
|
// is worse than false — it would misattribute a *direct* grant to a group that doesn't hold it.
|
||||||
|
hint: t(opts.readOnly === true ? "admin.grants.hintReadOnly" : "admin.grants.hint"),
|
||||||
|
inheritedNote: opts.readOnly !== true && choices.some((c) => c.inherited) ? t("admin.grants.inherited") : undefined,
|
||||||
|
legend: t("admin.grants.legend"),
|
||||||
|
pending: opts.transitive === true ? t("admin.grants.pending") : undefined,
|
||||||
|
readOnly: opts.readOnly === true,
|
||||||
|
submit: t("admin.grants.save"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a submitted set changes. Pure so the diff is testable without Keto: only declared names are
|
||||||
|
// considered, so a crafted POST cannot grant something no plugin gates on, and a held-but-undeclared
|
||||||
|
// name (left over from an uninstalled plugin) is never silently revoked by an unrelated save.
|
||||||
|
export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): { grant: string[]; revoke: string[] } {
|
||||||
|
const offered = new Set(declared.map((d) => d.name));
|
||||||
|
const heldSet = new Set(held);
|
||||||
|
const wantedSet = new Set(wanted.filter((name) => offered.has(name)));
|
||||||
|
return {
|
||||||
|
grant: [...wantedSet].filter((name) => !heldSet.has(name)).sort(),
|
||||||
|
revoke: [...heldSet].filter((name) => offered.has(name) && !wantedSet.has(name)).sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: { grant: string[]; revoke: string[] }): Promise<void> {
|
||||||
|
for (const name of diff.grant) await keto.writeTuple(grantTuple(name, subject));
|
||||||
|
for (const name of diff.revoke) await keto.deleteTuple(grantTuple(name, subject));
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@
|
|||||||
// 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 { can, 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 { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
|
||||||
|
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, 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 +91,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 {
|
||||||
@@ -110,10 +111,13 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildGroupsListModel(opts: {
|
export function buildGroupsListModel(opts: {
|
||||||
|
canWrite?: boolean;
|
||||||
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 +140,22 @@ 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),
|
canWrite: opts.canWrite !== false,
|
||||||
pagination: listPagination(state, page),
|
filterBar: listFilterBar(state, t),
|
||||||
table: listTable(rows, state, sort),
|
pagination: listPagination(state, page, t),
|
||||||
title: "Groups",
|
table: listTable(rows, state, sort, t),
|
||||||
|
title: t("admin.groups.title"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
|
function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
|
||||||
return {
|
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 +164,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 +202,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,39 +220,46 @@ 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"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGroupDetailModel(opts: {
|
export function buildGroupDetailModel(opts: {
|
||||||
|
canWrite?: boolean; // false ⇒ a `groups:read` holder: show the members, offer no edit
|
||||||
candidates: MemberOption[];
|
candidates: MemberOption[];
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
group: { name: string };
|
group: { name: string };
|
||||||
members: MemberView[];
|
members: MemberView[];
|
||||||
|
permissions?: PermissionPicker;
|
||||||
|
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));
|
||||||
const self = `group:${name}`; // a group can't be a member of itself
|
const self = `group:${name}`; // a group can't be a member of itself
|
||||||
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));
|
||||||
|
const canWrite = opts.canWrite !== false;
|
||||||
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 }],
|
||||||
|
canWrite, // the view drops add/remove/delete when false; the host already 403s those POSTs
|
||||||
csrfToken: opts.csrfToken ?? "",
|
csrfToken: opts.csrfToken ?? "",
|
||||||
delete: { action: `${base}/delete` },
|
delete: { action: `${base}/delete` },
|
||||||
error: opts.error,
|
error: opts.error,
|
||||||
group: { name },
|
group: { name },
|
||||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
members: { action: `${base}/members/delete`, rows: opts.members },
|
||||||
|
permissions: opts.permissions,
|
||||||
title: name,
|
title: name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- request handler (imperative shell) ----
|
// ---- request handler (imperative shell) ----
|
||||||
|
|
||||||
// Drain every page of a relation-tuple query. (Reused by the Roles screen — same membership model.)
|
// Drain every page of a relation-tuple query.
|
||||||
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
|
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
|
||||||
const out: RelationTuple[] = [];
|
const out: RelationTuple[] = [];
|
||||||
let pageToken: string | undefined;
|
let pageToken: string | undefined;
|
||||||
@@ -279,38 +293,39 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
|
|||||||
return page.tuples.length > 0;
|
return page.tuples.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate + the Keto and
|
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on
|
||||||
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
|
// a GET, `groups:write` on a POST) + the Keto and Kratos capabilities (else a themed 503). Each route
|
||||||
|
// below is a thin handler over these.
|
||||||
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; }
|
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; }
|
||||||
|
|
||||||
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHandler {
|
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
|
||||||
return async (ctx) => {
|
return async (ctx) => {
|
||||||
const user = requireAdmin(ctx);
|
const user = requirePermission(ctx, "groups", action);
|
||||||
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 });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same, plus the validated :name from ctx.params (an invalid group name → themed 404).
|
// Same, plus the validated :name from ctx.params (an invalid group name → themed 404).
|
||||||
function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise<RouteResult>): RouteHandler {
|
function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
|
||||||
return withGroups((deps) => {
|
return withGroups((deps) => {
|
||||||
const name = deps.ctx.params["name"] ?? "";
|
const name = deps.ctx.params["name"] ?? "";
|
||||||
if (!isValidGroupName(name)) return Promise.resolve(notFound(deps.ctx));
|
if (!isValidGroupName(name)) return Promise.resolve(notFound(deps.ctx));
|
||||||
return inner(deps, name);
|
return inner(deps, name);
|
||||||
});
|
}, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
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({ canWrite: can(ctx, permissionName("groups", "write")), 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 +336,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 });
|
||||||
@@ -330,13 +345,38 @@ export const groupsCreate = withGroups(async (deps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/groups/new — the create form.
|
// GET /admin/groups/new — the create form.
|
||||||
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}));
|
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
|
||||||
|
|
||||||
// GET /admin/groups/:name — the detail + membership page.
|
// GET /admin/groups/:name — the detail + membership page.
|
||||||
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" };
|
const subject = groupSubject(name);
|
||||||
|
const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]);
|
||||||
|
const permissions = buildPermissionPicker({
|
||||||
|
action: `${detailHref(name)}/permissions`,
|
||||||
|
declared: ctx.declaredPermissions,
|
||||||
|
direct,
|
||||||
|
effective, // a group nested in another group inherits its permissions too
|
||||||
|
readOnly: !can(ctx, permissionName("groups", "write")),
|
||||||
|
t: ctx.t,
|
||||||
|
transitive: true, // members inherit, so a change here lands at their next re-mint, not at once
|
||||||
|
});
|
||||||
|
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t }) }, view: "group-detail" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
|
||||||
|
// a group's permissions transitively, so the change reaches them at their next login or re-mint —
|
||||||
|
// the documented instant-revoke tradeoff for anything held through a group.
|
||||||
|
export const groupsPermissions = withGroupName(async ({ ctx, keto, user }, name) => {
|
||||||
|
const form = (await guardedForm(ctx))!;
|
||||||
|
const subject = groupSubject(name);
|
||||||
|
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
|
||||||
|
await applyGrants(keto, subject, diff);
|
||||||
|
if (diff.grant.length > 0 || diff.revoke.length > 0) {
|
||||||
|
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: diff.grant.join(","), group: name, revoked: diff.revoke.join(",") });
|
||||||
|
}
|
||||||
|
return { redirect: detailHref(name) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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,18 +390,25 @@ 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" });
|
||||||
});
|
}, "write");
|
||||||
|
|
||||||
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
|
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
|
||||||
export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => {
|
export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => {
|
||||||
await guardedForm(ctx); // CSRF-verify the POST
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
|
// Drop what the group *holds* before what it *contains*: a Keto set exists only through its
|
||||||
|
// tuples, so leaving the grants behind would resurrect every permission the moment someone
|
||||||
|
// re-created a group with the same name.
|
||||||
|
const subject = groupSubject(name);
|
||||||
|
const held = await heldPermissions(keto, subject);
|
||||||
|
for (const permission of held) await keto.deleteTuple(grantTuple(permission, subject));
|
||||||
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
|
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
|
||||||
ctx.log.info("admin: group deleted", { actor: user.id, group: name });
|
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
|
||||||
return { redirect: ADMIN_GROUPS_BASE };
|
return { redirect: ADMIN_GROUPS_BASE };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
// Roles & permissions admin screen: list / create / delete Keto roles and assign
|
|
||||||
// them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users
|
|
||||||
// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the
|
|
||||||
// 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:
|
|
||||||
// `keto.expand(Role:<name>#members)` flattened to the distinct users who hold the role directly or via
|
|
||||||
// a group — matching what login projects into the JWT (login.ts readRoles). Writes go only to Keto;
|
|
||||||
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
|
|
||||||
// ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded.
|
|
||||||
|
|
||||||
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
|
|
||||||
import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
|
||||||
import {
|
|
||||||
type GroupView,
|
|
||||||
groupsFromTuples,
|
|
||||||
isValidGroupName,
|
|
||||||
memberCandidates,
|
|
||||||
type MemberOption,
|
|
||||||
type MemberView,
|
|
||||||
memberView,
|
|
||||||
pagedTuples,
|
|
||||||
parseSubject,
|
|
||||||
} from "./admin-groups.ts";
|
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
|
||||||
|
|
||||||
const ROLE_NS = "Role";
|
|
||||||
const MEMBERS = "members";
|
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
|
||||||
const PAGE_SIZES = [25, 50, 100];
|
|
||||||
// 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).
|
|
||||||
const EXPAND_MAX_DEPTH = 50;
|
|
||||||
|
|
||||||
// A role and a group share the URL-safe name rule and the user|group membership model.
|
|
||||||
export type RoleView = GroupView;
|
|
||||||
export const isValidRoleName = isValidGroupName;
|
|
||||||
export const rolesFromTuples = groupsFromTuples;
|
|
||||||
export interface EffectiveUser {
|
|
||||||
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).
|
|
||||||
export function roleMemberTuple(role: string, value: string): RelationTuple | null {
|
|
||||||
const subject = parseSubject(value);
|
|
||||||
return subject ? { namespace: ROLE_NS, object: role, relation: MEMBERS, ...subject } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the role
|
|
||||||
// (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
|
|
||||||
// surface as leaves under them.
|
|
||||||
export function expandToEffectiveUsers(tree: ExpandTree | null | undefined): string[] {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
const walk = (node?: ExpandTree | null): void => {
|
|
||||||
if (!node) return;
|
|
||||||
const subjectId = node.tuple?.subject_id;
|
|
||||||
if (subjectId?.startsWith("user:")) ids.add(subjectId.slice("user:".length));
|
|
||||||
node.children?.forEach(walk);
|
|
||||||
};
|
|
||||||
walk(tree);
|
|
||||||
return [...ids].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- list view model ----
|
|
||||||
|
|
||||||
interface ListState {
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
q: string;
|
|
||||||
sort: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SORT: Record<string, (r: RoleView) => number | string> = {
|
|
||||||
members: (r) => r.memberCount,
|
|
||||||
name: (r) => r.name,
|
|
||||||
};
|
|
||||||
const COLUMNS = [
|
|
||||||
{ key: "name", label: "Role" },
|
|
||||||
{ key: "members", label: "Members" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function detailHref(name: string): string {
|
|
||||||
return `${ADMIN_ROLES_BASE}/${encodeURIComponent(name)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
|
|
||||||
const s = { ...state, ...overrides };
|
|
||||||
const p = new URLSearchParams();
|
|
||||||
if (s.q) p.set("q", s.q);
|
|
||||||
if (s.sort) p.set("sort", s.sort);
|
|
||||||
if (s.page > 1) p.set("page", String(s.page));
|
|
||||||
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
|
|
||||||
const qs = p.toString();
|
|
||||||
return qs ? `${ADMIN_ROLES_BASE}?${qs}` : ADMIN_ROLES_BASE;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildRolesListModel(opts: {
|
|
||||||
csrfToken?: string;
|
|
||||||
roles: RoleView[];
|
|
||||||
url: URL | URLSearchParams | string;
|
|
||||||
}) {
|
|
||||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
|
||||||
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
|
||||||
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
|
||||||
const needle = query.q.toLowerCase();
|
|
||||||
|
|
||||||
let list = opts.roles.filter((r) => !needle || r.name.toLowerCase().includes(needle));
|
|
||||||
if (sort) {
|
|
||||||
const get = SORT[sort.field]!;
|
|
||||||
const dir = sort.dir === "desc" ? -1 : 1;
|
|
||||||
list = [...list].sort((a, b) => {
|
|
||||||
const av = get(a), bv = get(b);
|
|
||||||
const cmp = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
|
|
||||||
return cmp * dir;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = paginate(list.length, query.page, query.pageSize, { boundaries: 1, siblings: 1 });
|
|
||||||
const start = (page.page - 1) * page.pageSize;
|
|
||||||
const rows = list.slice(start, start + page.pageSize);
|
|
||||||
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
|
||||||
|
|
||||||
return {
|
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }],
|
|
||||||
filterBar: listFilterBar(state),
|
|
||||||
pagination: listPagination(state, page),
|
|
||||||
table: listTable(rows, state, sort),
|
|
||||||
title: "Roles",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function listTable(rows: RoleView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
|
|
||||||
return {
|
|
||||||
caption: "Roles",
|
|
||||||
columns: COLUMNS.map((c) => {
|
|
||||||
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
|
||||||
const next = dir === "asc" ? `-${c.key}` : c.key;
|
|
||||||
return { href: listHref(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
|
|
||||||
}),
|
|
||||||
rows: rows.map((r) => ({
|
|
||||||
cells: [{ rowHeader: { href: detailHref(r.name), text: r.name } }, String(r.memberCount)],
|
|
||||||
name: r.name,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function listFilterBar(state: ListState) {
|
|
||||||
const pills: { label: string; remove: string; value: string }[] = [];
|
|
||||||
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
|
||||||
return {
|
|
||||||
applyLabel: "Apply",
|
|
||||||
clearHref: ADMIN_ROLES_BASE,
|
|
||||||
label: "Filter roles",
|
|
||||||
pills,
|
|
||||||
rows: [[
|
|
||||||
{ label: "Search roles", name: "q", placeholder: "Search role name…", type: "search", value: state.q },
|
|
||||||
{ type: "spacer" },
|
|
||||||
]],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
|
|
||||||
const hidden: { name: string; value: string }[] = [];
|
|
||||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
|
||||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
|
||||||
return {
|
|
||||||
label: "Roles pagination",
|
|
||||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
|
||||||
pages: page.pages.map((p) =>
|
|
||||||
p.ellipsis ? { ellipsis: true }
|
|
||||||
: p.current ? { current: true, label: String(p.page) }
|
|
||||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
|
||||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
|
||||||
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
|
|
||||||
summary: { from: page.from, to: page.to, total: page.total },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- create form + detail view models ----
|
|
||||||
|
|
||||||
export function buildRoleFormModel(opts: {
|
|
||||||
csrfToken?: string;
|
|
||||||
error?: string;
|
|
||||||
memberOptions: MemberOption[];
|
|
||||||
values?: { member?: string; name?: string };
|
|
||||||
}) {
|
|
||||||
const nameField: FieldConfig = {
|
|
||||||
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield",
|
|
||||||
id: "name", label: "Role name", name: "name", required: true, value: opts.values?.name ?? "",
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }],
|
|
||||||
error: opts.error,
|
|
||||||
form: {
|
|
||||||
action: ADMIN_ROLES_BASE,
|
|
||||||
cancelHref: ADMIN_ROLES_BASE,
|
|
||||||
csrfToken: opts.csrfToken ?? "",
|
|
||||||
memberOptions: opts.memberOptions,
|
|
||||||
nameField,
|
|
||||||
selectedMember: opts.values?.member ?? "",
|
|
||||||
submitLabel: "Create role",
|
|
||||||
},
|
|
||||||
title: "New role",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildRoleDetailModel(opts: {
|
|
||||||
candidates: MemberOption[];
|
|
||||||
csrfToken?: string;
|
|
||||||
effective: EffectiveUser[];
|
|
||||||
error?: string;
|
|
||||||
members: MemberView[];
|
|
||||||
role: { name: string };
|
|
||||||
}) {
|
|
||||||
const name = opts.role.name;
|
|
||||||
const base = detailHref(name);
|
|
||||||
const taken = new Set(opts.members.map((m) => m.subject));
|
|
||||||
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the role itself
|
|
||||||
return {
|
|
||||||
add: { action: `${base}/members`, options },
|
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }],
|
|
||||||
csrfToken: opts.csrfToken ?? "",
|
|
||||||
delete: { action: `${base}/delete` },
|
|
||||||
effective: opts.effective,
|
|
||||||
error: opts.error,
|
|
||||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
|
||||||
role: { name },
|
|
||||||
title: name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- request handler (imperative shell) ----
|
|
||||||
|
|
||||||
// instant-revoke: a role 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
|
|
||||||
// transitive across many users — left to lag (documented), so only direct user members revoke.
|
|
||||||
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
|
|
||||||
if (revoke && member.startsWith("user:")) revoke(member.slice("user:".length));
|
|
||||||
}
|
|
||||||
|
|
||||||
// A role exists exactly while it has ≥1 member (Keto has no create-object).
|
|
||||||
async function roleExists(keto: KetoClient, name: string): Promise<boolean> {
|
|
||||||
const page = await keto.listRelations({ namespace: ROLE_NS, object: name, relation: MEMBERS, pageSize: 1 });
|
|
||||||
return page.tuples.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The distinct users who effectively hold the role (expand → flatten → label by email). Skipped for
|
|
||||||
// an empty role (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[]> {
|
|
||||||
if (!hasMembers) return [];
|
|
||||||
const tree = await keto.expand({ namespace: ROLE_NS, object: name, relation: MEMBERS }, { maxDepth: EXPAND_MAX_DEPTH });
|
|
||||||
return expandToEffectiveUsers(tree)
|
|
||||||
.map((id) => ({ label: emailById.get(id) ?? `user:${id}` }))
|
|
||||||
.sort((a, b) => a.label.localeCompare(b.label));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared per-request deps for the Roles screen, resolved by `withRoles`: the gate + the Keto and
|
|
||||||
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
|
|
||||||
interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
|
|
||||||
|
|
||||||
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
|
|
||||||
return async (ctx) => {
|
|
||||||
const user = requireAdmin(ctx);
|
|
||||||
const keto = ctx.system?.keto;
|
|
||||||
const kratosAdmin = ctx.system?.kratosAdmin;
|
|
||||||
if (!keto || !kratosAdmin) return unavailable(ctx, "Keto and Kratos identity admin");
|
|
||||||
return inner({ ctx, keto, kratosAdmin, revoke: ctx.system?.revoke, user });
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same, plus the validated :name from ctx.params (an invalid role name → themed 404).
|
|
||||||
function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
|
|
||||||
return withRoles((deps) => {
|
|
||||||
const name = deps.ctx.params["name"] ?? "";
|
|
||||||
if (!isValidRoleName(name)) return Promise.resolve(notFound(deps.ctx));
|
|
||||||
return inner(deps, name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
|
|
||||||
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
|
||||||
return { data: { chrome: deps.ctx.chrome, model: buildRoleFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "role-form" };
|
|
||||||
};
|
|
||||||
|
|
||||||
// The role 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 { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
|
||||||
const tuples = await pagedTuples(deps.keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
|
|
||||||
const members = tuples.map((t) => memberView(t, emailById));
|
|
||||||
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
|
|
||||||
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildRoleDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, role: { name }, ...(error ? { error } : {}) }) }, view: "role-detail" };
|
|
||||||
return error ? { ...result, status: 400 } : result;
|
|
||||||
};
|
|
||||||
|
|
||||||
// GET /admin/roles — the list.
|
|
||||||
export const rolesList = withRoles(async ({ ctx, keto }) => {
|
|
||||||
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
|
|
||||||
return { data: { chrome: ctx.chrome, model: buildRolesListModel({ csrfToken: ctx.chrome.csrfToken, roles, url: ctx.url }) }, view: "roles" };
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /admin/roles — create + assign the first member (a *user* grant revokes their live tokens).
|
|
||||||
export const rolesCreate = withRoles(async (deps) => {
|
|
||||||
const { ctx, keto, revoke, user } = deps;
|
|
||||||
const form = (await guardedForm(ctx))!;
|
|
||||||
const name = (form.get("name") ?? "").trim();
|
|
||||||
const member = (form.get("member") ?? "").trim();
|
|
||||||
const tuple = roleMemberTuple(name, member);
|
|
||||||
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 (!tuple) return reject("Pick a user or group to assign the role to.");
|
|
||||||
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
|
|
||||||
await keto.writeTuple(tuple);
|
|
||||||
revokeUserMember(revoke, member);
|
|
||||||
ctx.log.info("admin: role created + first member assigned", { actor: user.id, member, role: name });
|
|
||||||
return { redirect: detailHref(name) };
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /admin/roles/new — the create form.
|
|
||||||
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
|
|
||||||
|
|
||||||
// GET /admin/roles/:name — the detail (members + effective access via Keto expand).
|
|
||||||
export const rolesDetail = withRoleName((deps, name) => roleDetailResult(deps, name));
|
|
||||||
|
|
||||||
// POST /admin/roles/:name/members — assign a user/group; a *user* grant revokes their live tokens.
|
|
||||||
export const rolesAddMember = withRoleName(async (deps, name) => {
|
|
||||||
const { ctx, keto, revoke, user } = deps;
|
|
||||||
const form = (await guardedForm(ctx))!;
|
|
||||||
const member = (form.get("member") ?? "").trim();
|
|
||||||
const tuple = roleMemberTuple(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 }); }
|
|
||||||
return { redirect: detailHref(name) };
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /admin/roles/:name/delete — confirm, except the admin role can't be deleted.
|
|
||||||
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.");
|
|
||||||
const base = detailHref(name);
|
|
||||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
|
|
||||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role",
|
|
||||||
message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role",
|
|
||||||
}) }, view: "confirm" });
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /admin/roles/:name/delete — remove every member tuple (a whole-role delete lags per the
|
|
||||||
// documented instant-revoke tradeoff; the admin role is protected).
|
|
||||||
export const rolesDelete = withRoleName(async (deps, name) => {
|
|
||||||
const { ctx, keto, user } = deps;
|
|
||||||
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.");
|
|
||||||
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS });
|
|
||||||
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
|
|
||||||
return { redirect: ADMIN_ROLES_BASE };
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /admin/roles/: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
|
|
||||||
// covered — the robust "last effective admin" check is deferred).
|
|
||||||
export const rolesRemoveMember = withRoleName(async (deps, name) => {
|
|
||||||
const { ctx, keto, revoke, user } = deps;
|
|
||||||
const form = (await guardedForm(ctx))!;
|
|
||||||
const member = (form.get("member") ?? "").trim();
|
|
||||||
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
|
|
||||||
const tuple = roleMemberTuple(name, member);
|
|
||||||
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
|
|
||||||
return { redirect: detailHref(name) };
|
|
||||||
});
|
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
|
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
|
||||||
// (requireAdmin/guardedForm gate every admin write) and reused across all four screens, so pin the
|
// (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the
|
||||||
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
|
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
|
||||||
// Import only from the #plugin-api barrel — the same contract boundary the plugin code uses.
|
// Import only from the #plugin-api barrel — the same contract boundary the plugin code uses.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
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, isValidPermissionName, 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_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts";
|
||||||
|
|
||||||
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
|
const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] };
|
||||||
const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
|
const writer: User = { email: "cy@x.io", id: "u3", permissions: ["users:read", "users:write"] };
|
||||||
|
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,28 +19,59 @@ 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, declaredPermissions: [], 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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- nav fragment ----
|
// ---- nav fragment ----
|
||||||
|
|
||||||
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => {
|
test("ADMIN_NAV: an ungated Admin header whose three screens each gate on their own read permission", () => {
|
||||||
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
|
// No gate on the header: a user may hold one screen's permission and not another's. composeNav
|
||||||
|
// drops a header left with no visible children, so holding none of the three hides the section.
|
||||||
|
// Both halves matter — give the header an `href` and it survives the filter as a visible leaf,
|
||||||
|
// ungated, for anonymous visitors included.
|
||||||
|
assert.equal(ADMIN_NAV.permission, undefined);
|
||||||
|
assert.equal(ADMIN_NAV.href, undefined);
|
||||||
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/clients"]);
|
||||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read"]);
|
||||||
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree
|
// 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.clients"]);
|
||||||
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients"]);
|
||||||
|
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- permission naming ----
|
||||||
|
|
||||||
|
test("permissionName builds <resource>:<action>, and the host agrees the result is well-formed", () => {
|
||||||
|
assert.equal(permissionName("users", "read"), "users:read");
|
||||||
|
assert.equal(permissionName("oauth2-clients", "write"), "oauth2-clients:write");
|
||||||
|
assert.ok(isValidPermissionName(permissionName("oauth2-clients", "write"))); // the rule discovery enforces
|
||||||
|
});
|
||||||
|
|
||||||
|
test("actionForMethod: read for GET/HEAD, write for every mutation", () => {
|
||||||
|
assert.equal(actionForMethod("GET"), "read");
|
||||||
|
assert.equal(actionForMethod("HEAD"), "read"); // a GET route also answers HEAD
|
||||||
|
assert.equal(actionForMethod("POST"), "write");
|
||||||
|
assert.equal(actionForMethod("DELETE"), "write"); // anything that isn't a read is a write
|
||||||
|
assert.equal(actionForMethod("get"), "read"); // method case is the caller's
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- auth gates ----
|
// ---- auth gates ----
|
||||||
|
|
||||||
test("requireAdmin: anonymous → 401→/login, signed-in non-admin → 403, admin → the user", () => {
|
test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
|
||||||
assert.throws(() => requireAdmin(fakeCtx({ user: null })), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
|
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
|
||||||
assert.throws(() => requireAdmin(fakeCtx({ user: member })), (e: unknown) => e instanceof GuardError && e.status === 403);
|
assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
|
||||||
assert.equal(requireAdmin(fakeCtx({ user: admin })), admin);
|
assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader);
|
||||||
|
// The whole point of the split: users:read opens the list but not the create/delete POSTs.
|
||||||
|
assert.throws(() => requirePermission(fakeCtx({ method: "POST", user: reader }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
|
||||||
|
assert.equal(requirePermission(fakeCtx({ method: "POST", user: writer }), "users"), writer);
|
||||||
|
// Resources don't leak into each other: a users holder is not a groups holder.
|
||||||
|
assert.throws(() => requirePermission(fakeCtx({ user: writer }), "groups"), (e: unknown) => e instanceof GuardError && e.status === 403);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("guardedForm: valid double-submit → the parsed body, bad token → 403, non-POST → undefined", async () => {
|
test("guardedForm: valid double-submit → the parsed body, bad token → 403, non-POST → undefined", async () => {
|
||||||
|
|||||||
@@ -3,38 +3,68 @@
|
|||||||
// (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";
|
||||||
|
|
||||||
|
// 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"; // role token 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_CLIENTS_BASE = "/admin/clients";
|
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||||
|
|
||||||
export type AdminScreen = "clients" | "groups" | "roles" | "users";
|
// One resource per screen — the `<resource>` half of every permission this plugin gates on.
|
||||||
|
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
|
||||||
|
// There is no `permissions` resource: permissions are declared in plugin code, not created here, so
|
||||||
|
// holding a grant is a property of a user or a group and is edited on those two screens.
|
||||||
|
export type AdminResource = "groups" | "oauth2-clients" | "users";
|
||||||
|
|
||||||
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
|
export type AdminAction = "read" | "write";
|
||||||
// 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.
|
// `<resource>:<action>` (README → Naming a permission).
|
||||||
|
export function permissionName(resource: AdminResource, action: AdminAction): string {
|
||||||
|
return `${resource}:${action}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This plugin's mapping from method to action: every screen reads on GET/HEAD and mutates on POST.
|
||||||
|
// The manifest's route table and the in-handler guard both go through it rather than each spelling
|
||||||
|
// the permission out, so they cannot drift into gating on different names. Deliberately local — as
|
||||||
|
// a general mechanism it would make authorization a function of the transport verb, and a route
|
||||||
|
// table should answer "what does this need?" on its own (AGENTS.md).
|
||||||
|
export function actionForMethod(method: string): AdminAction {
|
||||||
|
const verb = method.toUpperCase();
|
||||||
|
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own
|
||||||
|
// read permission. The header carries no `permission` because a user may hold one screen's and not
|
||||||
|
// another's; composeNav drops a header left with no visible children, so a user holding none of the
|
||||||
|
// three never sees the section. The host current-marks the active item — no `current`/`open` 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", permission: permissionName("users", "read") },
|
||||||
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
|
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
|
||||||
{ href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" },
|
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") },
|
||||||
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the
|
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
||||||
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
|
// declares the same permission, so the host enforces it before the handler runs; this is
|
||||||
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
|
// defence-in-depth and what a direct unit test relies on. Returns the (non-null) user for the
|
||||||
export function requireAdmin(ctx: RequestContext): User {
|
// handler to thread on. GuardError → /login or 403.
|
||||||
|
// `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create form
|
||||||
|
// or a delete-confirm page, whose only purpose is to start a write. Those refuse a reader honestly
|
||||||
|
// instead of rendering a form whose submit would 403; the route table declares the same override, so
|
||||||
|
// the two still cannot disagree.
|
||||||
|
export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): 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");
|
const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET"));
|
||||||
|
if (!can(ctx, permission)) throw new GuardError(403, `${permission} required`);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,13 +79,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,9 @@
|
|||||||
// 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 { can, type Identity, type KetoClient, 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 { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
|
||||||
|
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, 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 +31,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 +87,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.
|
||||||
@@ -107,10 +106,13 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildUsersListModel(opts: {
|
export function buildUsersListModel(opts: {
|
||||||
|
canWrite?: boolean;
|
||||||
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 +135,71 @@ 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),
|
canWrite: opts.canWrite !== false,
|
||||||
pagination: listPagination(state, page),
|
filterBar: listFilterBar(state, all.length, t),
|
||||||
table: listTable(rows, state, sort),
|
pagination: listPagination(state, page, t),
|
||||||
title: "Users",
|
table: listTable(rows, state, sort, t),
|
||||||
|
title: t("admin.users.title"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
|
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
|
||||||
return {
|
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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -216,12 +219,16 @@ export interface FieldConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildUserFormModel(opts: {
|
export function buildUserFormModel(opts: {
|
||||||
|
canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
identity?: Identity | null;
|
identity?: Identity | null;
|
||||||
|
permissions?: PermissionPicker; // editing only — a user that doesn't exist yet can hold nothing
|
||||||
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 +236,30 @@ 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" });
|
||||||
|
|
||||||
|
const canWrite = opts.canWrite !== false;
|
||||||
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") }],
|
||||||
|
canWrite, // the view drops every write affordance when false; the host already 403s the POSTs
|
||||||
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") },
|
||||||
|
permissions: editing ? opts.permissions : undefined,
|
||||||
recovery: opts.recovery,
|
recovery: opts.recovery,
|
||||||
title: editing ? "Edit user" : "New user",
|
title: editing ? t("admin.users.edit") : t("admin.users.new"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,39 +274,42 @@ function readUserInput(form: URLSearchParams): UserInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (admin only) and
|
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (`users:read` on a
|
||||||
// the Kratos capability (else a themed 503). Each route below is a thin handler over these.
|
// GET, `users:write` on a POST) and the Kratos capability (else a themed 503). Each route below is a
|
||||||
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
|
// thin handler over these.
|
||||||
|
// `keto` is optional the way every other capability here is: without it the page still lists and
|
||||||
|
// edits users, it just can't show the permission picker.
|
||||||
|
interface UsersDeps { ctx: RequestContext; keto: KetoClient | undefined; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
|
||||||
|
|
||||||
// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the
|
// Resolve the shared deps, then run `inner`. The route's own `permission` already gated at the host;
|
||||||
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
|
// `requirePermission` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
|
||||||
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler {
|
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
|
||||||
return async (ctx) => {
|
return async (ctx) => {
|
||||||
const user = requireAdmin(ctx);
|
const user = requirePermission(ctx, "users", action);
|
||||||
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, keto: ctx.system?.keto, kratosAdmin, revoke: ctx.system?.revoke, user });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same, plus the target identity from ctx.params.id (unknown id → themed 404). The router already
|
// Same, plus the target identity from ctx.params.id (unknown id → themed 404). The router already
|
||||||
// decoded the id and 404s malformed %-encoding, so no manual decode is needed here.
|
// decoded the id and 404s malformed %-encoding, so no manual decode is needed here.
|
||||||
function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => Promise<RouteResult>): RouteHandler {
|
function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
|
||||||
return withUser(async (deps) => {
|
return withUser(async (deps) => {
|
||||||
const id = deps.ctx.params["id"] ?? "";
|
const id = deps.ctx.params["id"] ?? "";
|
||||||
const identity = await deps.kratosAdmin.getIdentity(id);
|
const identity = await deps.kratosAdmin.getIdentity(id);
|
||||||
if (!identity) return notFound(deps.ctx);
|
if (!identity) return notFound(deps.ctx);
|
||||||
return inner(deps, identity, id);
|
return inner(deps, identity, id);
|
||||||
});
|
}, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
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({ canWrite: canWriteUsers(ctx), 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 +318,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 });
|
||||||
@@ -313,18 +326,73 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/users/new — the empty create form.
|
// GET /admin/users/new — the empty create form.
|
||||||
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})));
|
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write");
|
||||||
|
|
||||||
// GET /admin/users/:id — the edit form, prefilled.
|
// GET /admin/users/:id — the edit form, prefilled.
|
||||||
export const usersEditForm = withTarget((deps, identity) => Promise.resolve(formResult(deps.ctx, { identity })));
|
export const usersEditForm = withTarget(async (deps, identity, id) => {
|
||||||
|
const permissions = await userPermissionPicker(deps, id);
|
||||||
|
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
|
||||||
|
});
|
||||||
|
|
||||||
|
const canWriteUsers = (ctx: RequestContext): boolean => can(ctx, permissionName("users", "write"));
|
||||||
|
|
||||||
|
// The checkbox list of declared permissions: ticked where this user holds one, and disabled where
|
||||||
|
// the grant comes from a group (real, but removed on that group). Undefined when Keto isn't wired —
|
||||||
|
// the rest of the edit page still works.
|
||||||
|
async function userPermissionPicker(deps: UsersDeps, id: string, error?: string): Promise<PermissionPicker | undefined> {
|
||||||
|
if (!deps.keto) return undefined;
|
||||||
|
const subject = userSubject(id);
|
||||||
|
const [direct, effective] = await Promise.all([
|
||||||
|
heldPermissions(deps.keto, subject),
|
||||||
|
effectivePermissions(deps.keto, subject, deps.ctx.declaredPermissions),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
...buildPermissionPicker({
|
||||||
|
action: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}/permissions`,
|
||||||
|
declared: deps.ctx.declaredPermissions,
|
||||||
|
direct,
|
||||||
|
effective,
|
||||||
|
readOnly: !canWriteUsers(deps.ctx),
|
||||||
|
t: deps.ctx.t,
|
||||||
|
}),
|
||||||
|
...(error ? { error } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct*
|
||||||
|
// grants; grant what's newly ticked, revoke what's newly unticked. A change to a user's own grants
|
||||||
|
// revokes their live tokens so it lands now rather than at the next re-mint.
|
||||||
|
export const usersPermissions = withTarget(async (deps, identity, id) => {
|
||||||
|
const { ctx, keto, revoke, user } = deps;
|
||||||
|
const form = (await guardedForm(ctx))!;
|
||||||
|
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
|
||||||
|
const subject = userSubject(id);
|
||||||
|
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
|
||||||
|
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
|
||||||
|
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
|
||||||
|
// next request. Recovery would be a curl against Keto — not something the operator persona can do.
|
||||||
|
if (id === user.id && diff.revoke.length > 0) {
|
||||||
|
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
|
||||||
|
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
|
||||||
|
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status: 400 };
|
||||||
|
}
|
||||||
|
await applyGrants(keto, subject, diff);
|
||||||
|
if (diff.grant.length > 0 || diff.revoke.length > 0) {
|
||||||
|
revoke?.(id);
|
||||||
|
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: diff.grant.join(","), revoked: diff.revoke.join(","), target: id });
|
||||||
|
}
|
||||||
|
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||||
|
});
|
||||||
|
|
||||||
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
|
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
|
||||||
export const usersUpdate = withTarget(async ({ ctx, kratosAdmin }, identity, id) => {
|
export const usersUpdate = withTarget(async (deps, identity, id) => {
|
||||||
|
const { ctx, kratosAdmin } = deps;
|
||||||
const input = readUserInput((await guardedForm(ctx))!);
|
const input = readUserInput((await guardedForm(ctx))!);
|
||||||
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 };
|
// Re-render with the picker, or the permissions section vanishes off the page on a failed save.
|
||||||
|
if (err instanceof KratosError) return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), error: ctx.t("admin.users.error.save"), identity, ...(await pickerOrNothing(deps, id)) }), status: 400 };
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||||
@@ -334,7 +402,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 +412,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" });
|
||||||
});
|
}, "write");
|
||||||
|
|
||||||
// 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 });
|
||||||
@@ -365,14 +434,21 @@ export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user },
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
|
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
|
||||||
export const usersRecovery = withTarget(async ({ ctx, kratosAdmin }, identity, id) => {
|
export const usersRecovery = withTarget(async (deps, identity, id) => {
|
||||||
|
const { ctx, kratosAdmin } = deps;
|
||||||
await guardedForm(ctx); // CSRF-verify the POST
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
const recovery = await kratosAdmin.createRecoveryCode(id);
|
const recovery = await kratosAdmin.createRecoveryCode(id);
|
||||||
return formResult(ctx, { identity, recovery });
|
return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) });
|
||||||
});
|
});
|
||||||
|
|
||||||
function createError(err: KratosError): string {
|
// The picker as a spreadable fragment, so a re-render never silently drops the section.
|
||||||
return err.status === 409
|
async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> {
|
||||||
? "A user with that email already exists."
|
const permissions = await userPermissionPicker(deps, id);
|
||||||
: "Could not create the user — check the email and try again.";
|
return permissions ? { permissions } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createError(err: KratosError, t: Translate): string {
|
||||||
|
return err.status === 409
|
||||||
|
? t("admin.users.error.duplicate")
|
||||||
|
: t("admin.users.error.create");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// 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.grants.hint": "Which permissions exist is set by the plugins installed on this system. Tick to grant, untick to revoke.",
|
||||||
|
"admin.grants.hintReadOnly": "Which permissions exist is set by the plugins installed on this system. You can see these, but not change them.",
|
||||||
|
"admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.",
|
||||||
|
"admin.grants.legend": "Permissions",
|
||||||
|
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
|
||||||
|
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
|
||||||
|
"admin.grants.save": "Save permissions",
|
||||||
|
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
|
||||||
|
|
||||||
|
"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 can't be undone.",
|
||||||
|
"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.section": "Admin",
|
||||||
|
"admin.nav.users": "Users",
|
||||||
|
|
||||||
|
"admin.notFound.message": "That item doesn't exist.",
|
||||||
|
"admin.notFound.title": "Not found",
|
||||||
|
|
||||||
|
"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,134 @@
|
|||||||
|
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.grants.hint": "Vilka behörigheter som finns bestäms av de plugins som är installerade. Kryssa i för att tilldela, ur för att återkalla.",
|
||||||
|
"admin.grants.hintReadOnly": "Vilka behörigheter som finns bestäms av de plugins som är installerade. Du kan se dem, men inte ändra dem.",
|
||||||
|
"admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.",
|
||||||
|
"admin.grants.legend": "Behörigheter",
|
||||||
|
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
|
||||||
|
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
|
||||||
|
"admin.grants.save": "Spara behörigheter",
|
||||||
|
"admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.",
|
||||||
|
|
||||||
|
"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 går inte att ångra.",
|
||||||
|
"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.section": "Administration",
|
||||||
|
"admin.nav.users": "Användare",
|
||||||
|
|
||||||
|
"admin.notFound.message": "Objektet finns inte.",
|
||||||
|
"admin.notFound.title": "Hittades inte",
|
||||||
|
|
||||||
|
"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;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// The manifest's own invariants. A route gating on a permission the manifest doesn't declare is
|
||||||
|
// silent: bootstrap seeds only declared names, so the demo admin would simply 403 on that screen
|
||||||
|
// with nothing in the logs to explain it. Pin the two halves against each other here.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { isValidPermissionName } from "#plugin-api";
|
||||||
|
import manifest from "./plugin.ts";
|
||||||
|
|
||||||
|
const routes = manifest.routes ?? [];
|
||||||
|
const declared = (manifest.permissions ?? []).map((p) => p.name);
|
||||||
|
|
||||||
|
test("every route is gated, and gates on a permission the manifest declares", () => {
|
||||||
|
assert.ok(routes.length > 0);
|
||||||
|
for (const route of routes) {
|
||||||
|
assert.equal(route.public, undefined, `${route.method} ${route.path} must not be public`);
|
||||||
|
assert.ok(route.permission, `${route.method} ${route.path} has no permission`);
|
||||||
|
assert.ok(declared.includes(route.permission!), `${route.method} ${route.path} gates on undeclared ${route.permission}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the manifest declares no permission it never gates on", () => {
|
||||||
|
const gated = new Set(routes.map((r) => r.permission));
|
||||||
|
for (const name of declared) assert.ok(gated.has(name), `declared but unused: ${name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A nav permission is a plain string the host matches against the JWT claim: a typo ("user:read")
|
||||||
|
// passes discovery's shape check and silently hides that menu item forever. Same silent-failure
|
||||||
|
// class the route checks above close, so close it on the nav side too.
|
||||||
|
test("every nav permission is one the manifest declares", () => {
|
||||||
|
const navPermissions: string[] = [];
|
||||||
|
const walk = (nodes: typeof manifest.nav): void => {
|
||||||
|
for (const node of nodes ?? []) {
|
||||||
|
if (node.permission != null) navPermissions.push(node.permission);
|
||||||
|
walk(node.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(manifest.nav);
|
||||||
|
assert.equal(navPermissions.length, 3);
|
||||||
|
for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
|
||||||
|
for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it
|
||||||
|
// Three screens × read/write. There is deliberately no `permissions:` pair: permissions are
|
||||||
|
// declared in plugin code, so holding one is edited on the user or group that holds it.
|
||||||
|
assert.deepEqual([...declared].sort(), [
|
||||||
|
"groups:read", "groups:write",
|
||||||
|
"oauth2-clients:read", "oauth2-clients:write",
|
||||||
|
"users:read", "users:write",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET routes gate on read and mutations on write, so a reader can open a screen but not change it", () => {
|
||||||
|
// …except a write-intent GET — a create form or a delete-confirm page, which exists only to start a
|
||||||
|
// write. Those gate on `:write` so a reader is refused there rather than at the submit.
|
||||||
|
const writeIntent = (path: string): boolean => path.endsWith("/new") || path.endsWith("/delete");
|
||||||
|
for (const route of routes) {
|
||||||
|
const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write";
|
||||||
|
assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path} → ${route.permission}`);
|
||||||
|
}
|
||||||
|
assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per screen
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Admin example plugin: the Users / Groups / Roles / OAuth2-clients screens for running the system.
|
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system.
|
||||||
// These used to ship inside the core; they were extracted here so a fresh clone has no built-in admin
|
// These used to ship inside the core; they were extracted here so a fresh clone has no built-in admin
|
||||||
// GUI. Copy this folder to plugins/admin (then restart) to enable it — see README → Quick start.
|
// GUI. Copy this folder to plugins/admin (then restart) to enable it — see README → Quick start.
|
||||||
//
|
//
|
||||||
@@ -8,58 +8,67 @@
|
|||||||
|
|
||||||
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, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
|
||||||
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-roles.ts";
|
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
|
||||||
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
|
|
||||||
|
|
||||||
// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor
|
// One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`,
|
||||||
// to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are
|
// derived through the same two helpers the in-handler guard uses, so the table below cannot drift
|
||||||
// thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style.
|
// from it. The host redirects an anonymous visitor to /login, gives a signed-in user missing the
|
||||||
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION });
|
// permission the 403 page, and filters the nav the same way. Handlers are thin and keyed on
|
||||||
|
// ctx.params (the host extracts :id / :name), the idiomatic per-route style.
|
||||||
|
// `action` overrides the method's default for a *write-intent GET* — a create form or a
|
||||||
|
// delete-confirm page, which exists only to start a write and so refuses a reader rather than
|
||||||
|
// rendering a form whose submit would 403. The handler's own guard takes the same override.
|
||||||
|
const on = (resource: AdminResource) => (method: HttpMethod, path: string, handler: RouteHandler, action?: AdminAction): Route =>
|
||||||
|
({ handler, method, path, permission: permissionName(resource, action ?? actionForMethod(method)) });
|
||||||
|
|
||||||
|
const users = on("users");
|
||||||
|
const groups = on("groups");
|
||||||
|
const clients = on("oauth2-clients");
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||||
|
|
||||||
nav: [ADMIN_NAV],
|
nav: [ADMIN_NAV],
|
||||||
|
|
||||||
permissions: [{ description: "Administer users, groups, roles, and OAuth2 clients", token: ADMIN_PERMISSION }],
|
permissions: [
|
||||||
|
{ description: "View users and the permissions they hold", name: "users:read" },
|
||||||
|
{ description: "Create, edit and delete users, and grant them permissions", name: "users:write" },
|
||||||
|
{ description: "View groups, their members and the permissions they hold", name: "groups:read" },
|
||||||
|
{ description: "Create and delete groups, and change their members and permissions", name: "groups:write" },
|
||||||
|
{ description: "View OAuth2 clients", name: "oauth2-clients:read" },
|
||||||
|
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
|
||||||
|
],
|
||||||
|
|
||||||
routes: [
|
routes: [
|
||||||
// Users
|
// Users
|
||||||
r("GET", "/users", usersList),
|
users("GET", "/users", usersList),
|
||||||
r("POST", "/users", usersCreate),
|
users("POST", "/users", usersCreate),
|
||||||
r("GET", "/users/new", usersNewForm),
|
users("GET", "/users/new", usersNewForm, "write"),
|
||||||
r("GET", "/users/:id", usersEditForm),
|
users("GET", "/users/:id", usersEditForm),
|
||||||
r("POST", "/users/:id", usersUpdate),
|
users("POST", "/users/:id", usersUpdate),
|
||||||
r("POST", "/users/:id/state", usersState),
|
users("POST", "/users/:id/state", usersState),
|
||||||
r("GET", "/users/:id/delete", usersDeleteConfirm),
|
users("GET", "/users/:id/delete", usersDeleteConfirm, "write"),
|
||||||
r("POST", "/users/:id/delete", usersDelete),
|
users("POST", "/users/:id/delete", usersDelete),
|
||||||
r("POST", "/users/:id/recovery", usersRecovery),
|
users("POST", "/users/:id/recovery", usersRecovery),
|
||||||
|
users("POST", "/users/:id/permissions", usersPermissions),
|
||||||
// Groups
|
// Groups
|
||||||
r("GET", "/groups", groupsList),
|
groups("GET", "/groups", groupsList),
|
||||||
r("POST", "/groups", groupsCreate),
|
groups("POST", "/groups", groupsCreate),
|
||||||
r("GET", "/groups/new", groupsNewForm),
|
groups("GET", "/groups/new", groupsNewForm, "write"),
|
||||||
r("GET", "/groups/:name", groupsDetail),
|
groups("GET", "/groups/:name", groupsDetail),
|
||||||
r("POST", "/groups/:name/members", groupsAddMember),
|
groups("POST", "/groups/:name/members", groupsAddMember),
|
||||||
r("GET", "/groups/:name/delete", groupsDeleteConfirm),
|
groups("GET", "/groups/:name/delete", groupsDeleteConfirm, "write"),
|
||||||
r("POST", "/groups/:name/delete", groupsDelete),
|
groups("POST", "/groups/:name/delete", groupsDelete),
|
||||||
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
groups("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
||||||
// Roles
|
groups("POST", "/groups/:name/permissions", groupsPermissions),
|
||||||
r("GET", "/roles", rolesList),
|
|
||||||
r("POST", "/roles", rolesCreate),
|
|
||||||
r("GET", "/roles/new", rolesNewForm),
|
|
||||||
r("GET", "/roles/:name", rolesDetail),
|
|
||||||
r("POST", "/roles/:name/members", rolesAddMember),
|
|
||||||
r("GET", "/roles/:name/delete", rolesDeleteConfirm),
|
|
||||||
r("POST", "/roles/:name/delete", rolesDelete),
|
|
||||||
r("POST", "/roles/:name/members/delete", rolesRemoveMember),
|
|
||||||
// OAuth2 clients
|
// OAuth2 clients
|
||||||
r("GET", "/clients", clientsList),
|
clients("GET", "/clients", clientsList),
|
||||||
r("POST", "/clients", clientsCreate),
|
clients("POST", "/clients", clientsCreate),
|
||||||
r("GET", "/clients/new", clientsNewForm),
|
clients("GET", "/clients/new", clientsNewForm, "write"),
|
||||||
r("GET", "/clients/:id", clientsDetail),
|
clients("GET", "/clients/:id", clientsDetail),
|
||||||
r("GET", "/clients/:id/delete", clientsDeleteConfirm),
|
clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"),
|
||||||
r("POST", "/clients/:id/delete", clientsDelete),
|
clients("POST", "/clients/:id/delete", clientsDelete),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
shell. Doubles as the post-register page when `created`/`secret` are set.
|
shell. Doubles as the post-register page when `created`/`secret` are set.
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const body = include("partials/client-detail-body", { client: model.client, created: model.created, csrfToken: chrome.csrfToken, del: model.delete, secret: model.secret });
|
const body = include("partials/client-detail-body", { canWrite: model.canWrite, client: model.client, created: model.created, csrfToken: chrome.csrfToken, del: model.delete, secret: model.secret });
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
body,
|
body,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<%#
|
<%#
|
||||||
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 Groups 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>';
|
// Only offer "Register client" to an oauth2-clients:write holder — a :read one would get the 403 page.
|
||||||
|
const actions = model.canWrite === false ? "" : '<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,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Group admin detail / membership page: the group-detail body in the app shell.
|
Group admin detail / membership page: the group-detail body in 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/group-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members });
|
const body = include("partials/group-detail-body", { add: model.add, canWrite: model.canWrite, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members, permissions: model.permissions });
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
body,
|
body,
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
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>';
|
// Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page.
|
||||||
|
const actions = model.canWrite === false ? "" : '<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,30 @@
|
|||||||
-%>
|
-%>
|
||||||
<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">
|
<% if (locals.canWrite !== false) { -%>
|
||||||
<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>
|
<section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>">
|
||||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete client</a>
|
<p class="field-hint"><%= t("admin.clients.rereg") %></p>
|
||||||
|
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a>
|
||||||
</section>
|
</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,33 @@
|
|||||||
<%- 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"><% if (locals.canWrite !== false) { %><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>
|
||||||
|
<% if (locals.canWrite !== false) { -%>
|
||||||
<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">
|
<% } -%>
|
||||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete group</a>
|
<% if (locals.permissions) { -%>
|
||||||
|
<%- include("partials/permission-picker", { csrfToken: csrf, permissions: locals.permissions }) %>
|
||||||
|
<% } -%>
|
||||||
|
<% if (locals.canWrite !== false) { -%>
|
||||||
|
<section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>">
|
||||||
|
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
|
||||||
</section>
|
</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,49 @@
|
|||||||
|
<%#
|
||||||
|
The permission picker, shared by the user-edit and group-detail pages. A fieldset of checkboxes —
|
||||||
|
one per permission the installed plugins declare — ticked where this user/group holds it. The whole
|
||||||
|
set posts back, so what is submitted IS the desired set of *direct* grants (see admin-grants.ts).
|
||||||
|
|
||||||
|
Two rows never post, by design: an `inherited` one (the grant comes from a group, so it is changed
|
||||||
|
there) and every row when `readOnly` (the viewer holds :read but not :write). Neither can be diffed
|
||||||
|
into an accidental revoke, because grantDiff compares against the direct grants only.
|
||||||
|
|
||||||
|
Locals: csrfToken, permissions ({ action, choices, empty, error, field, hint, inheritedNote, legend, pending, readOnly, submit }).
|
||||||
|
%>
|
||||||
|
<section class="form-card" aria-labelledby="permissions-h">
|
||||||
|
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
|
||||||
|
<% if (permissions.error) { -%>
|
||||||
|
<%- include("partials/alert", { text: permissions.error, tone: "neg" }) %>
|
||||||
|
<% } -%>
|
||||||
|
<% if (permissions.empty) { -%>
|
||||||
|
<p class="cell-muted"><%= permissions.empty %></p>
|
||||||
|
<% } else { -%>
|
||||||
|
<p class="cell-muted"><%= permissions.hint %></p>
|
||||||
|
<% if (permissions.readOnly) { -%>
|
||||||
|
<fieldset class="check-group">
|
||||||
|
<legend class="sr-only"><%= permissions.legend %></legend>
|
||||||
|
<% permissions.choices.forEach((c) => { -%>
|
||||||
|
<label class="check"><input type="checkbox"<%= c.checked ? " checked" : "" %> disabled><span><%= c.description || c.name %></span><span class="cell-muted"><%= c.name %></span></label>
|
||||||
|
<% }) -%>
|
||||||
|
</fieldset>
|
||||||
|
<% } else { -%>
|
||||||
|
<form method="post" action="<%= localeHref(permissions.action) %>">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<fieldset class="check-group">
|
||||||
|
<legend class="sr-only"><%= permissions.legend %></legend>
|
||||||
|
<% permissions.choices.forEach((c) => { -%>
|
||||||
|
<label class="check"><input type="checkbox" name="<%= permissions.field %>" value="<%= c.name %>"<%= c.checked ? " checked" : "" %><%= c.inherited ? " disabled" : "" %>><span><%= c.description || c.name %></span><span class="cell-muted"><%= c.name %></span></label>
|
||||||
|
<% }) -%>
|
||||||
|
</fieldset>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><%= permissions.submit %></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<% } -%>
|
||||||
|
<% if (permissions.inheritedNote) { -%>
|
||||||
|
<p class="cell-muted"><%= permissions.inheritedNote %></p>
|
||||||
|
<% } -%>
|
||||||
|
<% if (permissions.pending) { -%>
|
||||||
|
<p class="cell-muted"><%= permissions.pending %></p>
|
||||||
|
<% } -%>
|
||||||
|
<% } -%>
|
||||||
|
</section>
|
||||||
@@ -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>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<%#
|
|
||||||
Admin role create form body, captured into the shell content slot. Config:
|
|
||||||
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
|
|
||||||
memberOptions: {label,value}[], selectedMember }
|
|
||||||
error? string shown when a write was rejected
|
|
||||||
%><%
|
|
||||||
const form = locals.form;
|
|
||||||
-%>
|
|
||||||
<div class="form-page">
|
|
||||||
<% if (locals.error) { -%>
|
|
||||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
|
||||||
<% } -%>
|
|
||||||
<form class="form-card" method="post" action="<%= form.action %>">
|
|
||||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
|
||||||
<%- include("partials/field", form.nameField) %>
|
|
||||||
<div class="field">
|
|
||||||
<label for="member">Assign to</label>
|
|
||||||
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
|
|
||||||
<span class="field-hint">A role exists once assigned; add more users or groups after creating it.</span>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
|
||||||
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@@ -14,23 +14,28 @@
|
|||||||
<%- 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>
|
||||||
|
<% if (locals.canWrite !== false) { -%>
|
||||||
<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 && locals.permissions) { -%>
|
||||||
<section class="form-card admin-actions" aria-label="Account actions">
|
<%- include("partials/permission-picker", { csrfToken: form.csrfToken, permissions: locals.permissions }) %>
|
||||||
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg>Generate recovery code</button></form>
|
<% } -%>
|
||||||
<form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
|
<% if (edit && locals.canWrite !== false) { -%>
|
||||||
<a class="btn btn-danger" href="<%= edit.deleteAction %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete user</a>
|
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
|
||||||
|
<form method="post" action="<%= localeHref(edit.recoveryAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
|
||||||
|
<form method="post" action="<%= localeHref(edit.stateAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
|
||||||
|
<a class="btn btn-danger" href="<%= localeHref(edit.deleteAction) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a>
|
||||||
</section>
|
</section>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}) %>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<%#
|
|
||||||
Role admin create page: the role-form body captured into the app shell.
|
|
||||||
%><%
|
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
|
||||||
const body = include("partials/role-form-body", { error: model.error, form: model.form });
|
|
||||||
-%>
|
|
||||||
<%- include("partials/shell", {
|
|
||||||
body,
|
|
||||||
brand: chrome.brand,
|
|
||||||
breadcrumbs: model.breadcrumbs,
|
|
||||||
csrfToken: chrome.csrfToken,
|
|
||||||
nav,
|
|
||||||
theme: chrome.theme,
|
|
||||||
title: model.title,
|
|
||||||
user: chrome.user,
|
|
||||||
}) %>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<%#
|
|
||||||
Roles 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.
|
|
||||||
%><%
|
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
|
||||||
const filters = include("partials/filter-bar", model.filterBar);
|
|
||||||
const table = include("partials/data-table", model.table);
|
|
||||||
const pager = include("partials/pagination", model.pagination);
|
|
||||||
const actions = '<a class="btn btn-primary" href="/admin/roles/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add role</a>';
|
|
||||||
-%>
|
|
||||||
<%- include("partials/shell", {
|
|
||||||
actions,
|
|
||||||
body: filters + table + pager,
|
|
||||||
brand: chrome.brand,
|
|
||||||
breadcrumbs: model.breadcrumbs,
|
|
||||||
csrfToken: chrome.csrfToken,
|
|
||||||
nav,
|
|
||||||
theme: chrome.theme,
|
|
||||||
title: model.title,
|
|
||||||
user: chrome.user,
|
|
||||||
}) %>
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
Users admin create/edit page: the user-form body captured into the app shell.
|
Users admin create/edit page: the user-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/user-form-body", { edit: model.edit, error: model.error, form: model.form, recovery: model.recovery });
|
const body = include("partials/user-form-body", { canWrite: model.canWrite, edit: model.edit, error: model.error, form: model.form, permissions: model.permissions, recovery: model.recovery });
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
body,
|
body,
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
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>';
|
// Only offer "New user" to a users:write holder — a users:read one would get the 403 page.
|
||||||
|
const actions = model.canWrite === false ? "" : '<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, declaredPermissions: [], 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 [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-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;
|
||||||
@@ -475,20 +474,44 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent);
|
.check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent);
|
||||||
margin: 0; cursor: pointer; }
|
margin: 0; cursor: pointer; }
|
||||||
.check:hover, .radio:hover { color: var(--text); }
|
.check:hover, .radio:hover { color: var(--text); }
|
||||||
|
/* A stacked group of .check rows in a <fieldset> — the right element for related checkboxes, but the
|
||||||
|
UA gives it a groove border, so reset it like .filter-field and .menu-field do. A disabled row is
|
||||||
|
still readable: it states a fact (a permission held through a group) rather than offering an edit. */
|
||||||
|
.check-group { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.check-group .check { align-items: baseline; }
|
||||||
|
.check-group .check input:disabled { cursor: default; }
|
||||||
|
.check-group .check:has(input:disabled) { opacity: .7; cursor: default; }
|
||||||
|
.check-group .check .cell-muted { margin-left: auto; padding-left: 12px; font-size: var(--fz-xs); }
|
||||||
|
|
||||||
/* 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 +523,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 +634,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 {
|
||||||
|
|||||||
+33
-25
@@ -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,35 @@ 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 (empty by default) 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 names → the demo admin
|
||||||
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
|
// holds exactly what the installed plugins gate on, derived from discovery, not hardcoded here.
|
||||||
assert.deepEqual(seedRoles(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
|
assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["scheduling:read", "scheduling:write"]);
|
||||||
assert.deepEqual(seedRoles(undefined, []), ["admin"]); // no plugins → just the base admin role
|
// No plugins → nothing to grant. A host-invented base would be a permission that gates nothing.
|
||||||
assert.deepEqual(seedRoles("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
|
assert.deepEqual(seedPermissions(undefined, []), []);
|
||||||
assert.deepEqual(seedRoles("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
|
assert.deepEqual(seedPermissions("ops:read, ops:write ", ["inventory:read"]), ["ops:read", "ops:write", "inventory:read"]); // env trimmed + extended
|
||||||
assert.deepEqual(seedRoles("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
|
assert.deepEqual(seedPermissions("scheduling:read", ["scheduling:read"]), ["scheduling:read"]); // dedup, no double grant
|
||||||
|
assert.deepEqual(seedPermissions(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => {
|
test("seedPermissions refuses an ADMIN_PERMISSIONS name that isn't <resource>:<action>", () => {
|
||||||
|
// The operator's env is the one remaining hand-typed path; a manifest's names were checked at
|
||||||
|
// discovery. `admin` would otherwise write a tuple that gates nothing, with no error anywhere.
|
||||||
|
assert.throws(() => seedPermissions("admin", []), /ADMIN_PERMISSIONS.*<resource>:<action>.*admin/s);
|
||||||
|
assert.throws(() => seedPermissions("users:read,Bad Name", []), /Bad Name/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 +65,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 +98,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 +114,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/,
|
||||||
);
|
);
|
||||||
|
|||||||
+35
-26
@@ -2,13 +2,14 @@
|
|||||||
// 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 — every
|
||||||
// every discovered plugin's declared permission tokens, so a dropped-in plugin is usable by
|
// discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so a dropped-in
|
||||||
// the demo admin with no host config edit (the host stays plugin-agnostic).
|
// plugin is usable by 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";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { discoverPlugins } from "../plugin-host/discovery.ts";
|
import { discoverPlugins } from "../plugin-host/discovery.ts";
|
||||||
|
import { declaredPermissions, isValidPermissionName } from "../plugin-host/plugin.ts";
|
||||||
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
|
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
|
||||||
import { createLogger, runWithLog, tracedFetch } from "../logger.ts";
|
import { createLogger, runWithLog, tracedFetch } from "../logger.ts";
|
||||||
|
|
||||||
@@ -22,19 +23,27 @@ 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, empty by default)
|
||||||
// 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:<name>#granted` 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 permissions are seeded out of the box. Deduped, order-stable, blanks dropped.
|
||||||
export function seedRoles(adminRolesEnv: string | undefined, declaredTokens: string[]): string[] {
|
// The base is empty because permissions are `<resource>:<action>` and every one of them is owned by
|
||||||
|
// the plugin that gates on it — a host-invented default would gate nothing.
|
||||||
|
// ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
|
||||||
|
// same `<resource>:<action>` rule discovery applies to a manifest — fail loud rather than write a
|
||||||
|
// tuple that gates nothing. A declared name has already passed that check at discovery.
|
||||||
|
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: 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)])];
|
const configured = clean((adminPermissionsEnv ?? "").split(","));
|
||||||
|
const bad = configured.filter((name) => !isValidPermissionName(name));
|
||||||
|
if (bad.length > 0) throw new Error(`bootstrap: ADMIN_PERMISSIONS must be <resource>:<action> names, e.g. "things:read"; got ${bad.join(", ")}`);
|
||||||
|
return [...new Set([...configured, ...clean(declaredNames)])];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- JWKS safety net -----------------------------------------------------------------
|
// --- JWKS safety net -----------------------------------------------------------------
|
||||||
@@ -63,13 +72,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 +102,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 +131,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 +152,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 every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), 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 = declaredPermissions(await discoverPlugins()).map((decl) => decl.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 +164,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,11 +22,11 @@ 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 requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => {
|
test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => {
|
||||||
@@ -59,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 });
|
||||||
@@ -96,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[] = [];
|
||||||
|
|||||||
+5
-3
@@ -8,7 +8,8 @@ import { readFileSync } from "node:fs";
|
|||||||
|
|
||||||
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
|
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
|
||||||
const workflow = read(".gitea/workflows/ci.yml");
|
const workflow = read(".gitea/workflows/ci.yml");
|
||||||
const gate = read("ci.sh");
|
// Comments stripped: the flags below must be asserted against the code, not against prose naming them.
|
||||||
|
const gate = read("ci.sh").split("\n").filter((l) => !l.trimStart().startsWith("#")).join("\n");
|
||||||
const step = (needle: string) => {
|
const step = (needle: string) => {
|
||||||
const found = workflow.split("\n - ").slice(1).filter((s) => s.includes(needle));
|
const found = workflow.split("\n - ").slice(1).filter((s) => s.includes(needle));
|
||||||
assert.equal(found.length, 1, `exactly one workflow step contains ${needle}`);
|
assert.equal(found.length, 1, `exactly one workflow step contains ${needle}`);
|
||||||
@@ -28,7 +29,8 @@ test("the commit-hash image is pushed even when the gate no-ops", () => {
|
|||||||
assert.doesNotMatch(step("docker push"), /^\s*if:/m);
|
assert.doesNotMatch(step("docker push"), /^\s*if:/m);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("only *.md counts as docs, and a dirty working tree counts as changed", () => {
|
test("only *.md counts as docs; a dirty tree and a rename both count as changed", () => {
|
||||||
assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test");
|
assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test");
|
||||||
assert.match(gate, /git status --porcelain/, "uncommitted code can never be skipped over");
|
assert.match(gate, /git status --porcelain --no-renames/, "uncommitted code and a staged rename can never be skipped over");
|
||||||
|
assert.match(gate, /git diff --name-only --no-renames/, "a rename must list both of its paths");
|
||||||
});
|
});
|
||||||
|
|||||||
+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),
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Guards the E2E console guard: a spec that imports `test` straight from Playwright runs unwatched,
|
||||||
|
// and a browser warning nobody looks at is exactly what the guard exists to catch — so the wiring is
|
||||||
|
// asserted here, in the fast unit gate, rather than discovered by a silent gap in an E2E run. A text
|
||||||
|
// guard: @playwright/test is installed in the e2e-tests image, not in the one running these tests.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync, readdirSync } from "node:fs";
|
||||||
|
|
||||||
|
const read = (p: string) => readFileSync(new URL(`../e2e-tests/${p}`, import.meta.url), "utf8");
|
||||||
|
const specs = readdirSync(new URL("../e2e-tests/", import.meta.url)).filter((f) => f.endsWith(".spec.ts"));
|
||||||
|
|
||||||
|
test("every spec takes its `test` from the console guard, never straight from Playwright", () => {
|
||||||
|
assert.ok(specs.length >= 5, "scans the E2E specs");
|
||||||
|
for (const spec of specs) {
|
||||||
|
const source = read(spec);
|
||||||
|
assert.match(source, /^import \{[^}]*\btest\b[^}]*\} from "\.\/console-guard\.ts";$/m, `${spec} imports test from the guard`);
|
||||||
|
assert.doesNotMatch(source, /^import \{(?![^}]*\btype\b)[^}]*\} from "@playwright\/test";$/m, `${spec} imports no value from @playwright/test`);
|
||||||
|
assert.doesNotMatch(source, /\.newPage\(/, `${spec} takes its page from the fixture or watchedPage(), never a raw newPage()`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the guard reads console errors, warnings and uncaught page errors, and fails on what it kept", () => {
|
||||||
|
const guard = read("console-guard.ts");
|
||||||
|
assert.match(guard, /type === "error" \|\| type === "warning"/);
|
||||||
|
assert.match(guard, /page\.on\("pageerror"/);
|
||||||
|
assert.match(guard, /expect\(unexpected, .*\)\.toEqual\(\[\]\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the Ory-free specs run in all three engines, so each engine's console is read", () => {
|
||||||
|
const config = read("playwright.config.ts");
|
||||||
|
for (const engine of ["firefox", "webkit"]) {
|
||||||
|
assert.match(config, new RegExp(`name: "${engine}", testMatch: ORY_FREE`), `${engine} runs the Ory-free specs`);
|
||||||
|
}
|
||||||
|
assert.match(config, /const ORY_FREE = \/\\\/\(visual\|language\)\\\.spec\\\.ts\$\//);
|
||||||
|
});
|
||||||
+302
-113
@@ -15,13 +15,16 @@ import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
|
|||||||
import { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
import { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
||||||
import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts";
|
import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts";
|
||||||
import { staticJwks } from "../auth/jwks.ts";
|
import { staticJwks } from "../auth/jwks.ts";
|
||||||
import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
||||||
import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts";
|
import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts";
|
||||||
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
|
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
|
||||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
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,28 @@ 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_ALL) => 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_ALL) }, method: "POST", redirect: "manual" });
|
||||||
return { get, post, token, url };
|
return { get, post, token, url };
|
||||||
}
|
}
|
||||||
|
// What the plugin itself declares — the harness holds every screen's read and write, so a screen
|
||||||
|
// test exercises the screen rather than the gate. assertAdminGate covers the refusals.
|
||||||
|
const ADMIN_ALL = (adminManifest.permissions ?? []).map((p) => p.name);
|
||||||
// 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 +892,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 +900,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.
|
||||||
@@ -1100,11 +1108,28 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
|||||||
|
|
||||||
await assertAdminGate(url, get, "/admin/users");
|
await assertAdminGate(url, get, "/admin/users");
|
||||||
|
|
||||||
// Nav: the admin plugin's section composes into the one global menu for an admin, and is filtered
|
// Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
|
||||||
// out for a signed-in non-admin (the gate on the section header) — proving the drop-in nav fragment.
|
// by its own read permission — proving the drop-in nav fragment. A user holding only users:read
|
||||||
|
// sees Users and nothing else; holding none of the three, composeNav drops the emptied header.
|
||||||
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
|
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
|
||||||
|
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
|
||||||
|
assert.match(usersOnlyNav, /href="\/admin\/users"/);
|
||||||
|
assert.doesNotMatch(usersOnlyNav, /href="\/admin\/groups"/);
|
||||||
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
|
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
|
||||||
|
|
||||||
|
// The read/write split: users:read opens the list but is refused on every mutation, and the
|
||||||
|
// resources don't leak — a users holder is not a groups holder.
|
||||||
|
assert.equal((await get("/admin/users", ["users:read"])).status, 200);
|
||||||
|
assert.equal((await get("/admin/groups", ["users:read", "users:write"])).status, 403);
|
||||||
|
const readOnlyPost = await fetch(url + "/admin/users", {
|
||||||
|
body: `_csrf=${token}&email=nope@example.com`,
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` },
|
||||||
|
method: "POST",
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
assert.equal(readOnlyPost.status, 403);
|
||||||
|
assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false);
|
||||||
|
|
||||||
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
|
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
|
||||||
const listHtml = await (await get("/admin/users")).text();
|
const listHtml = await (await get("/admin/users")).text();
|
||||||
assert.match(listHtml, /ada@example\.com/);
|
assert.match(listHtml, /ada@example\.com/);
|
||||||
@@ -1164,7 +1189,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);
|
||||||
});
|
});
|
||||||
@@ -1213,107 +1238,138 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
|||||||
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
|
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
|
||||||
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
||||||
|
|
||||||
|
// Give it a permission first, so the delete below has an orphan to avoid leaving behind.
|
||||||
|
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=users%3Aread`);
|
||||||
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng"));
|
||||||
|
|
||||||
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||||
assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/);
|
assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/);
|
||||||
const del = await post("/admin/groups/eng/delete", `_csrf=${token}`);
|
const del = await post("/admin/groups/eng/delete", `_csrf=${token}`);
|
||||||
assert.equal(del.status, 303);
|
assert.equal(del.status, 303);
|
||||||
assert.equal(del.headers.get("location"), "/admin/groups");
|
assert.equal(del.headers.get("location"), "/admin/groups");
|
||||||
assert.ok(!tuples.some((tp) => tp.object === "eng"));
|
assert.ok(!tuples.some((tp) => tp.object === "eng"));
|
||||||
|
// …and the permissions it held go with it. A Keto set exists only through its tuples, so an
|
||||||
|
// orphaned grant would resurrect the moment someone re-created a group with the same name.
|
||||||
|
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng"));
|
||||||
|
|
||||||
// An invalid group name in the path → 404; malformed %-encoding doesn't 500.
|
// An invalid group name in the path → 404; malformed %-encoding doesn't 500.
|
||||||
assert.equal((await get("/admin/groups/Bad%20Name")).status, 404);
|
assert.equal((await get("/admin/groups/Bad%20Name")).status, 404);
|
||||||
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
|
// Granting permissions over HTTP, on the two screens that replaced the deleted Permissions screen.
|
||||||
// against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the
|
// The offered set is the host's catalog (ctx.declaredPermissions, from what the installed plugins
|
||||||
// effective-access view surfaces a user reachable only through a group.
|
// declare), so the checkboxes are a fixed list and the POST is the desired state.
|
||||||
test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => {
|
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
|
||||||
const ada = randomUUID();
|
const ada = randomUUID();
|
||||||
const grace = randomUUID();
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
const identities: Identity[] = [
|
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||||
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
|
const keto = fakeKeto(tuples);
|
||||||
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
];
|
const denylist = createDenylist();
|
||||||
// grace is in the `eng` group; `editor` is an existing role whose only direct member is ada.
|
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||||
const tuples: RelationTuple[] = [
|
|
||||||
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` },
|
// The user edit page renders one checkbox per declared permission, ticked where already held.
|
||||||
{ namespace: "Role", object: "editor", relation: "members", subject_id: `user:${ada}` },
|
const edit = await (await get(`/admin/users/${ada}`)).text();
|
||||||
];
|
for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) {
|
||||||
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
|
assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name);
|
||||||
const expandSet = (set: SubjectSet): ExpandTree => ({
|
}
|
||||||
children: tuples
|
assert.match(edit, /value="users:read"[^>]*checked/); // held → ticked
|
||||||
.filter((tp) => tp.namespace === set.namespace && tp.object === set.object && tp.relation === set.relation)
|
assert.doesNotMatch(edit, /value="groups:write"[^>]*checked/); // not held → unticked
|
||||||
.map((tp) => (tp.subject_id ? { tuple: { namespace: "", object: "", relation: "", subject_id: tp.subject_id }, type: "leaf" } : expandSet(tp.subject_set!))),
|
|
||||||
tuple: { namespace: "", object: "", relation: "", subject_set: set },
|
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
|
||||||
type: "union",
|
const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||||
|
assert.equal(saved.status, 303);
|
||||||
|
assert.deepEqual(
|
||||||
|
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
|
||||||
|
["groups:read", "users:write"],
|
||||||
|
);
|
||||||
|
assert.equal(denylist.isRevoked(ada, 0), true); // a change to your own grants revokes live tokens
|
||||||
|
|
||||||
|
// A crafted POST can't grant something no plugin declares.
|
||||||
|
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=superuser%3Aall`);
|
||||||
|
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
|
||||||
|
|
||||||
|
// The same picker on a group writes the group's subject_set, which Keto resolves transitively.
|
||||||
|
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||||
|
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=groups%3Aread`);
|
||||||
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
|
||||||
});
|
});
|
||||||
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
|
|
||||||
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 { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
|
||||||
|
|
||||||
await assertAdminGate(url, get, "/admin/roles");
|
// Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
|
||||||
|
// hook lands it on the next request — recovery would be a curl against Keto. Guarded like
|
||||||
|
// self-deactivate and self-delete are. (`admin1` is the harness's own sub.)
|
||||||
|
test("admin permission grants: you can't revoke your own permissions, but you can still grant", async (t) => {
|
||||||
|
const identities: Identity[] = [{ id: "admin1", traits: { email: "you@example.com" } }];
|
||||||
|
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:write", relation: "granted", subject_id: "user:admin1" }];
|
||||||
|
const keto = fakeKeto(tuples);
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
const { post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
|
|
||||||
// List: the existing role shows + the "add" link.
|
const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}`); // every box cleared
|
||||||
const listHtml = await (await get("/admin/roles")).text();
|
assert.equal(refused.status, 400);
|
||||||
assert.match(listHtml, /href="\/admin\/roles\/editor"/);
|
assert.match(await refused.text(), /lock yourself out/);
|
||||||
assert.match(listHtml, /href="\/admin\/roles\/new"/);
|
assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
|
||||||
|
|
||||||
// Create: a valid post writes the first-member tuple and redirects to the detail.
|
// Granting yourself more is not a lockout, so it goes through.
|
||||||
assert.match(await (await get("/admin/roles/new")).text(), /Create role/);
|
const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||||
const created = await post("/admin/roles", `_csrf=${token}&name=viewer&member=user:${ada}`);
|
assert.equal(granted.status, 303);
|
||||||
assert.equal(created.status, 303);
|
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
|
||||||
assert.equal(created.headers.get("location"), "/admin/roles/viewer");
|
});
|
||||||
assert.ok(tuples.some((tp) => tp.namespace === "Role" && 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
|
|
||||||
|
|
||||||
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
// The read/write split is only honest if the UI models it: a users:read holder must not be shown
|
||||||
const before = tuples.length;
|
// buttons that 403 on submit. The gate already refuses them (asserted above); this is the affordance.
|
||||||
assert.equal((await post("/admin/roles", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400);
|
test("admin screens render no write affordance for a read-only holder", async (t) => {
|
||||||
assert.equal((await post("/admin/roles", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists
|
const ada = randomUUID();
|
||||||
assert.equal((await post("/admin/roles", `name=x&member=user:${ada}`)).status, 403);
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
assert.equal(tuples.length, before);
|
const keto = fakeKeto([{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }]);
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
// Hydra is wired so the clients screen renders for real — without it the page is a 503 and the
|
||||||
|
// "no Register button" assertion below would pass without proving anything.
|
||||||
|
const reporting = { client_id: "existing", client_name: "Reporting" };
|
||||||
|
const hydra = stubHydra({ getClient: async (id) => (id === reporting.client_id ? reporting : null), listClients: async () => ({ clients: [reporting], nextPageToken: null }) });
|
||||||
|
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
|
||||||
|
const readOnly = ["users:read", "groups:read"];
|
||||||
|
|
||||||
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
|
const list = await (await get("/admin/users", readOnly)).text();
|
||||||
// yet — though grace appears elsewhere as an assignable candidate, so target the effective <li>.
|
assert.doesNotMatch(list, /href="\/admin\/users\/new"/); // no "New user"
|
||||||
const effectiveLi = (email: string) => new RegExp(`<li><span class="cell-strong">${email.replace(".", "\\.")}`);
|
assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
|
||||||
const detail = await (await get("/admin/roles/editor")).text();
|
|
||||||
assert.match(detail, effectiveLi("ada@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).
|
// (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.)
|
||||||
await post("/admin/roles/editor/members", `_csrf=${token}&member=group:eng`);
|
const detail = await (await get(`/admin/users/${ada}`, readOnly)).text();
|
||||||
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
assert.doesNotMatch(detail, /Save changes/);
|
||||||
const withGroup = await (await get("/admin/roles/editor")).text();
|
assert.doesNotMatch(detail, /Generate recovery code/);
|
||||||
assert.match(withGroup, effectiveLi("grace@example.com"));
|
assert.doesNotMatch(detail, /Delete user/);
|
||||||
|
assert.doesNotMatch(detail, /Save permissions/);
|
||||||
|
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
|
||||||
|
|
||||||
// Revoke the group membership.
|
const group = await (await get("/admin/groups/eng", readOnly)).text();
|
||||||
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`);
|
assert.doesNotMatch(group, /Add a member/);
|
||||||
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
assert.doesNotMatch(group, /Delete group/);
|
||||||
|
assert.doesNotMatch(group, /Save permissions/);
|
||||||
|
|
||||||
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
|
// The OAuth2-clients screen is held to the same rule (it was the one this test was written to catch).
|
||||||
await post("/admin/roles/editor/members", `_csrf=${token}&member=user:${grace}`);
|
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
|
||||||
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
|
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
|
||||||
assert.equal(denylist.isRevoked(grace, 0), true);
|
const clients = await clientsRes.text();
|
||||||
|
assert.match(clients, /Reporting/); // the list is there — that's what :read buys
|
||||||
|
assert.doesNotMatch(clients, /href="\/admin\/clients\/new"/);
|
||||||
|
// The detail page is where Delete lives, so check it too and not just the list.
|
||||||
|
const clientDetail = await (await get("/admin/clients/existing", ["oauth2-clients:read"])).text();
|
||||||
|
assert.match(clientDetail, /Reporting/);
|
||||||
|
assert.doesNotMatch(clientDetail, /clients\/existing\/delete/);
|
||||||
|
|
||||||
// Delete the role: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
// A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
|
||||||
assert.match(await (await get("/admin/roles/editor/delete")).text(), /Cancel/);
|
// rendering a form whose submit would 403.
|
||||||
const del = await post("/admin/roles/editor/delete", `_csrf=${token}`);
|
for (const path of ["/admin/users/new", "/admin/groups/new", `/admin/users/${ada}/delete`, "/admin/groups/eng/delete"]) {
|
||||||
assert.equal(del.status, 303);
|
assert.equal((await get(path, readOnly)).status, 403, path);
|
||||||
assert.equal(del.headers.get("location"), "/admin/roles");
|
}
|
||||||
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor"));
|
assert.equal((await get("/admin/clients/new", ["oauth2-clients:read"])).status, 403);
|
||||||
|
|
||||||
// Self-protection: the admin role can't be deleted, nor can you revoke your own admin (sub admin1).
|
// A writer sees the affordances the reader didn't.
|
||||||
tuples.push({ namespace: "Role", object: "admin", relation: "members", subject_id: "user:admin1" });
|
const writable = await (await get(`/admin/users/${ada}`, ["users:read", "users:write"])).text();
|
||||||
assert.equal((await post("/admin/roles/admin/delete", `_csrf=${token}`)).status, 400);
|
assert.match(writable, /Save changes/);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "admin"));
|
assert.match(writable, /Save permissions/);
|
||||||
assert.equal((await post("/admin/roles/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
|
|
||||||
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.
|
|
||||||
assert.equal((await get("/admin/roles/Bad%20Name")).status, 404);
|
|
||||||
assert.equal((await get("/admin/roles/%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 +1399,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 +1451,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"/);
|
||||||
|
});
|
||||||
|
|||||||
+135
-33
@@ -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";
|
||||||
@@ -21,11 +25,12 @@ import type { KratosPublic } from "../auth/kratos-public.ts";
|
|||||||
import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts";
|
import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts";
|
||||||
import { remintSession } from "../auth/login.ts";
|
import { remintSession } from "../auth/login.ts";
|
||||||
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
||||||
import type { Plugin, RouteHandler, RouteResult } from "../plugin-host/plugin.ts";
|
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
|
||||||
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
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;
|
||||||
@@ -90,6 +99,9 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
|
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
|
||||||
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
|
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
|
||||||
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
||||||
|
// The permission catalog is a property of the installed plugin set, so it is computed once at
|
||||||
|
// wiring rather than per request.
|
||||||
|
const permissionCatalog = declaredPermissions(plugins);
|
||||||
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
|
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
|
||||||
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
||||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||||
@@ -107,6 +119,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 +159,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 +176,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 +202,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 +222,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 +242,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 +286,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, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||||
|
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
||||||
|
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, 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 +322,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 +345,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 +355,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 +419,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 /", () => {
|
||||||
|
|||||||
+41
-8
@@ -1,38 +1,61 @@
|
|||||||
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 { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only
|
||||||
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;
|
||||||
|
// Every permission the installed plugins declare, deduped and sorted — the fixed list an admin
|
||||||
|
// screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is
|
||||||
|
// what *this user holds*. Empty when no installed plugin declares any.
|
||||||
|
declaredPermissions: readonly PermissionDecl[];
|
||||||
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 +66,15 @@ 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;
|
||||||
|
declaredPermissions?: readonly PermissionDecl[];
|
||||||
|
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 +95,20 @@ 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); },
|
||||||
|
declaredPermissions: options.declaredPermissions ?? [],
|
||||||
|
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user