Compare commits
132 Commits
c30cd95ebd
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 83c9fa68a9 | |||
| fa7cad1d65 | |||
| 3f74bf8832 | |||
| aff47c8b90 | |||
| 9a1bfcc69d | |||
| 075cd2f090 | |||
| fd32bea28e | |||
| 47f4498fff | |||
| bcff5967ad | |||
| cbf55bebae | |||
| 9e0cb26b3e | |||
| 02617a935e | |||
| 8fd492e544 | |||
| 6c5eddf0c9 | |||
| f416abf936 | |||
| e44b86f532 | |||
| bfcf4ed072 | |||
| a3d9a3df5f | |||
| 5befa2775a | |||
| d3b227eef8 | |||
| a96a2b8abd | |||
| d545445ea8 | |||
| c35ba3fb4e | |||
| 27a8cdc385 | |||
| 4b48bc2416 | |||
| ef96ebd1f4 | |||
| ecf33733a1 | |||
| c16fb2449b | |||
| d823f830c3 | |||
| 1a1aad90e3 | |||
| 45b3cfc010 | |||
| a3cb1f4840 | |||
| 0f40d06f3b | |||
| b39defe39d | |||
| e0046e5068 | |||
| 5589472e25 | |||
| e66a8a3e89 | |||
| 060535c8ab | |||
| d2211cf75a | |||
| c7013be2f0 | |||
| 9ff8f57509 | |||
| 8f3fc7414a | |||
| 552cc6bd97 | |||
| 52a3228503 | |||
| ee7b78b94d | |||
| 65e76b69fd | |||
| e00dad8ed7 | |||
| cb59eee76d | |||
| 82af77356f | |||
| a261570796 | |||
| 94dc581593 | |||
| 453058c67b | |||
| af974cfa36 | |||
| 1ba6dbdc51 | |||
| 09ab2fcb85 | |||
| a977ecf1c7 | |||
| 04a508c169 | |||
| d74989c4e0 | |||
| 43d7062d9e | |||
| d5ee0353d4 | |||
| 137c35478d | |||
| 352f2f2794 | |||
| 3b6f2c1ed3 | |||
| f99a029bc5 | |||
| 1daa2b1777 | |||
| 8a5d2dfd6c | |||
| a005acb93d | |||
| f5240ef7f6 | |||
| 78f5f72151 | |||
| 1cf34a0d45 | |||
| 073ec294e9 | |||
| e8b91ecd09 | |||
| ab5c24deb7 | |||
| 3d3313c0ee | |||
| bcf4d7fb1f | |||
| 2852722873 | |||
| 45b16824f1 | |||
| f76cd2a267 | |||
| 38ebe40398 | |||
| 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 |
@@ -1,9 +1,14 @@
|
||||
.git
|
||||
# Load-bearing both ways: a stray copy would bake in at /app/node_modules and shadow /node_modules,
|
||||
# and matching only the root one is what lets a baked plugin keep its own deps. Never `**/node_modules`.
|
||||
node_modules
|
||||
npm-debug.log
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# A plugin's .npmrc is where a private-registry token would sit — never in a shipped image.
|
||||
plugins/**/.npmrc
|
||||
|
||||
e2e-tests/artifacts
|
||||
# Orchestration, not test code — keep them out of the runner image (COPY e2e-tests/ ./)
|
||||
e2e-tests/Dockerfile
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
full-gate:
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
- uses: actions/checkout@v4.4.0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0 # ci.sh's docs-only check needs history; checkout defaults to depth 1
|
||||
- run: bash ci.sh
|
||||
|
||||
@@ -9,10 +9,13 @@ jobs:
|
||||
github-mirror:
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
- uses: actions/checkout@v4.4.0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
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: |
|
||||
git push --force \
|
||||
git push --force --prune \
|
||||
"https://x-access-token:${{ secrets.MIRROR_GITHUB_TOKEN }}@github.com/larvit/plainpages.git" \
|
||||
refs/remotes/origin/main:refs/heads/main 'refs/tags/*:refs/tags/*'
|
||||
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
prune-stale-images:
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
- uses: actions/checkout@v4.4.0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- name: Delete hash images that are neither release-tagged nor a branch head
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||
@@ -19,4 +19,4 @@ jobs:
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||
-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
|
||||
|
||||
@@ -2,12 +2,27 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags: ['v[0-9]+.[0-9]+.[0-9]+']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
overview_version:
|
||||
description: 'Released version to republish the overview for, without the leading v (e.g. 0.1.0)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
retag-image:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
- uses: actions/checkout@v4.4.0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
# Before anything is published: the contract version IS the release version, so a tag that
|
||||
# disagrees would ship a host misreporting itself to every plugin's compatibility check.
|
||||
- name: Refuse a tag that disagrees with HOST_API_VERSION
|
||||
env:
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
||||
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
|
||||
- name: Promote the commit-hash image to semver + latest
|
||||
env:
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
@@ -15,34 +30,77 @@ jobs:
|
||||
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
|
||||
REPO: gitea.larvit.se/${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||
VERSION=${GIT_TAG#v}
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
|
||||
docker pull "$REPO:$COMMIT" \
|
||||
|| { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; }
|
||||
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
|
||||
# No bare-major tag while major is 0: a 0.x minor is a contract break, so `:0` would move
|
||||
# across one and abort boot for everything tracking it. `:0.1` only moves across patches.
|
||||
TAGS="$VERSION ${VERSION%.*} latest"
|
||||
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
|
||||
for TAG in $TAGS; do
|
||||
docker tag "$REPO:$COMMIT" "$REPO:$TAG"
|
||||
docker push "$REPO:$TAG"
|
||||
done
|
||||
- name: Sync the release tags to Docker Hub
|
||||
env:
|
||||
DOCKERHUB_REPO: docker.io/${{ github.repository }}
|
||||
DOCKERHUB_IMAGE: docker.io/${{ github.repository }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
REPO: gitea.larvit.se/${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||
VERSION=${GIT_TAG#v}
|
||||
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|
||||
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; }
|
||||
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
|
||||
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
|
||||
docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG"
|
||||
docker push "$DOCKERHUB_REPO:$TAG"
|
||||
TAGS="$VERSION ${VERSION%.*} latest"
|
||||
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
|
||||
for TAG in $TAGS; do
|
||||
docker tag "$REPO:$COMMIT" "$DOCKERHUB_IMAGE:$TAG"
|
||||
docker push "$DOCKERHUB_IMAGE:$TAG"
|
||||
done
|
||||
- name: Log out of the registries
|
||||
if: always()
|
||||
run: |
|
||||
docker logout gitea.larvit.se
|
||||
docker logout docker.io
|
||||
set -uo pipefail
|
||||
# Cleanup, and the runner's Docker config is shared (AGENTS.md) — a lost race here must not
|
||||
# fail a release that published, nor skip the overview job that follows.
|
||||
docker logout gitea.larvit.se || true
|
||||
docker logout docker.io || true
|
||||
|
||||
publish-overview:
|
||||
if: always() && (github.event_name == 'workflow_dispatch' || needs.retag-image.result == 'success')
|
||||
needs: [retag-image]
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
if: github.event_name == 'push'
|
||||
# Publish the named release's own tree, so the page never pairs one Plainpages tag with another
|
||||
# release's sidecar pins. A version that was never released fails here.
|
||||
- uses: actions/checkout@v7.0.1
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: refs/tags/v${{ inputs.overview_version }}
|
||||
- name: Publish the Docker Hub overview
|
||||
env:
|
||||
DOCKERHUB_REPO: ${{ github.repository }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
INPUT_VERSION: ${{ inputs.overview_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
|
||||
VERSION=${VERSION#v}
|
||||
# An empty dispatch input falls back to the branch name, so gate this like a tag.
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
||||
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
|
||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
|
||||
node:24.19.0-alpine3.24 \
|
||||
node release-tooling/dockerhub-overview.ts "$VERSION"
|
||||
|
||||
@@ -21,20 +21,21 @@ jobs:
|
||||
-e RENOVATE_PLATFORM=gitea \
|
||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||
-e RENOVATE_TOKEN \
|
||||
renovate/renovate:44.7.2
|
||||
renovate/renovate:44.39.2
|
||||
|
||||
# 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
|
||||
# trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit
|
||||
# (a human owns that release) or nothing new merged. ff-only merges keep the renovate commit's
|
||||
# (a human owns that release), nothing new merged, or nothing that merged carried a `Release-Bump:`
|
||||
# trailer — a release nobody can observe is noise. ff-only merges keep the renovate commit's
|
||||
# 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 (release-tooling/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).
|
||||
auto-release:
|
||||
runs-on: docker-host
|
||||
needs: renovate
|
||||
steps:
|
||||
- uses: actions/checkout@v4.4.0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Tag a release for what Renovate merged
|
||||
@@ -54,8 +55,15 @@ jobs:
|
||||
fi
|
||||
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
|
||||
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
|
||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.18.1-alpine3.24 \
|
||||
node auto-release/next-version.ts "$LATEST" $BUMPS)
|
||||
if [ -z "$BUMPS" ]; then
|
||||
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
|
||||
fi
|
||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
||||
node release-tooling/next-version.ts "$LATEST" $BUMPS)
|
||||
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
|
||||
git show origin/main:src/plugin-host/plugin.ts \
|
||||
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
||||
node release-tooling/contract-version.ts "$NEXT" -
|
||||
echo "Releasing $LATEST -> $NEXT"
|
||||
git tag "$NEXT" origin/main
|
||||
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
|
||||
|
||||
+8
-2
@@ -3,8 +3,10 @@
|
||||
*.log
|
||||
node_modules
|
||||
|
||||
# Playwright E2E outputs (screenshots, html report, traces)
|
||||
e2e-tests/artifacts/
|
||||
# Playwright E2E outputs (screenshots, html report, traces). The dir itself is tracked: an absent
|
||||
# bind-mount source is created by the daemon as root, which the unprivileged runner cannot write.
|
||||
/e2e-tests/artifacts/*
|
||||
!/e2e-tests/artifacts/.gitkeep
|
||||
|
||||
# plugins/ is a drop-in mount point, not committed code — keep it empty (see examples/plugins/ for the reference)
|
||||
/plugins/*
|
||||
@@ -13,3 +15,7 @@ e2e-tests/artifacts/
|
||||
# config/ is a drop-in mount point for your menu/branding override — keep it empty (see examples/config/ for the template)
|
||||
/config/*
|
||||
!/config/.gitkeep
|
||||
|
||||
# locales/ is a drop-in mount point for extra (or replacement) language catalogs — keep it empty
|
||||
/locales/*
|
||||
!/locales/.gitkeep
|
||||
|
||||
@@ -3,109 +3,317 @@
|
||||
Guidance for AI agents and contributors working in this repo. Read `README.md` for
|
||||
commands and layout.
|
||||
|
||||
## Prose discipline
|
||||
|
||||
Every word in this repo is read again on every future task, so prose is a recurring cost. On **any**
|
||||
change, sweep the prose you touched — this file, `README.md`, the example READMEs, and code
|
||||
comments — and cut it back to what a competent reader could not infer:
|
||||
|
||||
- **Delete history.** Git holds it. No "this moved from X", "used to be Y", "was tried and
|
||||
rejected", "(declined twice)", dated changelog entries, or the symptom that prompted a fix. Record
|
||||
the decision and the reason it *currently* turns on, nothing else.
|
||||
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that
|
||||
re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
|
||||
not trimming.
|
||||
- **Delete the self-evident** and anything already stated once elsewhere. **One home per fact** —
|
||||
link to it instead of repeating it; the same sentence in five files is five chances to drift.
|
||||
- **Give every accepted risk an expiry** ("valid while X"), and delete the entry once X stops
|
||||
holding.
|
||||
- **Keep** the surprising why, the footgun, the invariant, the external constraint, and the one-time
|
||||
setup a reader cannot dig out of the code. Once a line has earned its place, make it short and
|
||||
information-dense.
|
||||
|
||||
Trimming is not a separate task to schedule — do it in the same change, every time.
|
||||
|
||||
## How to work with tasks
|
||||
|
||||
Use the file `todo.md`.
|
||||
|
||||
For each todo item, interview the user extensively to deeply understand the scope and goal of each. When done, check the completed task in `todo.md`. Commit all changes and push to a new branch, create a PR and merge it when the CI/CD turns green.
|
||||
For each todo item, interview the user extensively to deeply understand the scope and goal of
|
||||
each. When done, check the completed task in `todo.md`. Commit all changes and push to a new
|
||||
branch, create a PR and merge it when the CI/CD turns green.
|
||||
|
||||
## Project priorities (do not erode)
|
||||
|
||||
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
|
||||
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`,
|
||||
`@larvit/log` — the last itself zero-dependency, for structured/OTLP logging).
|
||||
Prefer the Node standard library; justify any new dependency; do not add
|
||||
frameworks. The app is
|
||||
**stateless — no database**. Auth/identity/OAuth are **Ory sidecar services**
|
||||
(Kratos/Keto/Hydra, backed by Postgres), reached over their REST APIs with
|
||||
built-in `fetch` — no SDK dependency. New capabilities ship as **plugin
|
||||
folders** under `plugins/` that fetch their data from upstream services, not as
|
||||
core code. See `README.md` for the architecture.
|
||||
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
|
||||
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
|
||||
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
|
||||
Postgres database, which the host provisions but never reads or writes inside. Auth/identity/OAuth are
|
||||
**Ory sidecar services** reached over their REST APIs with built-in `fetch` — no SDK. New
|
||||
capabilities ship as **plugin folders** under `plugins/` that get their data from an upstream
|
||||
service or their own database, not as core code.
|
||||
3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
|
||||
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer
|
||||
exact types and limit nullable and multi option types when possible. KISS.
|
||||
4. **Environment-agnostic** — the app never asks *which environment* it runs in; there is
|
||||
no `NODE_ENV` (or equivalent) branching. Every behaviour is an **explicit config
|
||||
toggle** (e.g. `CACHE_TEMPLATES`, `REQUIRE_SECURE_SECRETS`, a future "disable email"),
|
||||
read once in `src/config.ts`. Compose files set the toggles per deployment.
|
||||
5. **Semantic, accessible DOM** — markup is a first-class concern. Use the right element
|
||||
for the job (landmarks, one `<h1>` per page + sane heading order, lists, `<table>` with
|
||||
row/column headers, `<fieldset>`/`<legend>`, `<button>` vs `<a>`); add ARIA only to fill
|
||||
real gaps (`aria-current`, `aria-sort`, labels). Classes/ids name *meaning*, not looks.
|
||||
Prefer native semantics over `div` + ARIA. New views and partials keep this bar.
|
||||
6. **Full, parallel E2E** — every user-facing flow (each page, form, guard, plugin route)
|
||||
has a Playwright E2E test, and a new surface ships *with* its E2E in the same change.
|
||||
Tests stay independent and side-effect-free so the suite runs `fullyParallel` — keep it
|
||||
that way as it grows (never serialise on shared state); parallelism is what keeps it
|
||||
fast. E2E runs in Docker against the live stack — see `README.md`.
|
||||
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the
|
||||
only way to add domain features. It optimises for being **powerful, predictable, and
|
||||
overloadable** (a plugin can take over as much of a page as it wants), and the host
|
||||
**fails loud at boot/discovery** (bad manifest, version mismatch, or conflict stops
|
||||
startup with a clear message) rather than sandboxing at runtime. Runtime crash-isolation
|
||||
is a deliberate **non-goal** — diagnose at deploy time, not in production. Keep this
|
||||
contract stable; see `README.md` → Building plugins.
|
||||
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types;
|
||||
limit nullable and multi-option types.
|
||||
4. **Environment-agnostic** — no `NODE_ENV` branching. Every behaviour is an **explicit config
|
||||
toggle** read once in `src/config.ts`; compose files set them per deployment.
|
||||
5. **Semantic, accessible DOM** — the right element for the job (landmarks, one `<h1>` per page +
|
||||
sane heading order, lists, `<table>` with row/column headers, `<fieldset>`/`<legend>`, `<button>`
|
||||
vs `<a>`); ARIA only to fill real gaps. Classes/ids name *meaning*, not looks.
|
||||
6. **Full, parallel E2E** — every user-facing flow has a Playwright test, shipped in the same change
|
||||
as the surface. Tests stay independent and side-effect-free so the suite runs `fullyParallel`.
|
||||
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the only way to
|
||||
add domain features. It optimises for being powerful, predictable and overloadable, and the host
|
||||
**fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a
|
||||
deliberate **non-goal**.
|
||||
|
||||
## Deliberate architectural deviations (don't re-flag)
|
||||
|
||||
Intentional, reasoned choices — an architecture review should honor them, not re-raise
|
||||
them. Revisit only if the stated reason stops holding.
|
||||
Intentional, reasoned choices — an architecture review should honor them, not re-raise them.
|
||||
Revisit only if the stated reason stops holding.
|
||||
|
||||
- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/`
|
||||
(session-JWT hot path, guards, and the Ory REST clients), `plugin-host/`
|
||||
(discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel + `system.ts`, the
|
||||
`ctx.system` capability surface), and `ui/` (design-system view-models + menu/chrome);
|
||||
`server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests
|
||||
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
|
||||
screens** — even the admin GUI (users/groups/permissions) is a drop-in plugin (`examples/plugins/admin/`),
|
||||
not `src/` code.
|
||||
- **`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
|
||||
(`/`). (Declined twice.)
|
||||
- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web`
|
||||
never touches SMTP. Customization is Kratos' built-in `courier.template_override_path`,
|
||||
not app code — keeping `web` stateless and dependency-light (see [Email](README.md#email)).
|
||||
- **Plugins and config import the host only via package.json `imports`** — `#plugin-api`
|
||||
→ `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts` — never a
|
||||
relative `../../src/*` path. These two barrels are the whole author/operator contract
|
||||
surface; the `src/*` behind them may be refactored freely. Depth-independent and
|
||||
refactor-stable by design — don't "fix" a `#`-import back to a relative path.
|
||||
**One caveat:** `#plugin-api` re-exports the Ory client types for the `ctx.system` surface
|
||||
(`KratosAdmin`/`KetoClient`/`HydraAdmin` + their DTOs and error classes). Those shapes are
|
||||
therefore **contract-visible** — changing them is a plugin-API break needing a major
|
||||
`apiVersion` bump, not a free refactor. Keep the Ory clients stable, or bump the version.
|
||||
- **A plugin/config folder must stay a plain folder — no `package.json` of its own.** Node
|
||||
resolves `#`-specifiers against the nearest parent `package.json`; a `package.json` inside
|
||||
the folder becomes its own scope and `#plugin-api`/`#menu-config` stop resolving. Accepted
|
||||
cost of the `#`-import contract (fits the stateless, no-per-plugin-deps ethos). A plugin
|
||||
kept in its own repo typechecks against the barrel only when mounted under the host tree
|
||||
(or by adding a local `imports` map / vendored stub).
|
||||
### Structure & contracts
|
||||
|
||||
- **`src/` is grouped by concern**, not flat — `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`,
|
||||
with `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` at the root; tests are
|
||||
co-located. Add a new module to the folder owning its concern. The core ships **no domain
|
||||
screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
|
||||
- **Plugins and config import the host only through a barrel** — `@plainpages/plugin-api` →
|
||||
`plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts`,
|
||||
never a relative `../../src/*` path. These two barrels are the whole contract surface; don't "fix"
|
||||
either back to a relative path. Three consequences:
|
||||
- `@plainpages/plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
|
||||
DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
|
||||
`apiVersion` bump, not a free refactor.
|
||||
- **The barrel is a package, not a `#`-import, so a plugin folder may carry its own
|
||||
`package.json`** and depend on npm packages (README → Plugin dependencies). The Dockerfile links
|
||||
it into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own
|
||||
`node_modules`: two instances of the barrel break `instanceof` across the boundary, which
|
||||
`plugin-api.test.ts` guards by asserting both paths reach one module.
|
||||
- **Plugin storage hands over credentials, not a client** (README → Plugin storage). The host takes
|
||||
`postgres` to run the provisioning DDL, and `storage-provisioning.ts` is the only module importing
|
||||
it — `storage.ts` beside it stays pure so `web` never loads a driver (`src/postgres.test.ts` guards
|
||||
both halves, because one value imported from the wrong module breaks it invisibly). It is never
|
||||
re-exported through the barrel, so no driver shape enters the contract. Three properties hold the design together, so
|
||||
don't trade one away in isolation: passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than
|
||||
stored, which is what keeps the host stateless — whoever holds that secret holds every plugin
|
||||
database, so it ranks with the DB password itself; the provisioning DSN reaches `bootstrap` only
|
||||
(`src/compose.test.ts` guards the split); and provisioning never drops anything, so uninstalling a
|
||||
plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the
|
||||
ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — incidental, not a
|
||||
packaging promise, and a plugin must still depend on its own driver.
|
||||
- **Plugin settings are declared, not discovered** (README → Plugin settings). `settings.ts` is pure and
|
||||
takes the env as an argument, so the whole matrix unit-tests without a stack. Four rules carry the
|
||||
design: the prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`, because a plugin id `db` with key
|
||||
`url` would otherwise name the host's own `PLUGIN_DB_URL`; keys are camelCase so the
|
||||
`camelCase → SNAKE_CASE` mapping is total and no two keys collide, with the residual cross-plugin
|
||||
collision caught by `findConflicts`; `required` and `default` are mutually exclusive, which is what
|
||||
lets `SettingsOf` type a declared key as present rather than `T | undefined`, so no plugin author
|
||||
casts; and a secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`
|
||||
— not even as a mask or a length. An author mistake is refused at discovery, a bad operator value
|
||||
refuses the boot, and a stray `PLUGIN_SETTING_` variable only warns (the orphan-database precedent).
|
||||
- **The trust boundary is the `web` process, not the plugin.** Per-plugin databases and roles bound
|
||||
*accidents*, not hostile plugins: `PLUGIN_DB_SECRET` is in `web`'s environment during `onBoot`, and
|
||||
a plugin already holds `ctx.system`'s Ory admin clients — so cross-plugin DB isolation is
|
||||
containment, and README says so rather than implying a sandbox. Consistent with priority #7
|
||||
(crash-isolation is a non-goal). `server.ts` still deletes the secret from `process.env` right
|
||||
after `loadConfig`, which is before discovery imports any plugin module — the ordering is the whole
|
||||
point, so move it earlier if anything, **never later**. **Valid while plugins are
|
||||
operator-installed code, not third-party uploads.**
|
||||
- **`ory/postgres/init/init.sql` is the only home for the Ory databases' ACL** — don't re-assert the
|
||||
`REVOKE CONNECT` from `bootstrap`. `REVOKE` only *warns* when the caller doesn't own the database,
|
||||
so under the least-privilege provisioning account the README recommends it would report success
|
||||
while changing nothing, and it hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a server with no
|
||||
`kratos`. It runs only on **first init**, so a revoke added to it later never reaches a volume that
|
||||
already exists — `docker compose down -v` is the dev remedy, a deployed install needs a migration.
|
||||
- **`bootstrap.ts` stays under `src/auth/`** even though it now provisions plugin databases as well
|
||||
as seeding Ory. It is the one-shot service's entrypoint, not an auth module; moving it to
|
||||
`src/bootstrap.ts` would edit `compose.yml`, five e2e compose files and `src/compose.test.ts` for a
|
||||
rename. Reconsider when a third seeding concern lands.
|
||||
- **`BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook.** Adding
|
||||
to the context costs a minor bump and removing one a major, so the shape errs small elsewhere. Pools
|
||||
handed to a plugin are reaped on process exit — revisit if a plugin ever needs an orderly drain.
|
||||
- **`config/` is still a plain dir — no `package.json` of its own**, or `#menu-config` resolves
|
||||
against that instead and boot fails loud. An operator's menu override has no use for
|
||||
dependencies; if that changes, it needs the same package treatment.
|
||||
- **A plugin `package.json` without `"type": "module"` is refused, not warned.** Allowing it costs a
|
||||
warning and a re-parse per file, not a break — Node detects module syntax, so even a `.js` helper
|
||||
loads — and an operator on a read-only third-party mount cannot apply the remedy. Refused anyway
|
||||
because the direction is safe: refuse→warn relaxes freely, warn→refuse breaks installed plugins.
|
||||
**Valid while nothing is installed in the wild.**
|
||||
- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to
|
||||
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirror folders are in
|
||||
`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
|
||||
mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise).
|
||||
- **Authorization vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships
|
||||
no namespaces — all four in `ory/keto/namespaces.keto.ts` are ours. `Permission` follows RBAC,
|
||||
where a permission is one operation ("read shifts") and a role is a *bundle* of them; a route
|
||||
gates on one operation, so it gates on a permission, and a bundle is just a group with several
|
||||
grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the
|
||||
separate per-row tier.
|
||||
- **Plainpages says "user" everywhere; Ory's word for it is "identity".** Kratos calls the record
|
||||
an identity, but Ory's own docs state it uses that term *interchangeably* with "users" and
|
||||
"accounts" — so this is house style, not a renamed concept, and "user" is the word readers
|
||||
already know (Nielsen's heuristic #2: match between the system and the real world). One note in
|
||||
README → Auth records the mapping so nobody has to rediscover it. The single exception is the
|
||||
`Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape and keeps Ory's
|
||||
name — don't rename that one.
|
||||
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in
|
||||
`tsconfig.include` and resolve the host through the barrels, so each typechecks in place *and*
|
||||
copies across unchanged. Never commit real plugins/config into the root mount dirs — they ship empty.
|
||||
- **`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 (`/`).
|
||||
- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
|
||||
`dashboard`) and an `onRequest` short-circuit build their 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.
|
||||
- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` never
|
||||
touches SMTP. Customization is Kratos' `courier.template_override_path`, not app code.
|
||||
|
||||
### Authorization
|
||||
|
||||
- **Vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships no namespaces —
|
||||
all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one operation ("read shifts");
|
||||
a role is a *bundle*, which here 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. **Enforced at discovery**
|
||||
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav
|
||||
`permission` and every declared name), fail-loud like any other manifest rule — not only in the
|
||||
admin GUI, which an operator removes by not copying it in.
|
||||
- **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 list the admin screens offer. Hence **no Permissions admin screen**: nothing
|
||||
in a GUI invents a name, and holding one is a property of a user or group, edited as a checkbox
|
||||
list there. A Keto tuple naming something no installed plugin declares gates nothing, is not
|
||||
offered, and is never revoked by an unrelated save — the picker only speaks for what it showed.
|
||||
- `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`): users are
|
||||
the *host's*, and cross-plugin sharing is a goal. Cost: collision-freedom is a convention rather
|
||||
than structural. Accepted — the alternative penalizes the sharing case.
|
||||
- **Declaring a permission stays optional.** Mandatory declaration would let `findConflicts` see all
|
||||
overlaps, but would then warn on exactly that legitimate sharing case. Shape is enforced;
|
||||
declaration is not.
|
||||
- `ADMIN_PERMISSIONS` **defaults to empty**, and **an unusable value is dropped with a warning,
|
||||
never fatal** — fail-loud belongs at the manifest boundary where a developer authored the
|
||||
mistake, whereas `bootstrap` gates `web`, so refusing operator env takes the whole stack down
|
||||
(`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one). The seed is
|
||||
a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
|
||||
`docker compose up -d`, not `restart web`. `bootstrap`'s matching `./plugins` mount belongs in
|
||||
`compose.override.yml` and nowhere else: 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 `@plainpages/plugin-api`.** Inside the admin
|
||||
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
||||
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
||||
verb — a route table must answer "what does this need?" on its own.
|
||||
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
|
||||
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
|
||||
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
|
||||
form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`. Two
|
||||
grant-specific guards go with it: you cannot revoke your own **direct** grants (self-lockout would
|
||||
need a `curl` against Keto to undo), and a permission held *through a group* renders
|
||||
ticked-but-disabled, because unticked stated the opposite of the truth. **Known gap:** the group
|
||||
paths are unguarded — unticking a permission on a group you belong to, leaving it, or deleting it
|
||||
can still strip your own access. The robust "last effective holder" check needs a reverse Keto
|
||||
query and is deferred.
|
||||
- **`users:write` and `groups:write` are equivalent to full administrative access**: `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.
|
||||
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed
|
||||
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
|
||||
Kratos' wire shape — don't rename it.
|
||||
|
||||
### i18n
|
||||
|
||||
- **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. A cookie would make a
|
||||
page's language invisible in its address and unshareable; the cost is that a plugin wraps 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.
|
||||
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell, `pagination`,
|
||||
`filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every href in
|
||||
`localeHref`; nav and sign-in are wrapped in `chrome.ts`; the two GET forms carry it as a hidden
|
||||
`locale` input, since a GET submit replaces the whole query string. **A form's `action` counts as a
|
||||
link** — sign-out, consent and auth-card forms carry it too, or picking a language and then saving
|
||||
anything drops back to `Accept-Language`. The obligation stays on the building block, never on each
|
||||
call site. `ctx.localeHref` remains for hrefs a plugin's own markup emits. The one round-trip that
|
||||
cannot carry it is the Kratos sign-in POST (absolute off-site URL).
|
||||
- **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, 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, merged after a handler's
|
||||
`data` so a collision loses the key instead of breaking the shell.
|
||||
- **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` requires — and a
|
||||
mismatch stops startup. A plugin may ship fewer locales than the host (its strings fall back to
|
||||
`en-US` per key), never one the host lacks.
|
||||
- **`locales/` at the repo root is a drop-in mount**, like `plugins/` and `config/` — `locales/<tag>.ts`
|
||||
for the core, `locales/plugins/<id>/<tag>.ts` for a plugin; a new tag adds a language, an existing
|
||||
one replaces that catalog wholesale. Adding a language must never require forking the image. The
|
||||
SHIPPED `en-US` 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).
|
||||
- **The language picker is on every page, POST-rendered ones included.** A POST-rendered URL often
|
||||
answers no GET (`POST /admin/users/:id/recovery`), so the host resolves the picker's target
|
||||
(`app.ts` → `switchBase`): this path when it answers GET, else the same-origin Referer, else `/`.
|
||||
Accepted cost: switching language there leaves that POST's own result behind. Valid while the picker
|
||||
is expected on literally every page — if that softens, hiding it after a POST is simpler.
|
||||
- **An unknown translation key renders as itself.** That single rule 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;
|
||||
one carrying markup uses `<%- %>`, and then its `{{vars}}` are escaped at the call site. Don't move
|
||||
escaping into `t()` — every other value in a view would become the odd one out.
|
||||
- **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` 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. Valid while no deployment needs an RTL language.
|
||||
|
||||
### UI
|
||||
|
||||
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
|
||||
owns open/close — the only zero-JS way to dismiss by clicking outside — and the panel sits in the
|
||||
top layer, so a row kebab is not clipped by `.table-wrap`'s `overflow`. Four rules hold it
|
||||
together: the panel carries **`position-anchor: auto`** (a bare `anchor()` resolves to nothing in
|
||||
all three engines); it stays the trigger's **next sibling inside the `.menu` wrapper**, which the
|
||||
open-state style and the old-browser fallback both read; the partial **requires a caller-named
|
||||
`id`** and fails loud without one, since that is the `popovertarget` idref (never generate one —
|
||||
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
|
||||
`aria-haspopup` is written**, because a zero-JS invoker cannot keep the first truthful and the
|
||||
second would promise `role="menu"` semantics these panels don't implement. `<details>` stays where
|
||||
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
||||
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
|
||||
the two in step.
|
||||
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
|
||||
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
|
||||
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
|
||||
unknown sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test
|
||||
catches anything reaching the nav).
|
||||
|
||||
### Build, test & release
|
||||
|
||||
- **Deps install to `/node_modules`, above `WORKDIR /app`** — Node resolves upward, so dev's `.:/app`
|
||||
bind mount has nothing to shadow. Not a volume at `/app/node_modules`: the daemon creates a mount
|
||||
destination as root whatever `--user` says, leaving a root-owned dir in the checkout. Nothing may
|
||||
sit at that path now — it shadows `/node_modules` silently (`src/compose.test.ts` guards the compose
|
||||
files, `.dockerignore` the image).
|
||||
- **A container whose output a human then edits or deletes runs as `--user "$(id -u):$(id -g)"`** —
|
||||
the E2E runner (artifacts) and a lockfile edit, or the output is root-owned and needs `sudo`, which
|
||||
a dev box may not have. Not universal: `bootstrap` writes `jwks.json` as root when it is absent on
|
||||
first boot; the committed dev key makes that rare, and when it happens the rotation runbook's
|
||||
host-side `>` needs the file re-owned first (valid while the dev key ships committed). Three
|
||||
consequences: `e2e-tests/artifacts/` is *tracked* (`.gitkeep`), since an absent bind-mount source is
|
||||
daemon-created as root and that uid then cannot write it (README → Upgrading); the runner image sets
|
||||
`HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an unwritable `/`; and
|
||||
rootless Docker wants the flag *dropped*, container root already being the invoking user. Baking a
|
||||
`USER` in instead does not work — the image's `pwuser` is 1001 and no fixed uid matches every host.
|
||||
`src/compose.test.ts` guards every documented command, `src/ci-gate.test.ts` the gate's own.
|
||||
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
|
||||
`e2e-tests/console-guard.ts`, which fails a test on a console error/warning or uncaught exception on
|
||||
any page it opened. A zero-JS app has nothing to say in the console, so the bar is *zero* rather than
|
||||
a curated tolerance list; the two exceptions are narrow — a module-level allowance for the COOP header
|
||||
Chromium drops (the e2e stacks serve plain http over container hostnames), and `allowConsole(re)` for
|
||||
a test whose own page provokes a message on purpose. `src/e2e-console-guard.test.ts` locks the wiring
|
||||
in the *unit* gate, since a spec importing `test` straight from Playwright — or minting a page with
|
||||
a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. Accepted cost: a page
|
||||
outliving its test can log late and fail the next one.
|
||||
- **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 parallel runs don't collide, and a
|
||||
console message only appears in the engine that renders the page (`ORY_FREE` in
|
||||
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
|
||||
so widening them means a stack per engine.
|
||||
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** 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` would otherwise read as docs and skip the gate over a source file that
|
||||
was gone. `src/ci-gate.test.ts` locks the flags as a
|
||||
*text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
|
||||
`release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
|
||||
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
|
||||
skipped. `README.md` is the one markdown a test reads — `release-tooling/contract-version.test.ts`
|
||||
checks its `apiVersion` samples — and a README-only change skips that check; accepted, because
|
||||
those samples are illustrative and the copies that matter (`examples/`, `views/`, the template) are
|
||||
gated. **Valid while no markdown file is rendered or executed.**
|
||||
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
||||
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`:
|
||||
concurrent jobs can race (one job's logout can 401 another's push — recover by re-running),
|
||||
and tokens sit in that file between login and logout. Same class: concurrent runs share 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
|
||||
`concurrency` group if it ever bites.
|
||||
`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), and tokens sit in that
|
||||
file between login and logout. Same class: concurrent runs share 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 `concurrency` group if it ever bites.
|
||||
|
||||
## Docker only — no host tooling
|
||||
|
||||
@@ -123,73 +331,97 @@ docker compose -f compose.yml up --build -d # production
|
||||
|
||||
`README.md` serves two readers, in this order — preserve it when editing:
|
||||
|
||||
1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets
|
||||
the stack up (`docker compose up`, sign in) and a *minimal* plugin live. Nothing comes
|
||||
before Quick start — no philosophy, no rationale. Keep its commands copy-pasteable and the
|
||||
example plugin as small as possible; deeper detail lives in its own section, linked.
|
||||
2. **Returning developer (rest).** A **Contents** ToC immediately after Quick start, then
|
||||
sections ordered by **what a developer adopting Plainpages reaches for, in priority
|
||||
order** — not by architectural layering. The value that sets the order: getting up and
|
||||
running **building plugins** comes first, then **configuring and securing** the system
|
||||
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
|
||||
deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
|
||||
Users, groups & permissions → Building plugins → menu/blocks/interactivity →
|
||||
Configuration → Auth → Email → Architecture → Testing → Production → Observability → the
|
||||
JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place
|
||||
it by this value (how early an adopter needs it), not by where it sits in the stack.
|
||||
1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets the
|
||||
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
|
||||
copy-pasteable; deeper detail lives in its own section, linked.
|
||||
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
|
||||
by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups
|
||||
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
||||
Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
|
||||
map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
|
||||
permissions precedes Building plugins** because a manifest's `permission:` gate is unreadable
|
||||
without the model, and it is the one home for that model.
|
||||
|
||||
**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
|
||||
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
|
||||
one home, linking to it rather than restating (credentials, env vars, rotation steps).
|
||||
|
||||
**Don't document internals here.** How a script reaches a decision, why one run behaved
|
||||
differently from another, what a function guards — a developer doesn't need it day to day and
|
||||
can read it off the code or a run's log in seconds. Prose like that only makes the README
|
||||
longer and harder to consume, for humans and machines alike. It belongs in the code it
|
||||
describes, or nowhere. The README earns its length on what you cannot dig out: how to use and
|
||||
operate Plainpages, the external contracts, and one-time setup (secrets, accounts, tokens).
|
||||
Same test before adding a row to a table or the file map — a clause, not a paragraph.
|
||||
Keep the ToC in sync when you add/rename/remove an `H2`/`H3`. **Don't document internals** — how a
|
||||
script reaches a decision, what a function guards; a developer reads that off the code in seconds.
|
||||
The README earns its length on how to use and operate Plainpages, the external contracts, and
|
||||
one-time setup. A file-map or table row gets a clause, not a paragraph.
|
||||
|
||||
## Rules
|
||||
|
||||
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
|
||||
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or
|
||||
decorators. Import local modules with their `.ts` extension.
|
||||
- **No `.mjs`.** Write modules as `.ts` (Prio 1) — even standalone scripts run in bare
|
||||
`node:24` containers (the e2e mock servers, `examples/shifts-upstream/server.ts`): Node
|
||||
strips types and detects ESM from syntax, no package.json needed. If a file genuinely
|
||||
must be plain JavaScript, use `.js` (Prio 2); `"type": "module"` is already set in both
|
||||
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or decorators. Import
|
||||
local modules with their `.ts` extension.
|
||||
- **No `.mjs`.** Write modules as `.ts` — even standalone scripts run in bare `node:24` containers.
|
||||
If a file genuinely must be plain JavaScript, use `.js`; `"type": "module"` is set in both
|
||||
`package.json`s, so `.js` is ESM.
|
||||
- **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit.
|
||||
- Before finishing a change, run the typecheck and tests above; both must pass.
|
||||
- Tests use the built-in `node --test` runner — no test framework dependency.
|
||||
- English everywhere. Keep code comments short and information-dense. Self explained code
|
||||
without any comment at all is the preferred solution.
|
||||
- Do not comment about history in the code or README. Like "This function included X before,
|
||||
but it moved to Y".
|
||||
- Do not comment about the absence of things, if it is not very unexpected. Banned is things
|
||||
like "This function does not calculate pi, that is done in function Z".
|
||||
- Pin all dependencies and Docker images to exact, human-readable **semantic
|
||||
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
|
||||
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
|
||||
`node:24.16.0-alpine3.24`).
|
||||
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version the
|
||||
plugin was built against — bumped by hand on rebuild, **never** the host's
|
||||
`HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the
|
||||
host, so `checkApiVersion` can never fire and a breaking change slips through silently.
|
||||
- **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler
|
||||
per `{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed
|
||||
`%`-encoding — no manual path-slicing/decoding). Don't funnel many routes into one dispatcher
|
||||
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
|
||||
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
|
||||
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
|
||||
- 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.
|
||||
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
|
||||
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
|
||||
and not "ids=x,y".
|
||||
- English everywhere.
|
||||
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
|
||||
ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images
|
||||
by tag.
|
||||
- **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist*: its
|
||||
rules name exactly what carries the trailer, so a dependency outside them never escalates the
|
||||
release version and nothing fails to say so. A new manifest, compose file, custom manager or dep
|
||||
type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing
|
||||
and let it ride the next patch.
|
||||
- **`HOST_API_VERSION` *is* the release version.** Its `major.minor` must equal the release tag's, and
|
||||
both release paths refuse a tag that disagrees (`release-tooling/contract-version.ts`). The patch
|
||||
digit may lag on purpose: `checkApiVersion`
|
||||
ignores patch, and auto-release cuts patch releases with no commit to bump a constant in. So a
|
||||
dependency update big enough to force a **minor** is plugin-visible by definition — `auto-release`
|
||||
stops rather than tagging, and the fix is to bump `HOST_API_VERSION` to that `X.Y.0` in a PR, merge
|
||||
it, then tag. Never bump it to "catch up" with a patch release. **The contract surface
|
||||
includes `views/partials/*.ejs`** — 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. Know the hole
|
||||
that leaves: discovery fails loud on a bad `apiVersion`, but `include("menu", { open: true })`
|
||||
silently ignores a dropped option, so the partial vocabulary is a surface the version check cannot
|
||||
police for you.
|
||||
- **The contract surface also includes the packaging promises** (README → Plugin dependencies): the
|
||||
barrel is ambient at `/node_modules` with nothing for a plugin to declare, `"type": "module"` is
|
||||
mandatory, and the host neither upgrades nor dedupes a plugin's dependencies. Same hole as the
|
||||
partials — move the publish point, rename the package or start hoisting and every installed plugin
|
||||
breaks with no version signal. Note the promise is deliberately *not* "your deps are yours alone":
|
||||
build-time dedupe for baked images stays open, module-instance sharing stays unpromised.
|
||||
- **Publishing `@plainpages/plugin-api` to a registry is deferred, not rejected.** Today it is
|
||||
`private` and shaped as a shim — `index.ts` re-exports `../src/…`, so `npm pack` would ship a
|
||||
broken tree. The trigger is the first plugin author outside this repo — the first who cannot
|
||||
typecheck against a mounted host tree. Whoever does it must first make the artifact self-contained
|
||||
(types-only `.d.ts`, or move the barrel into `plugin-api/`).
|
||||
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version it 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, so `checkApiVersion` can never fire.
|
||||
- **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler per
|
||||
`{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding).
|
||||
Don't funnel many routes into one dispatcher 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
|
||||
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.
|
||||
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer agents.
|
||||
- **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") 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:** slightly different wording is often the right
|
||||
call, and a build-failing check takes that judgment away.
|
||||
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POSTing it, for
|
||||
example on list pages with filters and pagination. Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not
|
||||
`ids=x,y`.
|
||||
|
||||
## Comments
|
||||
|
||||
Default to **no comment**. Delete one that restates the adjacent code, repeats a convention used
|
||||
elsewhere, justifies self-evident code, or records history. Write one only for what a competent
|
||||
reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external
|
||||
constraint. See [Prose discipline](#prose-discipline).
|
||||
|
||||
+12
-6
@@ -1,14 +1,20 @@
|
||||
# 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
|
||||
|
||||
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
|
||||
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
|
||||
# Dev deps kept so typecheck/test run in-image.
|
||||
COPY package.json package-lock.json .npmrc /deps/
|
||||
RUN cd /deps && npm ci && mv node_modules /node_modules && rm -rf /deps
|
||||
|
||||
# The barrel as a package, so a plugin folder can own a package.json. Linked because it re-exports /app.
|
||||
RUN mkdir -p /node_modules/@plainpages && ln -s /app/plugin-api /node_modules/@plainpages/plugin-api
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Reproducible install from the lockfile. Dev deps kept so typecheck/test run in-image.
|
||||
COPY package.json package-lock.json .npmrc ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# The host uid running a lockfile edit has no home here, so npm's cache would land in unwritable /.
|
||||
ENV npm_config_cache=/tmp/.npm
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
CMD ["node", "src/server.ts"]
|
||||
|
||||
@@ -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.
|
||||
# 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.
|
||||
# --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() {
|
||||
local base changed
|
||||
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
|
||||
base=$(git merge-base refs/remotes/origin/main HEAD 2>/dev/null) || return 1
|
||||
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
|
||||
[ -n "$changed" ] || return 1
|
||||
! printf '%s\n' "$changed" | grep -qvE '\.md$'
|
||||
@@ -56,13 +60,42 @@ echo "$units" | grep -E '^. (tests|pass|fail) ' || true
|
||||
count=$(echo "$units" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | head -1 || true)
|
||||
[ "${count:-0}" -ge 50 ] || { echo "only ${count:-0} unit tests ran — test glob broken?"; exit 1; }
|
||||
|
||||
# Plugin storage against a real Postgres. The step above runs --no-deps, so this suite's integration
|
||||
# test skips there — and it is the only thing proving the DDL actually grants what it claims, rather
|
||||
# than that the SQL text is the text we wrote. `node --test` counts a skip, so the floor won't catch it.
|
||||
step "Plugin storage (real Postgres)"
|
||||
# Own project name, like every E2E suite below: the default project is the DEV stack, so a bare
|
||||
# `down -v` here would delete the operator's pgdata — Ory identities and every plugin database.
|
||||
# --wait, because initdb on a cold volume outlasts the suite's connect timeout.
|
||||
storage_rc=0
|
||||
storage_proj=plainpages-storage
|
||||
storage_files=(-p "$storage_proj" -f compose.yml) # no override merge, like the e2e suites below
|
||||
storage_dsn="postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory"
|
||||
storage_out=""
|
||||
docker compose "${storage_files[@]}" up -d --wait postgres >/dev/null || storage_rc=$?
|
||||
# `if`, not `&&`: a false `&&` returns non-zero, which under `set -e` would exit before teardown.
|
||||
if [ "$storage_rc" -eq 0 ]; then
|
||||
# --build like the e2e suites: this stack mounts no source, so without it the step would test
|
||||
# whatever `web` image that project last baked.
|
||||
storage_out=$(docker compose "${storage_files[@]}" run --build --rm --no-deps \
|
||||
-e "PLUGIN_DB_ADMIN_URL=$storage_dsn" \
|
||||
web node --test src/plugin-host/storage.test.ts 2>&1) || storage_rc=$?
|
||||
fi
|
||||
docker compose "${storage_files[@]}" down -v >/dev/null 2>&1 || true # also covers a failed `up`
|
||||
echo "$storage_out" | grep -E '^. (tests|pass|fail|skipped) ' || true
|
||||
[ "$storage_rc" -eq 0 ] || { echo "$storage_out"; echo "plugin storage integration tests failed (exit $storage_rc)"; exit "$storage_rc"; }
|
||||
# A skip here exits 0 and proves nothing — the same trap the unit floor above guards against.
|
||||
echo "$storage_out" | grep -qE '^. skipped 0$' || { echo "storage integration test skipped — PLUGIN_DB_ADMIN_URL not wired through"; exit 1; }
|
||||
|
||||
# Run one E2E suite against its OWN named stack, then always tear it down (even on failure). The
|
||||
# per-suite project name keeps a flaky teardown from leaking containers/volumes into the next suite.
|
||||
# --user: the runner writes screenshots + the report into the checkout, so they must belong to
|
||||
# whoever ran the gate — root-owned output needs sudo to delete, and a dev box may have none.
|
||||
e2e() {
|
||||
step "E2E: $1"
|
||||
local proj="plainpages-e2e-$(basename "$1" .yml | tr '.' '-')" # dots aren't valid in a compose project name
|
||||
local rc=0
|
||||
docker compose -p "$proj" -f compose.yml -f "$1" run --build --rm e2e || rc=$?
|
||||
docker compose -p "$proj" -f compose.yml -f "$1" run --user "$(id -u):$(id -g)" --build --rm e2e || rc=$?
|
||||
docker compose -p "$proj" -f compose.yml -f "$1" down -v >/dev/null 2>&1 || true
|
||||
[ "$rc" -eq 0 ] || { echo "E2E suite $1 failed (exit $rc)"; exit "$rc"; }
|
||||
}
|
||||
@@ -78,7 +111,7 @@ e2e e2e-tests/compose.full.yml # full browser flow: login (password + SSO),
|
||||
step "E2E: e2e-tests/compose.devstack.yml (dev-stack login: localhost works + 127.0.0.1 canonicalised)"
|
||||
devstack_files=(-f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml)
|
||||
rc=0
|
||||
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" run --build --rm e2e || rc=$?
|
||||
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" run --user "$(id -u):$(id -g)" --build --rm e2e || rc=$?
|
||||
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" down -v >/dev/null 2>&1 || true
|
||||
[ "$rc" -eq 0 ] || { echo "E2E suite e2e-tests/compose.devstack.yml failed (exit $rc)"; exit "$rc"; }
|
||||
|
||||
|
||||
+28
-6
@@ -1,5 +1,9 @@
|
||||
# Development overrides, merged automatically by `docker compose up`.
|
||||
# Mounts the source for live editing and restarts on change via `node --watch`.
|
||||
|
||||
# web connects with it and bootstrap provisions against it, so the two must agree — one home.
|
||||
x-plugin-db-url: &plugin-db-url postgres://postgres:5432
|
||||
|
||||
services:
|
||||
web:
|
||||
command: node --watch src/server.ts
|
||||
@@ -13,21 +17,39 @@ services:
|
||||
CACHE_TEMPLATES: "false"
|
||||
LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines)
|
||||
LOG_LEVEL: "debug" # verbose by default while developing (base defaults to info)
|
||||
# Point plugin storage at the bundled Postgres, so a dropped-in plugin declaring `storage`
|
||||
# works with no further config; the secret falls back to the dev throwaway (config.ts).
|
||||
PLUGIN_DB_URL: *plugin-db-url
|
||||
REQUIRE_SECURE_SECRETS: "false"
|
||||
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
|
||||
SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/
|
||||
PLUGIN_SETTING_SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
# 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
|
||||
|
||||
# Mirror web's source mount so bootstrap discovers the same plugins *and* runs the same code. Only
|
||||
# dev needs saying: the base file gives both services the image's baked copy, and it is the
|
||||
# `.:/app` above — dev-only — that makes web diverge onto the host tree. Without the mirror,
|
||||
# bootstrap silently runs whatever `src/` was baked at image-build time, so an edit to
|
||||
# bootstrap.ts appears to do nothing until someone remembers `--build`.
|
||||
# It belongs here and not in the base file, where it would desynchronise prod and collide with the
|
||||
# e2e stacks, which bind individual plugins *inside* /app/plugins.
|
||||
bootstrap:
|
||||
# Provisions the plugin databases web connects to above, as the dev superuser.
|
||||
environment:
|
||||
PLUGIN_DB_ADMIN_URL: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory
|
||||
PLUGIN_DB_URL: *plugin-db-url
|
||||
REQUIRE_SECURE_SECRETS: "false" # dev derives from the throwaway, as web does
|
||||
volumes:
|
||||
- .:/app
|
||||
|
||||
# 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
|
||||
# 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.
|
||||
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
||||
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
|
||||
shifts-upstream:
|
||||
image: node:24.18.1-alpine3.24
|
||||
image: node:24.19.0-alpine3.24
|
||||
command: node /srv/server.ts
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
@@ -36,7 +58,7 @@ services:
|
||||
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
||||
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.30.6
|
||||
image: axllent/mailpit:v1.31.0
|
||||
ports:
|
||||
- "8025:8025"
|
||||
restart: unless-stopped
|
||||
|
||||
+23
-5
@@ -17,10 +17,16 @@ services:
|
||||
CACHE_TEMPLATES: "true"
|
||||
CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}
|
||||
LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector
|
||||
# Per-plugin Postgres storage. Explicit toggle: unset ⇒ off, and a plugin declaring `storage`
|
||||
# refuses to boot rather than run without its data. The URL carries no credentials — each
|
||||
# plugin's own password is derived from the secret (README → Plugin storage).
|
||||
PLUGIN_DB_SECRET: ${PLUGIN_DB_SECRET:-}
|
||||
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-}
|
||||
REQUIRE_SECURE_SECRETS: "true"
|
||||
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
|
||||
# Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
|
||||
# consent handler) + the one-shot bootstrap (admin + JWKS seed).
|
||||
# consent handler) + the one-shot bootstrap (admin + JWKS seed). Postgres too: a plugin that
|
||||
# declares `storage` opens its connection in onBoot, before the server listens.
|
||||
depends_on:
|
||||
bootstrap:
|
||||
condition: service_completed_successfully
|
||||
@@ -30,17 +36,20 @@ services:
|
||||
condition: service_healthy
|
||||
hydra:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
# verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
|
||||
# Read-only — bootstrap is the only writer.
|
||||
volumes:
|
||||
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
|
||||
restart: unless-stopped
|
||||
|
||||
# Ory's storage only (Kratos/Keto/Hydra) — the web app never connects here.
|
||||
# init/init.sql creates one database per service. Dev defaults below; supply
|
||||
# The stack's storage: one database per Ory service (init/init.sql), plus one per plugin that
|
||||
# declares `storage` — bootstrap creates those at boot, since only it holds superuser credentials.
|
||||
# A plugin connects as its own role from inside web. Dev defaults below; supply
|
||||
# POSTGRES_USER/PASSWORD via env in production.
|
||||
postgres:
|
||||
image: postgres:18.4-alpine3.23
|
||||
image: postgres:18.6-alpine3.23
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-ory}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ory}
|
||||
@@ -127,16 +136,25 @@ services:
|
||||
condition: service_healthy
|
||||
keto:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||
# Base permissions for the demo admin; bootstrap also grants every discovered plugin's declared
|
||||
# permission names (so the reference plugin — and any drop-in — works out of the box).
|
||||
ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-admin}
|
||||
ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-}
|
||||
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
|
||||
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
||||
KETO_WRITE_URL: http://keto:4467
|
||||
KRATOS_ADMIN_URL: http://kratos:4434
|
||||
# The superuser DSN that creates each plugin's database and role lives ONLY here — never in
|
||||
# web, so plugin code cannot read it out of its own environment. Unset ⇒ a plugin declaring
|
||||
# `storage` fails the seed loudly. The secret must match web's; both derive the same passwords.
|
||||
PLUGIN_DB_ADMIN_URL: ${PLUGIN_DB_ADMIN_URL:-}
|
||||
PLUGIN_DB_SECRET: ${PLUGIN_DB_SECRET:-}
|
||||
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-} # only to refuse a mismatch: what bootstrap creates, web connects to
|
||||
REQUIRE_SECURE_SECRETS: "true" # refuse the throwaway secret here too, before any role is created
|
||||
volumes:
|
||||
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
|
||||
command: node src/auth/bootstrap.ts
|
||||
|
||||
@@ -9,4 +9,8 @@ RUN npm ci
|
||||
|
||||
COPY e2e-tests/ ./
|
||||
|
||||
# Runs as the invoking `--user` so artifacts land owned by them, not root — and an arbitrary uid has
|
||||
# no passwd entry here, so its home would be the unwritable `/`. npm's cache follows HOME.
|
||||
ENV HOME=/tmp
|
||||
|
||||
CMD ["npx", "playwright", "test"]
|
||||
|
||||
@@ -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
|
||||
// real Ory stack via e2e-tests/compose.auth.yml, where the session→JWT TTL is shortened to 8s and the
|
||||
@@ -83,7 +83,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
|
||||
const claims1 = jwtClaims(jwt1);
|
||||
expect(claims1.email).toBe(ADMIN_EMAIL);
|
||||
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
|
||||
expect(claims1.permissions, "permissions 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.
|
||||
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);
|
||||
const claims2 = jwtClaims(jwt2);
|
||||
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp);
|
||||
expect(claims2.permissions, "re-mint re-reads permissions 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.
|
||||
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# visual suite (e2e-tests/compose.visual.yml) covers the design system; this is its full-stack counterpart:
|
||||
# real Postgres + Kratos + Keto + bootstrap + web, with a SHORT tokenizer TTL (ory/kratos/e2e.yml)
|
||||
# and zero clock skew, so the JWT lapses and re-mints within seconds instead of ~10m.
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml run --user "$(id -u):$(id -g)" --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml down -v # tear down after
|
||||
services:
|
||||
web:
|
||||
@@ -30,6 +30,18 @@ services:
|
||||
timeout: 4s
|
||||
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.
|
||||
#
|
||||
# `admin` rides along on purpose: it was this setting's default until 2026-08-05 and is not a legal
|
||||
# `<resource>:<action>` name, so it is exactly the leftover an upgrading deployment carries. An
|
||||
# earlier revision made that fatal, and bootstrap gates `web` — so if the boot ever refuses operator
|
||||
# env again, `web` never turns healthy and this suite fails instead of CI going green over it.
|
||||
bootstrap:
|
||||
environment:
|
||||
ADMIN_PERMISSIONS: admin,users:read
|
||||
|
||||
# Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml),
|
||||
# merged after the base config.
|
||||
kratos:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# runs against the *plain* `docker compose up` topology and drives the browser on the HOST network, so
|
||||
# it sees http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host
|
||||
# browser does. Merge the dev override so the live stack is byte-for-byte `docker compose up`:
|
||||
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml run --user "$(id -u):$(id -g)" --build --rm e2e
|
||||
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml down -v # tear down
|
||||
services:
|
||||
web:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
||||
# login, menu filtering by permission, users/groups/permissions/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
|
||||
# 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 --user "$(id -u):$(id -g)" --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
|
||||
services:
|
||||
web:
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
|
||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||
shifts-upstream:
|
||||
image: node:24.18.1-alpine3.24
|
||||
image: node:24.19.0-alpine3.24
|
||||
command: ["node", "/server.ts"]
|
||||
volumes:
|
||||
- ./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
|
||||
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
||||
mock-oidc:
|
||||
image: node:24.18.1-alpine3.24
|
||||
image: node:24.19.0-alpine3.24
|
||||
command: ["node", "/mock-oidc.ts"]
|
||||
environment:
|
||||
ISSUER: http://mock-oidc:9000
|
||||
@@ -81,7 +81,7 @@ services:
|
||||
|
||||
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
||||
proxy:
|
||||
image: node:24.18.1-alpine3.24
|
||||
image: node:24.19.0-alpine3.24
|
||||
command: ["node", "/proxy.ts"]
|
||||
depends_on:
|
||||
web:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# it via the Kratos session and accepts. Runs against the real stack (Postgres + Kratos + Keto +
|
||||
# Hydra + bootstrap + web). The runner drives the flow over HTTP (fetch, manual cookies), so it
|
||||
# reaches the Ory services by their compose-network names.
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml run --user "$(id -u):$(id -g)" --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v # tear down after
|
||||
services:
|
||||
web:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 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.
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --build --rm e2e
|
||||
# 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 --user "$(id -u):$(id -g)" --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v # tear down after
|
||||
# --build rebuilds the runner (the image bakes in e2e-tests/) so spec edits are picked up.
|
||||
# Screenshots + HTML report land in ./e2e-tests/artifacts/ (git-ignored).
|
||||
@@ -29,8 +30,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: e2e-tests/Dockerfile
|
||||
# Just the Ory-free visual suite; the full-stack auth spec runs via e2e-tests/compose.auth.yml.
|
||||
command: ["npx", "playwright", "test", "visual.spec.ts"]
|
||||
# The Ory-free suites (design system + language switching); the full-stack auth spec runs via
|
||||
# e2e-tests/compose.auth.yml.
|
||||
command: ["npx", "playwright", "test", "visual.spec.ts", "language.spec.ts"]
|
||||
depends_on:
|
||||
web:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -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,20 +1,13 @@
|
||||
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
|
||||
// up`, open the printed login URL (http://localhost:3000), sign in as the seeded admin → you land on
|
||||
// the dashboard, signed in. Originally this dumped the user on http://127.0.0.1:3000/error?id=…
|
||||
// ("Page not found"): the banner printed `localhost` but kratos.yml hard-coded `127.0.0.1`, and a
|
||||
// host-scoped Kratos CSRF cookie can't cross `localhost`↔`127.0.0.1`, so the cross-host login POST
|
||||
// lost it and Kratos redirected to its error sink.
|
||||
// The from-scratch dev experience the banner advertises: `docker compose up`, open the printed
|
||||
// login URL, sign in as the seeded admin, land on the dashboard. A host-scoped Kratos CSRF cookie
|
||||
// cannot cross `localhost`↔`127.0.0.1`, so a cross-host login POST loses it and Kratos redirects to
|
||||
// its error sink; APP_URL canonicalises every off-host visitor onto one cookie host instead.
|
||||
//
|
||||
// The fix makes APP_URL the single source for the public host: the web app canonicalises every
|
||||
// off-host visitor onto it (so localhost / 127.0.0.1 / any alias funnel to one cookie host), Kratos'
|
||||
// browser URLs derive from it, and a real /error page replaces the 404.
|
||||
//
|
||||
// This is faithful to the user's environment: the runner uses the host network
|
||||
// (e2e-tests/compose.devstack.yml) against the plain `docker compose up` topology, so it sees
|
||||
// http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host browser
|
||||
// does. The proxied full-flow suite can't catch this regression — it fronts web + Kratos on one origin.
|
||||
// The runner is on the host network against the plain `docker compose up` topology, so it sees
|
||||
// http://localhost:3000 and http://127.0.0.1:4433 exactly as a host browser does. The proxied
|
||||
// full-flow suite cannot catch this — it fronts web + Kratos on one origin.
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
|
||||
const ADMIN_PASSWORD = "admin";
|
||||
|
||||
@@ -26,9 +19,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.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.getByRole("link", { name: "Log in" }).click();
|
||||
await page.locator("#main-content").getByRole("link", { name: "Sign in" }).click();
|
||||
await signIn(page);
|
||||
|
||||
// Signed in on the app — NOT dumped on the Kratos /error "Page not found" page.
|
||||
|
||||
+101
-13
@@ -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";
|
||||
|
||||
// Full browser E2E: the real Playwright UI against the live stack via the same-origin
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 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", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser: b }) => {
|
||||
browser = b;
|
||||
page = await (await browser.newContext()).newPage();
|
||||
page = await watchedPage(await browser.newContext());
|
||||
test.setTimeout(90_000);
|
||||
await loginPassword(page);
|
||||
});
|
||||
test.afterAll(async () => { await page.context().close(); });
|
||||
|
||||
// The list screens rebuild their query from the list state (sort/page/filter), so they are where a
|
||||
// chosen language is most easily dropped; the core building blocks carry it through.
|
||||
test("a sorted, paged admin list keeps the visitor's language", async () => {
|
||||
await page.goto("/admin/users?locale=sv-SE");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: /E-postadress/ }).click(); // a sort header
|
||||
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
|
||||
await page.getByRole("button", { name: "Använd filter" }).click(); // the filter bar's GET form
|
||||
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
|
||||
await page.getByRole("button", { name: "Visa" }).click(); // the rows-per-page GET form
|
||||
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
|
||||
// The breadcrumb is the chrome's way back up — it is rendered by the shell, not by the screen.
|
||||
await page.getByRole("navigation", { name: "Sidsökväg" }).getByRole("link").first().click();
|
||||
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
});
|
||||
|
||||
// A POST that re-renders a page: the write must keep the language, and the picker — which is on
|
||||
// every page — must point somewhere that answers GET rather than at the POST-only URL.
|
||||
test("a write keeps the visitor's language, and the picker still works on the POST-rendered page", async () => {
|
||||
await page.goto("/admin/users?locale=sv-SE");
|
||||
await page.getByRole("link", { name: "Ny användare" }).click();
|
||||
await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`);
|
||||
await page.getByRole("button", { name: "Skapa användare" }).click();
|
||||
await expect(page).toHaveURL(/locale=sv-SE/); // the POST → redirect → GET keeps it
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
|
||||
// Open the new user's edit page the way the CRUD test does — the row's Edit link carries the id.
|
||||
const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` });
|
||||
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
||||
await page.goto(`${editHref}`);
|
||||
await expect(page.locator('button[aria-label="Språk"]')).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route
|
||||
await expect(page.getByText("Återställningskod skapad")).toBeVisible();
|
||||
|
||||
// The picker is here too, and following it lands on a real page in the other language.
|
||||
await page.locator('button[aria-label="Språk"]').click();
|
||||
await page.getByRole("link", { name: /English/i }).click();
|
||||
expect(page.url()).toContain("locale=en-US");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
|
||||
await expect(page.getByRole("heading", { name: "Edit user" })).toBeVisible(); // not a 405
|
||||
});
|
||||
|
||||
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
|
||||
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
|
||||
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
|
||||
// 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 expect(page.locator('.sidebar a[href="/admin/users"]')).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 });
|
||||
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).
|
||||
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
||||
await page.goto(`${editHref}/delete`);
|
||||
@@ -65,7 +136,7 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("groups + permissions 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
|
||||
// enforces it); pick the first option (a user) from the required picker.
|
||||
const group = `e2e-grp-${suffix}`;
|
||||
@@ -76,13 +147,17 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
|
||||
await expect(page.locator("main")).toContainText(group);
|
||||
|
||||
const permission = `e2e-permission-${suffix}`;
|
||||
await page.goto("/admin/permissions/new");
|
||||
await page.fill('input[name="name"]', permission);
|
||||
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
||||
await page.locator('.form-card button[type="submit"]').click();
|
||||
await expect(page).toHaveURL(/\/admin\/permissions(\?|\/|$)/);
|
||||
await expect(page.locator("main")).toContainText(permission);
|
||||
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed
|
||||
// checkbox list rather than a create form — there is no Permissions screen to visit.
|
||||
await page.goto(`/admin/groups/${group}`);
|
||||
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
|
||||
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
|
||||
await expect(scheduling).not.toBeChecked();
|
||||
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 () => {
|
||||
@@ -120,9 +195,22 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
|
||||
});
|
||||
|
||||
test("plugin settings: the screen names the variable that sets each declared key", async () => {
|
||||
await page.goto("/admin/plugin-settings");
|
||||
await expect(page.locator("h1")).toHaveText("Plugin settings");
|
||||
// The reference plugin's one declared setting, and the variable an operator would set for it.
|
||||
const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" });
|
||||
await expect(scheduling).toContainText("upstream");
|
||||
await expect(scheduling).toContainText("http://shifts-upstream:4000"); // resolved, and its source shown
|
||||
// Every installed plugin gets a section, so "declares none" is distinguishable from "not installed".
|
||||
await expect(page.locator("h2", { hasText: "admin" })).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("logout: signing out ends the session and returns to the login page", async () => {
|
||||
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.waitForURL(/\/login(\?|$)/);
|
||||
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
|
||||
|
||||
@@ -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
|
||||
// starts an authorization flow and hands the browser to web's /oauth2/login; web resolves it via
|
||||
|
||||
Generated
-2
@@ -1,12 +1,10 @@
|
||||
{
|
||||
"name": "plainpages-e2e",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "plainpages-e2e",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"name": "plainpages-e2e",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
|
||||
"type": "module",
|
||||
|
||||
@@ -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
|
||||
// e2e-tests/compose.visual.yml. Parallel per the project's E2E principle; deterministic colorScheme/viewport
|
||||
// so the rendered design is stable across runs.
|
||||
|
||||
const ORY_FREE = /\/(visual|language)\.spec\.ts$/;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
outputDir: "artifacts/test-output",
|
||||
@@ -15,5 +18,14 @@ export default defineConfig({
|
||||
screenshot: "only-on-failure",
|
||||
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"] } },
|
||||
],
|
||||
});
|
||||
|
||||
+40
-11
@@ -1,14 +1,14 @@
|
||||
import { createPrivateKey, sign } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import 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 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> =>
|
||||
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
|
||||
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
|
||||
@@ -22,8 +22,6 @@ function devSession(permissions: string[] = []): string {
|
||||
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
|
||||
// member (no permissions) so the gated scheduling nav stays filtered out.
|
||||
test.beforeEach(async ({ context }) => {
|
||||
@@ -58,9 +56,8 @@ test("every icon <use> resolves to a defined <symbol> (no broken graphics)", asy
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
// (The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component
|
||||
// (list-query/data-table/filter-bar) and exercised live with real data by the full-flow E2E's admin
|
||||
// Users list. The mock-data dashboard that used to host it in this Ory-free suite is gone.)
|
||||
// The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component and
|
||||
// exercised live by the full-flow E2E's admin Users list, so it has no Ory-free counterpart here.
|
||||
|
||||
test("theme switch flips the palette with no JavaScript", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
@@ -70,6 +67,35 @@ test("theme switch flips the palette with no JavaScript", async ({ page }) => {
|
||||
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 }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/dashboard");
|
||||
@@ -101,12 +127,15 @@ test("the public landing at / is ungated and links to sign in + register", async
|
||||
await expect(page.locator(".landing")).toBeVisible();
|
||||
// the same app shell every page renders — the menu shows even signed out (permission-filtered).
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
|
||||
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
|
||||
await expect(page.locator('use[href="#i-gear"]')).toHaveCount(0); // no settings cog to offer a signed-out visitor
|
||||
// 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");
|
||||
});
|
||||
|
||||
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");
|
||||
expect(res?.status()).toBe(404);
|
||||
await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible();
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ across (or bind-mount your own) and restart.
|
||||
|
||||
| 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 `@plainpages/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 / 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). |
|
||||
| [`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 `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. |
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
// Reference config/menu.ts — copy into the (empty) config/ mount at the repo root:
|
||||
// Reference config/menu.ts — copy into the empty config/ mount at the repo root:
|
||||
// cp examples/config/menu.ts config/menu.ts
|
||||
// config/ ships empty; mount your own or copy this in. Absent config = built-in defaults.
|
||||
// Absent config = built-in defaults.
|
||||
//
|
||||
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins —
|
||||
// the override always wins, applied before the per-user permission filter. Every field is
|
||||
// optional; delete one to fall back to the default.
|
||||
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system).
|
||||
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — the
|
||||
// override always wins, applied before the per-user permission filter. Every field is optional.
|
||||
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system.
|
||||
|
||||
import { defineMenu } from "#menu-config";
|
||||
|
||||
export default defineMenu({
|
||||
branding: {
|
||||
name: "Plainpages", // app name shown in the sidebar
|
||||
sub: "Console", // optional subtitle under the name
|
||||
sub: "Console", // optional subtitle under the name — a catalog key here would be translated
|
||||
// logo: "/public/logo.svg", // optional logo asset (rendered in the sidebar brand)
|
||||
// theme: "auto", // default color theme: auto | light | dark
|
||||
},
|
||||
|
||||
// Operator override (rename → group → order → hide), keyed by node id.
|
||||
override: {
|
||||
// rename: { people: "Staff" }, // node id → new label
|
||||
// groups: [{ id: "admin", label: "Admin", children: ["users", "permissions"] }],
|
||||
// rename: { people: "Staff" }, // node id → new label (or a catalog key)
|
||||
// groups: [{ id: "admin", label: "Admin", children: ["users", "groups"] }],
|
||||
// order: ["people", "reports"], // top-level order by id
|
||||
// hide: ["teams"], // remove nodes (any depth)
|
||||
},
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
# Admin — the system-administration plugin
|
||||
|
||||
The Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. These used to be
|
||||
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
|
||||
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:
|
||||
The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped 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 screens live at `/admin/*`) and restart:
|
||||
|
||||
```bash
|
||||
cp -r examples/plugins/admin plugins/admin
|
||||
docker compose restart web
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The seeded `admin@plainpages.local` already holds the `admin` permission, so the section appears in the
|
||||
menu and the screens work immediately.
|
||||
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so
|
||||
the section appears in the menu and the screens work immediately. An older copy already in
|
||||
`plugins/` is yours — the host never updates it — so re-copy after a pull; a stale one stops the boot
|
||||
with a message naming it ([README → Upgrading](../../../README.md#upgrading)).
|
||||
|
||||
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`), the nav
|
||||
labels included. Each pure view-model builder takes an optional `t` defaulting to the plugin's own
|
||||
English, so a unit test reads in words rather than keys.
|
||||
|
||||
## What it demonstrates — a *system* plugin
|
||||
|
||||
@@ -20,29 +25,41 @@ 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:
|
||||
|
||||
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
|
||||
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Permissions).
|
||||
- **`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.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
|
||||
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,
|
||||
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,
|
||||
gated per route by `permission: "admin"`, rendering the core building blocks in `views/`.
|
||||
`ctx.system` is populated only when the host wired those services. Where a capability is absent the
|
||||
screen degrades to a themed 503 rather than crashing. Everything else is an ordinary plugin:
|
||||
folder-discovered, 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` — split into `:read` and
|
||||
`:write`, so a helpdesk account can be given `users:read` alone. Holding none of the six 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
|
||||
|
||||
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission, and the
|
||||
route table — one thin handler per method+path, all gated by `permission: "admin"`.
|
||||
- `admin-users.ts` · `admin-groups.ts` · `admin-permissions.ts` · `admin-clients.ts` — each a set of pure
|
||||
- `plugin.ts` — the manifest: the Admin nav fragment, the six permissions the plugin declares, and
|
||||
the route table — one thin handler per method+path, gated via `permissionName(resource, actionForMethod(method))`
|
||||
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
|
||||
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
|
||||
admin gate + the needed `ctx.system` clients once.
|
||||
- `admin-shared.ts` — the shared gate (`requireAdmin`), CSRF form reader (`guardedForm`), confirm
|
||||
screen's permission gate + the needed `ctx.system` clients once.
|
||||
- `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.
|
||||
- `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, …).
|
||||
|
||||
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
|
||||
`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
|
||||
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
|
||||
|
||||
import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
|
||||
import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
import { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
||||
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";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
@@ -64,14 +64,14 @@ export function clientPayload(input: ClientInput): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
export function validateClientInput(input: ClientInput): string | null {
|
||||
if (!input.name) return "Enter a name for the client.";
|
||||
if (!input.redirectUris.length) return "Add at least one redirect URI.";
|
||||
export function validateClientInput(input: ClientInput, t: Translate = ADMIN_EN): string | null {
|
||||
if (!input.name) return t("admin.clients.validation.name");
|
||||
if (!input.redirectUris.length) return t("admin.clients.validation.redirectUris");
|
||||
for (const uri of input.redirectUris) {
|
||||
try {
|
||||
new URL(uri); // must be an absolute URL — any scheme (public/native clients use custom ones)
|
||||
} catch {
|
||||
return `"${uri}" is not a valid redirect URI — use an absolute URL like https://app.example.com/callback.`;
|
||||
return t("admin.clients.validation.redirectUri", { uri });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -100,10 +100,13 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
||||
}
|
||||
|
||||
export function buildClientsListModel(opts: {
|
||||
canWrite?: boolean;
|
||||
clients: OAuth2Client[];
|
||||
csrfToken?: string;
|
||||
t?: Translate;
|
||||
url: URL | URLSearchParams | string;
|
||||
}) {
|
||||
const t = opts.t ?? ADMIN_EN;
|
||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||
const needle = query.q.toLowerCase();
|
||||
|
||||
@@ -116,56 +119,57 @@ export function buildClientsListModel(opts: {
|
||||
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q };
|
||||
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }],
|
||||
filterBar: listFilterBar(state),
|
||||
pagination: listPagination(state, page),
|
||||
table: listTable(rows),
|
||||
title: "OAuth2 clients",
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }],
|
||||
canWrite: opts.canWrite !== false,
|
||||
filterBar: listFilterBar(state, t),
|
||||
pagination: listPagination(state, page, t),
|
||||
table: listTable(rows, t),
|
||||
title: t("admin.clients.title"),
|
||||
};
|
||||
}
|
||||
|
||||
function listTable(rows: ClientView[]) {
|
||||
function listTable(rows: ClientView[], t: Translate) {
|
||||
return {
|
||||
caption: "OAuth2 clients",
|
||||
columns: [{ label: "Name" }, { label: "Client ID" }, { label: "Type" }],
|
||||
caption: t("admin.clients.title"),
|
||||
columns: [{ label: t("admin.clients.column.name") }, { label: t("admin.clients.column.id") }, { label: t("admin.clients.column.type") }],
|
||||
rows: rows.map((c) => ({
|
||||
cells: [
|
||||
{ rowHeader: { href: detailHref(c.id), text: c.name } },
|
||||
{ className: "cell-muted", text: c.id },
|
||||
{ badge: { label: c.public ? "Public" : "Confidential", tone: c.public ? "warn" : "info" } },
|
||||
{ badge: { label: c.public ? t("admin.clients.public") : t("admin.clients.confidential"), tone: c.public ? "warn" : "info" } },
|
||||
],
|
||||
name: c.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function listFilterBar(state: ListState) {
|
||||
function listFilterBar(state: ListState, t: Translate) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
return {
|
||||
applyLabel: "Apply",
|
||||
applyLabel: t("filter.apply"),
|
||||
clearHref: ADMIN_CLIENTS_BASE,
|
||||
label: "Filter clients",
|
||||
label: t("admin.clients.filter"),
|
||||
pills,
|
||||
rows: [[
|
||||
{ label: "Search clients", name: "q", placeholder: "Search name or client ID…", type: "search", value: state.q },
|
||||
{ label: t("admin.clients.searchLabel"), name: "q", placeholder: t("admin.clients.searchPlaceholder"), type: "search", value: state.q },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
|
||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
|
||||
const hidden: { name: string; value: string }[] = [];
|
||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||
return {
|
||||
label: "Clients pagination",
|
||||
label: t("admin.clients.pagination"),
|
||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
||||
pages: page.pages.map((p) =>
|
||||
p.ellipsis ? { ellipsis: true }
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
||||
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
|
||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
@@ -175,18 +179,20 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
|
||||
export function buildClientFormModel(opts: {
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
t?: Translate;
|
||||
values?: Partial<ClientInput>;
|
||||
}) {
|
||||
const t = opts.t ?? ADMIN_EN;
|
||||
const v = opts.values;
|
||||
const nameField: FieldConfig = {
|
||||
autocomplete: "off", icon: "i-box", id: "name", label: "Name", name: "name", required: true, value: v?.name ?? "",
|
||||
autocomplete: "off", icon: "i-box", id: "name", label: t("admin.clients.field.name"), name: "name", required: true, value: v?.name ?? "",
|
||||
};
|
||||
const scopeField: FieldConfig = {
|
||||
hint: "Space-separated scopes the client may request.", id: "scope", label: "Scopes", name: "scope",
|
||||
hint: t("admin.clients.field.scopesHint"), id: "scope", label: t("admin.clients.field.scopes"), name: "scope",
|
||||
value: v?.scope ?? DEFAULT_SCOPE,
|
||||
};
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }],
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: t("admin.clients.register") }],
|
||||
error: opts.error,
|
||||
form: {
|
||||
action: ADMIN_CLIENTS_BASE,
|
||||
@@ -197,27 +203,31 @@ export function buildClientFormModel(opts: {
|
||||
public: v?.public ?? false,
|
||||
redirectUris: (v?.redirectUris ?? []).join("\n"),
|
||||
scopeField,
|
||||
submitLabel: "Register client",
|
||||
submitLabel: t("admin.clients.registerClient"),
|
||||
},
|
||||
title: "Register client",
|
||||
title: t("admin.clients.registerTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildClientDetailModel(opts: {
|
||||
canWrite?: boolean;
|
||||
client: ClientView;
|
||||
created?: boolean; // just registered → success banner + the one-time secret (if any)
|
||||
csrfToken?: string;
|
||||
secret?: string; // one-time client_secret (confidential clients), shown once right after create
|
||||
t?: Translate;
|
||||
}) {
|
||||
const t = opts.t ?? ADMIN_EN;
|
||||
const base = detailHref(opts.client.id);
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }],
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: opts.client.name }],
|
||||
canWrite: opts.canWrite !== false,
|
||||
client: opts.client,
|
||||
created: opts.created ?? false,
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
delete: { action: `${base}/delete` },
|
||||
secret: opts.secret,
|
||||
title: opts.created ? "Client registered" : opts.client.name,
|
||||
title: opts.created ? t("admin.clients.created") : opts.client.name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -237,47 +247,49 @@ function readClientInput(form: URLSearchParams): ClientInput {
|
||||
// Hydra capability (else a themed 503). Each route below is a thin handler over these.
|
||||
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) => {
|
||||
const user = requireAdmin(ctx);
|
||||
const user = requirePermission(ctx, "oauth2-clients", action);
|
||||
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 });
|
||||
};
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
const id = deps.ctx.params["id"] ?? "";
|
||||
const client = await deps.hydra.getClient(id);
|
||||
if (!client) return notFound(deps.ctx);
|
||||
return inner(deps, client, id);
|
||||
});
|
||||
}, action);
|
||||
}
|
||||
|
||||
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 =>
|
||||
({ 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.
|
||||
export const clientsList = withClients(async ({ ctx, hydra }) => {
|
||||
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
|
||||
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ clients, csrfToken: ctx.chrome.csrfToken, url: ctx.url }) }, view: "clients" };
|
||||
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ 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
|
||||
// returns it again). A Hydra 4xx (bad redirect/scope) re-renders the form (400); a 5xx rethrows → 500.
|
||||
export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
|
||||
const input = readClientInput((await guardedForm(ctx))!);
|
||||
const error = validateClientInput(input);
|
||||
const error = validateClientInput(input, ctx.t);
|
||||
if (error) return { ...clientFormResult(ctx, { error, values: input }), status: 400 };
|
||||
let created: OAuth2Client;
|
||||
try {
|
||||
created = await hydra.createClient(clientPayload(input));
|
||||
} catch (err) {
|
||||
if (err instanceof HydraError && err.status < 500) return { ...clientFormResult(ctx, { error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input }), status: 400 };
|
||||
if (err instanceof HydraError && err.status < 500) return { ...clientFormResult(ctx, { error: ctx.t("admin.clients.error.rejected"), values: input }), status: 400 };
|
||||
throw err;
|
||||
}
|
||||
ctx.log.info("admin: oauth2 client registered", { actor: user.id, client: created.client_id ?? "" });
|
||||
@@ -285,7 +297,7 @@ export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
|
||||
});
|
||||
|
||||
// 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).
|
||||
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) => {
|
||||
const base = detailHref(id);
|
||||
const name = toClientView(client).name;
|
||||
const tt = deps.ctx.t;
|
||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }],
|
||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client",
|
||||
message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, title: "Delete client",
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: tt("admin.clients.title") }, { href: base, label: name }, { label: tt("common.delete") }],
|
||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.clients.delete"),
|
||||
message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"),
|
||||
}) }, view: "confirm" });
|
||||
});
|
||||
}, "write");
|
||||
|
||||
// POST /admin/clients/:id/delete — perform it.
|
||||
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 "@plainpages/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
|
||||
});
|
||||
|
||||
// An inherited permission rendered unticked would say "not held" about a grant that reaches the JWT,
|
||||
// and unticking it writes nothing, reading as a successful revoke. So 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 "@plainpages/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));
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
memberView,
|
||||
parseSubject,
|
||||
} from "./admin-groups.ts";
|
||||
import type { RelationTuple } from "#plugin-api";
|
||||
import type { RelationTuple } from "@plainpages/plugin-api";
|
||||
|
||||
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
||||
const userTuple = (group: string, n: number): RelationTuple =>
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
||||
// each returning a RouteResult.
|
||||
|
||||
import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type User } from "#plugin-api";
|
||||
import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
|
||||
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";
|
||||
|
||||
const GROUP_NS = "Group";
|
||||
@@ -90,8 +91,8 @@ const SORT: Record<string, (g: GroupView) => number | string> = {
|
||||
name: (g) => g.name,
|
||||
};
|
||||
const COLUMNS = [
|
||||
{ key: "name", label: "Group" },
|
||||
{ key: "members", label: "Members" },
|
||||
{ key: "name", label: "admin.groups.column.name" },
|
||||
{ key: "members", label: "admin.groups.column.members" },
|
||||
];
|
||||
|
||||
function detailHref(name: string): string {
|
||||
@@ -110,10 +111,13 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
||||
}
|
||||
|
||||
export function buildGroupsListModel(opts: {
|
||||
canWrite?: boolean;
|
||||
csrfToken?: string;
|
||||
groups: GroupView[];
|
||||
t?: Translate;
|
||||
url: URL | URLSearchParams | string;
|
||||
}) {
|
||||
const t = opts.t ?? ADMIN_EN;
|
||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
||||
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
||||
@@ -136,21 +140,22 @@ export function buildGroupsListModel(opts: {
|
||||
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
||||
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Admin" }, { label: "Groups" }],
|
||||
filterBar: listFilterBar(state),
|
||||
pagination: listPagination(state, page),
|
||||
table: listTable(rows, state, sort),
|
||||
title: "Groups",
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }],
|
||||
canWrite: opts.canWrite !== false,
|
||||
filterBar: listFilterBar(state, t),
|
||||
pagination: listPagination(state, page, t),
|
||||
table: listTable(rows, state, sort, t),
|
||||
title: t("admin.groups.title"),
|
||||
};
|
||||
}
|
||||
|
||||
function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
|
||||
function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
|
||||
return {
|
||||
caption: "Groups",
|
||||
caption: t("admin.groups.title"),
|
||||
columns: COLUMNS.map((c) => {
|
||||
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
||||
const next = dir === "asc" ? `-${c.key}` : c.key;
|
||||
return { href: listHref(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
|
||||
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
|
||||
}),
|
||||
rows: rows.map((g) => ({
|
||||
cells: [{ rowHeader: { href: detailHref(g.name), text: g.name } }, String(g.memberCount)],
|
||||
@@ -159,34 +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 }[] = [];
|
||||
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
return {
|
||||
applyLabel: "Apply",
|
||||
applyLabel: t("filter.apply"),
|
||||
clearHref: ADMIN_GROUPS_BASE,
|
||||
label: "Filter groups",
|
||||
label: t("admin.groups.filter"),
|
||||
pills,
|
||||
rows: [[
|
||||
{ label: "Search groups", name: "q", placeholder: "Search group name…", type: "search", value: state.q },
|
||||
{ label: t("admin.groups.searchLabel"), name: "q", placeholder: t("admin.groups.searchPlaceholder"), type: "search", value: state.q },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
|
||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
|
||||
const hidden: { name: string; value: string }[] = [];
|
||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
||||
return {
|
||||
label: "Groups pagination",
|
||||
label: t("admin.groups.pagination"),
|
||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
||||
pages: page.pages.map((p) =>
|
||||
p.ellipsis ? { ellipsis: true }
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
||||
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
|
||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
@@ -197,14 +202,16 @@ export function buildGroupFormModel(opts: {
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
memberOptions: MemberOption[];
|
||||
t?: Translate;
|
||||
values?: { member?: string; name?: string };
|
||||
}) {
|
||||
const t = opts.t ?? ADMIN_EN;
|
||||
const nameField: FieldConfig = {
|
||||
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-layers",
|
||||
id: "name", label: "Group name", name: "name", required: true, value: opts.values?.name ?? "",
|
||||
autocomplete: "off", hint: t("admin.groups.field.nameHint"), icon: "i-layers",
|
||||
id: "name", label: t("admin.groups.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
|
||||
};
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: "New" }],
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: t("common.new") }],
|
||||
error: opts.error,
|
||||
form: {
|
||||
action: ADMIN_GROUPS_BASE,
|
||||
@@ -213,39 +220,46 @@ export function buildGroupFormModel(opts: {
|
||||
memberOptions: opts.memberOptions,
|
||||
nameField,
|
||||
selectedMember: opts.values?.member ?? "",
|
||||
submitLabel: "Create group",
|
||||
submitLabel: t("admin.groups.create"),
|
||||
},
|
||||
title: "New group",
|
||||
title: t("admin.groups.new"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGroupDetailModel(opts: {
|
||||
canWrite?: boolean; // false ⇒ a `groups:read` holder: show the members, offer no edit
|
||||
candidates: MemberOption[];
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
group: { name: string };
|
||||
members: MemberView[];
|
||||
permissions?: PermissionPicker;
|
||||
t?: Translate;
|
||||
}) {
|
||||
const t = opts.t ?? ADMIN_EN;
|
||||
const name = opts.group.name;
|
||||
const base = detailHref(name);
|
||||
const taken = new Set(opts.members.map((m) => m.subject));
|
||||
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 canWrite = opts.canWrite !== false;
|
||||
return {
|
||||
add: { action: `${base}/members`, options },
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: name }],
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: name }],
|
||||
canWrite, // the view drops add/remove/delete when false; the host already 403s those POSTs
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
delete: { action: `${base}/delete` },
|
||||
error: opts.error,
|
||||
group: { name },
|
||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
||||
permissions: opts.permissions,
|
||||
title: name,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 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[]> {
|
||||
const out: RelationTuple[] = [];
|
||||
let pageToken: string | undefined;
|
||||
@@ -279,38 +293,39 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
|
||||
return page.tuples.length > 0;
|
||||
}
|
||||
|
||||
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate + the Keto and
|
||||
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
|
||||
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on
|
||||
// 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; }
|
||||
|
||||
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHandler {
|
||||
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
|
||||
return async (ctx) => {
|
||||
const user = requireAdmin(ctx);
|
||||
const user = requirePermission(ctx, "groups", action);
|
||||
const keto = ctx.system?.keto;
|
||||
const kratosAdmin = ctx.system?.kratosAdmin;
|
||||
if (!keto || !kratosAdmin) return unavailable(ctx, "Keto and Kratos identity admin");
|
||||
if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto"));
|
||||
return inner({ ctx, keto, kratosAdmin, user });
|
||||
};
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
const name = deps.ctx.params["name"] ?? "";
|
||||
if (!isValidGroupName(name)) return Promise.resolve(notFound(deps.ctx));
|
||||
return inner(deps, name);
|
||||
});
|
||||
}, action);
|
||||
}
|
||||
|
||||
const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
|
||||
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
||||
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "group-form" };
|
||||
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "group-form" };
|
||||
};
|
||||
|
||||
// GET /admin/groups — the list.
|
||||
export const groupsList = withGroups(async ({ ctx, keto }) => {
|
||||
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
||||
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ csrfToken: ctx.chrome.csrfToken, groups, url: ctx.url }) }, view: "groups" };
|
||||
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ 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).
|
||||
@@ -321,8 +336,8 @@ export const groupsCreate = withGroups(async (deps) => {
|
||||
const member = (form.get("member") ?? "").trim();
|
||||
const tuple = memberTuple(name, member);
|
||||
const reject = async (error: string): Promise<RouteResult> => ({ ...(await groupFormResult(deps, { error, values: { member, name } })), status: 400 });
|
||||
if (!isValidGroupName(name)) return reject("Group names use lowercase letters, digits, dashes and underscores.");
|
||||
if (!tuple) return reject("Pick a member to add as the group's first member.");
|
||||
if (!isValidGroupName(name)) return reject(ctx.t("admin.groups.validation.name"));
|
||||
if (!tuple) return reject(ctx.t("admin.groups.validation.member"));
|
||||
if (await groupExists(keto, name)) return reject("A group with that name already exists.");
|
||||
await keto.writeTuple(tuple);
|
||||
ctx.log.info("admin: group created", { actor: user.id, group: name });
|
||||
@@ -330,13 +345,38 @@ export const groupsCreate = withGroups(async (deps) => {
|
||||
});
|
||||
|
||||
// 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.
|
||||
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
|
||||
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
||||
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
|
||||
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members }) }, view: "group-detail" };
|
||||
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).
|
||||
@@ -350,18 +390,25 @@ export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
|
||||
// GET /admin/groups/:name/delete — the deliberate confirm step.
|
||||
export const groupsDeleteConfirm = withGroupName((deps, name) => {
|
||||
const base = detailHref(name);
|
||||
const tt = deps.ctx.t;
|
||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }],
|
||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete group",
|
||||
message: `Delete group ${name}? This removes the group and all its memberships.`, title: "Delete group",
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("common.delete") }],
|
||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"),
|
||||
message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"),
|
||||
}) }, view: "confirm" });
|
||||
});
|
||||
}, "write");
|
||||
|
||||
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
|
||||
export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => {
|
||||
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 });
|
||||
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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
// Built-in Roles admin screen: the pure view-model + Keto builders. A permission is a
|
||||
// Keto subject set (Permission:<name>#members); members are users (subject_id) or groups (subject_set) —
|
||||
// "assign permissions to users/groups". The "effective access" view flattens a Keto `expand` tree into the
|
||||
// distinct set of users who hold the permission directly or transitively via a group. The HTTP
|
||||
// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { memberView } from "./admin-groups.ts";
|
||||
import {
|
||||
buildPermissionDetailModel,
|
||||
buildPermissionFormModel,
|
||||
buildPermissionsListModel,
|
||||
expandToEffectiveUsers,
|
||||
isValidRoleName,
|
||||
permissionGrantTuple,
|
||||
} from "./admin-permissions.ts";
|
||||
import type { ExpandTree, RelationTuple } from "#plugin-api";
|
||||
|
||||
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
||||
const userTuple = (permission: string, n: number): RelationTuple =>
|
||||
({ namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${uid(n)}` });
|
||||
const groupTuple = (permission: string, group: string): RelationTuple =>
|
||||
({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
||||
|
||||
test("isValidRoleName + permissionGrantTuple map the form value to a Permission tuple over a user/group (else null)", () => {
|
||||
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
|
||||
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
|
||||
|
||||
assert.deepEqual(permissionGrantTuple("editor", `user:${uid(2)}`), { namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${uid(2)}` });
|
||||
assert.deepEqual(permissionGrantTuple("editor", "group:eng"), { namespace: "Permission", object: "editor", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||
for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(permissionGrantTuple("editor", bad), null, bad);
|
||||
});
|
||||
|
||||
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
|
||||
// The subject rides on each node's `tuple` (Keto v26.2.0 shape, verified live).
|
||||
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `user:${uid(n)}` }, type: "leaf" });
|
||||
const tree: ExpandTree = {
|
||||
children: [
|
||||
leaf(1), // direct
|
||||
{
|
||||
children: [leaf(2), leaf(1)], // via group + dup
|
||||
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Group", object: "eng", relation: "members" } }, // a member group, not a user
|
||||
type: "union",
|
||||
},
|
||||
],
|
||||
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } },
|
||||
type: "union",
|
||||
};
|
||||
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
|
||||
assert.deepEqual(expandToEffectiveUsers(null), []);
|
||||
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty permission
|
||||
});
|
||||
|
||||
test("buildPermissionsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
||||
const permissions = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `permission-${String(i).padStart(2, "0")}` }));
|
||||
|
||||
const all = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions" });
|
||||
assert.equal(all.pagination.summary.total, 30);
|
||||
assert.equal(all.table.rows.length, 25); // default page size
|
||||
assert.equal(all.title, "Permissions");
|
||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||
assert.equal(first.rowHeader.text, "permission-00");
|
||||
assert.equal(first.rowHeader.href, "/admin/permissions/permission-00");
|
||||
|
||||
const one = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?q=permission-07" });
|
||||
assert.equal(one.pagination.summary.total, 1);
|
||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||
|
||||
const desc = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?sort=-members" });
|
||||
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "permission-29");
|
||||
});
|
||||
|
||||
test("buildPermissionFormModel: a create form with a required name field + member options (user or group)", () => {
|
||||
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||
const m = buildPermissionFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||
assert.equal(m.title, "New permission");
|
||||
assert.equal(m.form.action, "/admin/permissions");
|
||||
assert.equal(m.form.submitLabel, "Create permission");
|
||||
assert.equal(m.form.csrfToken, "tok.sig");
|
||||
assert.equal(m.form.nameField.required, true);
|
||||
assert.deepEqual(m.form.memberOptions, options);
|
||||
|
||||
const err = buildPermissionFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
|
||||
assert.equal(err.error, "That name is taken.");
|
||||
assert.equal(err.form.nameField.value, "Admin");
|
||||
assert.equal(err.form.selectedMember, "group:eng");
|
||||
});
|
||||
|
||||
test("buildPermissionDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
|
||||
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
|
||||
const candidates = [
|
||||
{ label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded
|
||||
{ label: "grace@example.com", value: `user:${uid(2)}` },
|
||||
{ label: "eng (group)", value: "group:eng" }, // already a member → excluded
|
||||
{ label: "ops (group)", value: "group:ops" },
|
||||
];
|
||||
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
|
||||
const m = buildPermissionDetailModel({ candidates, effective, members, permission: { name: "admin" } });
|
||||
assert.equal(m.title, "admin");
|
||||
assert.equal(m.members.rows.length, 2);
|
||||
assert.equal(m.members.action, "/admin/permissions/admin/members/delete");
|
||||
assert.equal(m.add.action, "/admin/permissions/admin/members");
|
||||
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
|
||||
assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
|
||||
assert.equal(m.delete.action, "/admin/permissions/admin/delete");
|
||||
});
|
||||
@@ -1,367 +0,0 @@
|
||||
// Permissions admin screen: list / create / delete Keto permissions and assign
|
||||
// them to users and groups. A permission is a Keto subject set `Permission:<name>#members` (OPL: members are users
|
||||
// or groups, resolved transitively) — the source of truth for the JWT `permissions` claim. It shares the
|
||||
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
|
||||
// are reused from admin-groups. The permission-specific piece is the **effective access** view:
|
||||
// `keto.expand(Permission:<name>#members)` flattened to the distinct users who hold the permission directly or via
|
||||
// a group — matching what login projects into the JWT (login.ts readPermissions). 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_PERMISSIONS_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 PERMISSION_NS = "Permission";
|
||||
const GRANTED = "granted";
|
||||
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 permission and a group share the URL-safe name rule and the user|group membership model.
|
||||
export type PermissionView = GroupView;
|
||||
export const isValidRoleName = isValidGroupName;
|
||||
export const permissionsFromTuples = groupsFromTuples;
|
||||
export interface EffectiveUser {
|
||||
label: string; // email (or the raw id when unresolved)
|
||||
}
|
||||
|
||||
// The full membership tuple for assigning/revoking `value` to/from `permission` (null if value is invalid).
|
||||
export function permissionGrantTuple(permission: string, value: string): RelationTuple | null {
|
||||
const subject = parseSubject(value);
|
||||
return subject ? { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject } : null;
|
||||
}
|
||||
|
||||
// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the permission
|
||||
// (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: PermissionView) => number | string> = {
|
||||
members: (r) => r.memberCount,
|
||||
name: (r) => r.name,
|
||||
};
|
||||
const COLUMNS = [
|
||||
{ key: "name", label: "Permission" },
|
||||
{ key: "members", label: "Members" },
|
||||
];
|
||||
|
||||
function detailHref(name: string): string {
|
||||
return `${ADMIN_PERMISSIONS_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_PERMISSIONS_BASE}?${qs}` : ADMIN_PERMISSIONS_BASE;
|
||||
}
|
||||
|
||||
export function buildPermissionsListModel(opts: {
|
||||
csrfToken?: string;
|
||||
permissions: PermissionView[];
|
||||
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.permissions.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_PERMISSIONS_BASE, label: "Admin" }, { label: "Permissions" }],
|
||||
filterBar: listFilterBar(state),
|
||||
pagination: listPagination(state, page),
|
||||
table: listTable(rows, state, sort),
|
||||
title: "Permissions",
|
||||
};
|
||||
}
|
||||
|
||||
function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
|
||||
return {
|
||||
caption: "Permissions",
|
||||
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_PERMISSIONS_BASE,
|
||||
label: "Filter permissions",
|
||||
pills,
|
||||
rows: [[
|
||||
{ label: "Search permissions", name: "q", placeholder: "Search permission 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 buildPermissionFormModel(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: "Permission name", name: "name", required: true, value: opts.values?.name ?? "",
|
||||
};
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: "New" }],
|
||||
error: opts.error,
|
||||
form: {
|
||||
action: ADMIN_PERMISSIONS_BASE,
|
||||
cancelHref: ADMIN_PERMISSIONS_BASE,
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
memberOptions: opts.memberOptions,
|
||||
nameField,
|
||||
selectedMember: opts.values?.member ?? "",
|
||||
submitLabel: "Create permission",
|
||||
},
|
||||
title: "New permission",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPermissionDetailModel(opts: {
|
||||
candidates: MemberOption[];
|
||||
csrfToken?: string;
|
||||
effective: EffectiveUser[];
|
||||
error?: string;
|
||||
members: MemberView[];
|
||||
permission: { name: string };
|
||||
}) {
|
||||
const name = opts.permission.name;
|
||||
const base = detailHref(name);
|
||||
const taken = new Set(opts.members.map((m) => m.subject));
|
||||
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the permission itself
|
||||
return {
|
||||
add: { action: `${base}/members`, options },
|
||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: name }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
delete: { action: `${base}/delete` },
|
||||
effective: opts.effective,
|
||||
error: opts.error,
|
||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
||||
permission: { name },
|
||||
title: name,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- request handler (imperative shell) ----
|
||||
|
||||
// instant-revoke: a permission change for a `user:<id>` member must take effect now, so revoke that
|
||||
// user's live tokens (a re-mint then re-reads permissions 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 permission 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: PERMISSION_NS, object: name, relation: GRANTED, pageSize: 1 });
|
||||
return page.tuples.length > 0;
|
||||
}
|
||||
|
||||
// The distinct users who effectively hold the permission (expand → flatten → label by email). Skipped for
|
||||
// an empty permission (no member tuples) so we don't expand a non-existent Keto object.
|
||||
async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map<string, string>): Promise<EffectiveUser[]> {
|
||||
if (!hasMembers) return [];
|
||||
const tree = await keto.expand({ namespace: PERMISSION_NS, object: name, relation: GRANTED }, { 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 permission 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: buildPermissionFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "permission-form" };
|
||||
};
|
||||
|
||||
// The permission detail (members + effective access). With `error` set it's a 400 (a rejected action).
|
||||
const permissionDetailResult = 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: PERMISSION_NS, object: name, relation: GRANTED });
|
||||
const members = tuples.map((t) => memberView(t, emailById));
|
||||
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
|
||||
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildPermissionDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, permission: { name }, ...(error ? { error } : {}) }) }, view: "permission-detail" };
|
||||
return error ? { ...result, status: 400 } : result;
|
||||
};
|
||||
|
||||
// GET /admin/permissions — the list.
|
||||
export const rolesList = withRoles(async ({ ctx, keto }) => {
|
||||
const permissions = permissionsFromTuples(await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED }));
|
||||
return { data: { chrome: ctx.chrome, model: buildPermissionsListModel({ csrfToken: ctx.chrome.csrfToken, permissions, url: ctx.url }) }, view: "permissions" };
|
||||
});
|
||||
|
||||
// POST /admin/permissions — 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 = permissionGrantTuple(name, member);
|
||||
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
|
||||
if (!isValidRoleName(name)) return reject("Permission names use lowercase letters, digits, dashes and underscores.");
|
||||
if (!tuple) return reject("Pick a user or group to assign the permission to.");
|
||||
if (await roleExists(keto, name)) return reject("A permission with that name already exists.");
|
||||
await keto.writeTuple(tuple);
|
||||
revokeUserMember(revoke, member);
|
||||
ctx.log.info("admin: permission created + first member assigned", { actor: user.id, member, permission: name });
|
||||
return { redirect: detailHref(name) };
|
||||
});
|
||||
|
||||
// GET /admin/permissions/new — the create form.
|
||||
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
|
||||
|
||||
// GET /admin/permissions/:name — the detail (members + effective access via Keto expand).
|
||||
export const rolesDetail = withRoleName((deps, name) => permissionDetailResult(deps, name));
|
||||
|
||||
// POST /admin/permissions/: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 = permissionGrantTuple(name, member); // the picker only offers real users/groups
|
||||
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission assigned", { actor: user.id, member, permission: name }); }
|
||||
return { redirect: detailHref(name) };
|
||||
});
|
||||
|
||||
// GET /admin/permissions/:name/delete — confirm, except the admin permission can't be deleted.
|
||||
export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
||||
if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access.");
|
||||
const base = detailHref(name);
|
||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { href: base, label: name }, { label: "Delete" }],
|
||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete permission",
|
||||
message: `Delete permission ${name}? This revokes it from everyone it's assigned to.`, title: "Delete permission",
|
||||
}) }, view: "confirm" });
|
||||
});
|
||||
|
||||
// POST /admin/permissions/:name/delete — remove every member tuple (a whole-permission delete lags per the
|
||||
// documented instant-revoke tradeoff; the admin permission 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 permissionDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access.");
|
||||
await keto.deleteTuple({ namespace: PERMISSION_NS, object: name, relation: GRANTED });
|
||||
ctx.log.info("admin: permission deleted", { actor: user.id, permission: name });
|
||||
return { redirect: ADMIN_PERMISSIONS_BASE };
|
||||
});
|
||||
|
||||
// POST /admin/permissions/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
|
||||
// Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
|
||||
// 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 permissionDetailResult(deps, name, "You can't revoke your own admin access.");
|
||||
const tuple = permissionGrantTuple(name, member);
|
||||
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission unassigned", { actor: user.id, member, permission: name }); }
|
||||
return { redirect: detailHref(name) };
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { PageChrome, PluginSettings } from "@plainpages/plugin-api";
|
||||
import { buildPluginSettingsModel } from "./admin-plugin-settings.ts";
|
||||
|
||||
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
||||
|
||||
const CATALOG: readonly PluginSettings[] = [
|
||||
{
|
||||
pluginId: "scheduling",
|
||||
settings: [
|
||||
{ description: "Where shifts come from", envName: "PLUGIN_SETTING_SCHEDULING_UPSTREAM", key: "upstream", required: true, secret: false, source: "env", type: "url", value: "https://shifts.test" },
|
||||
{ envName: "PLUGIN_SETTING_SCHEDULING_MODE", key: "mode", required: false, secret: false, source: "default", type: "enum", value: "strict", values: ["strict", "lenient"] },
|
||||
{ envName: "PLUGIN_SETTING_SCHEDULING_NOTE", key: "note", required: false, secret: false, source: "unset", type: "string" },
|
||||
],
|
||||
},
|
||||
{ pluginId: "quiet", settings: [] },
|
||||
];
|
||||
|
||||
test("a row carries the variable to set and where the value came from", () => {
|
||||
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
|
||||
const rows = model.groups[0]?.table.rows ?? [];
|
||||
assert.deepEqual(rows.map((r) => r.name), ["upstream", "mode", "note"]);
|
||||
assert.deepEqual(rows[0]?.cells, [
|
||||
{ rowHeader: { text: "upstream" } }, "Where shifts come from", "url", "Yes", "PLUGIN_SETTING_SCHEDULING_UPSTREAM", "Environment", "https://shifts.test",
|
||||
]);
|
||||
assert.equal(rows[1]?.cells[2], "enum (strict, lenient)"); // the choices are the useful half of the type
|
||||
assert.equal(rows[2]?.cells[5], "Not set");
|
||||
});
|
||||
|
||||
test("a plugin declaring nothing still gets a section, so it is visibly installed", () => {
|
||||
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
|
||||
assert.deepEqual(model.groups.map((g) => g.pluginId), ["scheduling", "quiet"]);
|
||||
assert.deepEqual(model.groups[1]?.table.rows, []);
|
||||
assert.match(model.groups[1]?.emptyText ?? "", /no settings/i);
|
||||
});
|
||||
|
||||
test("a secret renders as set-or-not, never as a value, a mask or a length", () => {
|
||||
const settings: readonly PluginSettings[] = [{
|
||||
pluginId: "billing",
|
||||
settings: [
|
||||
{ envName: "PLUGIN_SETTING_BILLING_API_KEY", key: "apiKey", required: false, secret: true, source: "env", type: "string" },
|
||||
{ envName: "PLUGIN_SETTING_BILLING_WEBHOOK_KEY", key: "webhookKey", required: false, secret: true, source: "unset", type: "string" },
|
||||
],
|
||||
}];
|
||||
const rows = buildPluginSettingsModel({ chrome: CHROME, settings }).groups[0]?.table.rows ?? [];
|
||||
assert.equal(rows[0]?.cells[6], "Secret — set");
|
||||
assert.equal(rows[1]?.cells[6], "Secret — not set");
|
||||
});
|
||||
|
||||
test("two tables on one page need distinct row-action id stems", () => {
|
||||
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
|
||||
const stems = model.groups.map((g) => g.table.actionsId);
|
||||
assert.equal(new Set(stems).size, stems.length);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// Plugin settings admin screen: what each installed plugin declares it can be configured with, the
|
||||
// variable that sets it, and how each key resolved. Read-only — the host reads settings from the
|
||||
// environment at boot, so changing one is a deploy, not a form.
|
||||
|
||||
import { type PageChrome, type PluginSettings, type RouteHandler, type SettingSummary, type Translate } from "@plainpages/plugin-api";
|
||||
import { ADMIN_EN, requirePermission } from "./admin-shared.ts";
|
||||
|
||||
interface SettingsGroup {
|
||||
emptyText: string;
|
||||
pluginId: string;
|
||||
table: {
|
||||
actionsId: string;
|
||||
caption: string;
|
||||
columns: { label: string }[];
|
||||
rows: { cells: (string | { rowHeader: { text: string } })[]; name: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
// One group per installed plugin, including those declaring nothing — an operator who cannot find
|
||||
// their plugin here has not installed it, which is the other half of what this screen answers.
|
||||
export function buildPluginSettingsModel(opts: { chrome: PageChrome; settings: readonly PluginSettings[]; t?: Translate }) {
|
||||
const t = opts.t ?? ADMIN_EN;
|
||||
return {
|
||||
breadcrumbs: [{ label: t("admin.pluginSettings.title") }],
|
||||
chrome: opts.chrome,
|
||||
groups: opts.settings.map((plugin): SettingsGroup => ({
|
||||
emptyText: t("admin.pluginSettings.none"),
|
||||
pluginId: plugin.pluginId,
|
||||
table: {
|
||||
actionsId: `settings-${plugin.pluginId}`, // two tables share this page, so the stem must differ
|
||||
caption: t("admin.pluginSettings.caption", { plugin: plugin.pluginId }),
|
||||
columns: [
|
||||
{ label: t("admin.pluginSettings.column.key") },
|
||||
{ label: t("admin.pluginSettings.column.description") },
|
||||
{ label: t("admin.pluginSettings.column.type") },
|
||||
{ label: t("admin.pluginSettings.column.required") },
|
||||
{ label: t("admin.pluginSettings.column.variable") },
|
||||
{ label: t("admin.pluginSettings.column.source") },
|
||||
{ label: t("admin.pluginSettings.column.value") },
|
||||
],
|
||||
rows: plugin.settings.map((setting) => ({
|
||||
cells: [
|
||||
{ rowHeader: { text: setting.key } },
|
||||
setting.description ?? "",
|
||||
typeLabel(setting),
|
||||
t(setting.required ? "admin.pluginSettings.yes" : "admin.pluginSettings.no"),
|
||||
setting.envName,
|
||||
t(`admin.pluginSettings.source.${setting.source}`),
|
||||
valueLabel(setting, t),
|
||||
],
|
||||
name: setting.key,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
title: t("admin.pluginSettings.title"),
|
||||
};
|
||||
}
|
||||
|
||||
// An enum's choices are the useful half of its type — they are what the operator must pick from.
|
||||
function typeLabel(setting: SettingSummary): string {
|
||||
return setting.type === "enum" && setting.values ? `${setting.type} (${setting.values.join(", ")})` : setting.type;
|
||||
}
|
||||
|
||||
// A secret never renders its value — not the value, not a mask of it, not its length. Whether it
|
||||
// resolved and from where is what an operator needs, and the source column already says the rest.
|
||||
function valueLabel(setting: SettingSummary, t: Translate): string {
|
||||
if (setting.secret) return t(setting.source === "unset" ? "admin.pluginSettings.secretUnset" : "admin.pluginSettings.secretSet");
|
||||
return setting.value ?? t("admin.pluginSettings.unset");
|
||||
}
|
||||
|
||||
// GET /admin/plugin-settings
|
||||
export const pluginSettingsList: RouteHandler = (ctx) => {
|
||||
requirePermission(ctx, "plugin-settings");
|
||||
return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" };
|
||||
};
|
||||
@@ -1,15 +1,16 @@
|
||||
// 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.
|
||||
// Import only from the #plugin-api barrel — the same contract boundary the plugin code uses.
|
||||
// Import only from the @plainpages/plugin-api barrel — the same contract boundary the plugin code uses.
|
||||
import assert from "node:assert/strict";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import { test } from "node:test";
|
||||
import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
|
||||
import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
|
||||
import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "@plainpages/plugin-api";
|
||||
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", permissions: ["admin"] };
|
||||
const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users: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;
|
||||
|
||||
@@ -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;
|
||||
req.method = opts.method ?? "GET";
|
||||
return {
|
||||
chrome: CHROME, user: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||
permissions: opts.user?.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
|
||||
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
|
||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 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 screens each gate on their own read permission", () => {
|
||||
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 them 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.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/permissions", "/admin/clients"]);
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Permissions", "OAuth2 clients"]);
|
||||
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients", "/admin/plugin-settings"]);
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings:read"]);
|
||||
// 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", "admin.nav.pluginSettings"]);
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients", "Plugin settings"]);
|
||||
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 ----
|
||||
|
||||
test("requireAdmin: anonymous → 401→/login, signed-in non-admin → 403, admin → the user", () => {
|
||||
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(() => requireAdmin(fakeCtx({ user: member })), (e: unknown) => e instanceof GuardError && e.status === 403);
|
||||
assert.equal(requireAdmin(fakeCtx({ user: admin })), admin);
|
||||
test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
|
||||
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(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
|
||||
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 () => {
|
||||
|
||||
@@ -1,40 +1,65 @@
|
||||
// Shared plumbing for the admin example plugin: the section nav fragment, the admin-only gate, the
|
||||
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers
|
||||
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
|
||||
// everything imports the host only through the #plugin-api barrel.
|
||||
// Shared plumbing for the admin example plugin: the section nav fragment, the screen gate, the
|
||||
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers.
|
||||
// Everything imports the host only through the @plainpages/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 "@plainpages/plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
|
||||
// This plugin's English — its catalog, then the host's — for a view model built outside a request,
|
||||
// i.e. its unit tests. At runtime the handlers pass ctx.t instead.
|
||||
export const ADMIN_EN: Translate = englishTranslator(enUS);
|
||||
|
||||
export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section
|
||||
export const ADMIN_USERS_BASE = "/admin/users";
|
||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||
export const ADMIN_PERMISSIONS_BASE = "/admin/permissions";
|
||||
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||
export const ADMIN_PLUGIN_SETTINGS_BASE = "/admin/plugin-settings";
|
||||
|
||||
export type AdminScreen = "clients" | "groups" | "permissions" | "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" | "plugin-settings" | "users";
|
||||
|
||||
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
|
||||
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
|
||||
// non-admin), and current-marks the active item — so there is no `current`/`open` state here.
|
||||
export type AdminAction = "read" | "write";
|
||||
|
||||
// `<resource>:<action>` (README → Naming a permission).
|
||||
export function permissionName(resource: AdminResource, action: AdminAction): string {
|
||||
return `${resource}:${action}`;
|
||||
}
|
||||
|
||||
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
|
||||
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately
|
||||
// local: generalised, it would make authorization a function of the transport verb (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 = {
|
||||
children: [
|
||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
|
||||
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
|
||||
{ href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "Permissions" },
|
||||
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
|
||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
|
||||
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
|
||||
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") },
|
||||
{ href: ADMIN_PLUGIN_SETTINGS_BASE, icon: "i-sliders", id: "plugin-settings", label: "admin.nav.pluginSettings", permission: permissionName("plugin-settings", "read") },
|
||||
],
|
||||
icon: "i-shield",
|
||||
id: "admin",
|
||||
label: "Admin",
|
||||
permission: ADMIN_PERMISSION,
|
||||
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
|
||||
};
|
||||
|
||||
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the
|
||||
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
|
||||
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
|
||||
export function requireAdmin(ctx: RequestContext): User {
|
||||
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
||||
// declares the same permission, so this is defence-in-depth and what a direct unit test relies on.
|
||||
// `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create
|
||||
// form or a delete-confirm page — which refuses a reader rather than rendering a form whose submit
|
||||
// would 403. The route table declares the same override, so the two cannot disagree.
|
||||
export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): User {
|
||||
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
||||
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin permission required");
|
||||
const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET"));
|
||||
if (!can(ctx, permission)) throw new GuardError(403, `${permission} required`);
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -49,13 +74,13 @@ export async function guardedForm(ctx: RequestContext): Promise<URLSearchParams
|
||||
|
||||
// A themed "not found" (bad id/name in the path) rendered in the admin shell — 404, never a 500.
|
||||
export function notFound(ctx: RequestContext): RouteResult {
|
||||
return { data: { chrome: ctx.chrome, message: "That item doesn't exist.", title: "Not found" }, status: 404, view: "notice" };
|
||||
return { data: { chrome: ctx.chrome, message: ctx.t("admin.notFound.message"), title: ctx.t("admin.notFound.title") }, status: 404, view: "notice" };
|
||||
}
|
||||
|
||||
// A capability the plugin needs isn't on ctx.system (Ory not wired). Login already requires these in
|
||||
// a real deployment, so this is the honest 503 fallback for a misconfigured host, not a crash.
|
||||
export function unavailable(ctx: RequestContext, what: string): RouteResult {
|
||||
return { data: { chrome: ctx.chrome, message: `${what} is not configured on this deployment.`, title: "Admin unavailable" }, status: 503, view: "notice" };
|
||||
return { data: { chrome: ctx.chrome, message: ctx.t("admin.unavailable.message", { what }), title: ctx.t("admin.unavailable.title") }, status: 503, view: "notice" };
|
||||
}
|
||||
|
||||
// Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { Identity } from "#plugin-api";
|
||||
import type { Identity } from "@plainpages/plugin-api";
|
||||
import {
|
||||
buildUserFormModel,
|
||||
buildUsersListModel,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Users admin screen: list Kratos identities (filter/sort/paginate) +
|
||||
// create/edit/deactivate/delete/trigger-recovery. Writes go only to Kratos via the admin client
|
||||
// (README "stateless"). Pure builders turn identities + the request URL into building-block view
|
||||
// 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).
|
||||
// create/edit/deactivate/delete/trigger-recovery. Pure builders turn identities + the request URL
|
||||
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
|
||||
// a shared `withUser` gate.
|
||||
|
||||
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
|
||||
import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
||||
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 DEFAULT_PAGE_SIZE = 25;
|
||||
@@ -30,8 +30,6 @@ export interface UserInput {
|
||||
password: string;
|
||||
}
|
||||
|
||||
const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
function nameParts(identity: Identity): { first: string; last: string } {
|
||||
const nm = ((identity.traits?.name ?? {}) as { first?: unknown; last?: unknown });
|
||||
return {
|
||||
@@ -88,9 +86,9 @@ const SORT: Record<string, (u: UserView) => string> = {
|
||||
status: (u) => u.state,
|
||||
};
|
||||
const COLUMNS = [
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "email", label: "Email" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "name", label: "admin.users.column.name" },
|
||||
{ key: "email", label: "admin.users.column.email" },
|
||||
{ key: "status", label: "admin.users.column.status" },
|
||||
];
|
||||
|
||||
// Canonical list URL from the current state + per-link overrides; omits defaults so links stay tidy.
|
||||
@@ -107,10 +105,13 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
||||
}
|
||||
|
||||
export function buildUsersListModel(opts: {
|
||||
canWrite?: boolean;
|
||||
csrfToken?: string;
|
||||
identities: Identity[];
|
||||
t?: Translate;
|
||||
url: URL | URLSearchParams | string;
|
||||
}) {
|
||||
const t = opts.t ?? ADMIN_EN;
|
||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||
const status = query.filters.status?.[0] ?? "all";
|
||||
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
||||
@@ -133,70 +134,71 @@ export function buildUsersListModel(opts: {
|
||||
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken, status };
|
||||
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Admin" }, { label: "Users" }],
|
||||
filterBar: listFilterBar(state, all.length),
|
||||
pagination: listPagination(state, page),
|
||||
table: listTable(rows, state, sort),
|
||||
title: "Users",
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }],
|
||||
canWrite: opts.canWrite !== false,
|
||||
filterBar: listFilterBar(state, all.length, t),
|
||||
pagination: listPagination(state, page, t),
|
||||
table: listTable(rows, state, sort, t),
|
||||
title: t("admin.users.title"),
|
||||
};
|
||||
}
|
||||
|
||||
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
|
||||
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
|
||||
return {
|
||||
actions: true,
|
||||
caption: "Users",
|
||||
caption: t("admin.users.title"),
|
||||
columns: COLUMNS.map((c) => {
|
||||
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
||||
const next = dir === "asc" ? `-${c.key}` : c.key; // asc→desc, else→asc
|
||||
return { href: listHref(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
|
||||
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
|
||||
}),
|
||||
rows: rows.map((u) => ({
|
||||
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: "Edit" }],
|
||||
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: t("common.edit") }],
|
||||
cells: [
|
||||
{ user: { initials: u.initials, name: u.name } },
|
||||
u.email,
|
||||
{ badge: { label: cap(u.state), tone: STATE_TONE[u.state] ?? "info" } },
|
||||
{ badge: { label: t(`admin.users.status.${u.state}`), tone: STATE_TONE[u.state] ?? "info" } },
|
||||
],
|
||||
name: u.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function listFilterBar(state: ListState, total: number) {
|
||||
function listFilterBar(state: ListState, total: number, t: Translate) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
if (state.status !== "all") pills.push({ label: "Status", remove: listHref(state, { page: 1, status: "all" }), value: cap(state.status) });
|
||||
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
if (state.status !== "all") pills.push({ label: t("admin.users.status.label"), remove: listHref(state, { page: 1, status: "all" }), value: t(`admin.users.status.${state.status}`) });
|
||||
return {
|
||||
applyLabel: "Apply filters",
|
||||
applyLabel: t("filter.apply"), // an untranslated core key still resolves: the host catalog is the fallback
|
||||
clearHref: ADMIN_USERS_BASE,
|
||||
label: "Filter users",
|
||||
label: t("admin.users.filter"),
|
||||
pills,
|
||||
rows: [[
|
||||
{ label: "Search users", name: "q", placeholder: "Search name or email…", type: "search", value: state.q },
|
||||
{ legend: "Status", name: "status", options: [
|
||||
{ count: total, label: "All", value: "all" },
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Inactive", value: "inactive" },
|
||||
{ label: t("admin.users.searchLabel"), name: "q", placeholder: t("admin.users.searchPlaceholder"), type: "search", value: state.q },
|
||||
{ legend: t("admin.users.status.label"), name: "status", options: [
|
||||
{ count: total, label: t("admin.users.status.all"), value: "all" },
|
||||
{ label: t("admin.users.status.active"), value: "active" },
|
||||
{ label: t("admin.users.status.inactive"), value: "inactive" },
|
||||
], type: "segmented", value: state.status },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
|
||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
|
||||
const hidden: { name: string; value: string }[] = [];
|
||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||
if (state.status !== "all") hidden.push({ name: "status", value: state.status });
|
||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
||||
return {
|
||||
label: "Users pagination",
|
||||
label: t("admin.users.pagination"),
|
||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
||||
pages: page.pages.map((p) =>
|
||||
p.ellipsis ? { ellipsis: true }
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
||||
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
|
||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
@@ -216,12 +218,16 @@ export interface FieldConfig {
|
||||
}
|
||||
|
||||
export function buildUserFormModel(opts: {
|
||||
canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
identity?: Identity | null;
|
||||
permissions?: PermissionPicker; // editing only — a user that doesn't exist yet can hold nothing
|
||||
recovery?: RecoveryCode;
|
||||
t?: Translate;
|
||||
values?: Partial<UserInput>;
|
||||
}) {
|
||||
const t = opts.t ?? ADMIN_EN;
|
||||
const editing = opts.identity != null;
|
||||
const view = editing ? toUserView(opts.identity!) : null;
|
||||
const np = editing ? nameParts(opts.identity!) : { first: opts.values?.first ?? "", last: opts.values?.last ?? "" };
|
||||
@@ -229,27 +235,30 @@ export function buildUserFormModel(opts: {
|
||||
const idPath = editing ? `${ADMIN_USERS_BASE}/${encodeURIComponent(view!.id)}` : ADMIN_USERS_BASE;
|
||||
|
||||
const fields: FieldConfig[] = [
|
||||
{ autocomplete: "email", icon: "i-mail", id: "email", label: "Email", name: "email", required: !editing, type: "email", value: email,
|
||||
...(editing ? { hint: "The login identifier — can't be changed here.", readonly: true } : {}) },
|
||||
{ id: "first", label: "First name", name: "first", optional: true, value: np.first },
|
||||
{ id: "last", label: "Last name", name: "last", optional: true, value: np.last },
|
||||
{ autocomplete: "email", icon: "i-mail", id: "email", label: t("admin.users.field.email"), name: "email", required: !editing, type: "email", value: email,
|
||||
...(editing ? { hint: t("admin.users.field.emailHint"), readonly: true } : {}) },
|
||||
{ id: "first", label: t("admin.users.field.first"), name: "first", optional: true, value: np.first },
|
||||
{ id: "last", label: t("admin.users.field.last"), name: "last", optional: true, value: np.last },
|
||||
];
|
||||
if (!editing) fields.push({ autocomplete: "new-password", hint: "Optional — leave blank to have the user set one via a recovery code.", icon: "i-lock", id: "password", label: "Password", name: "password", optional: true, type: "password" });
|
||||
if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" });
|
||||
|
||||
const canWrite = opts.canWrite !== false;
|
||||
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 ? {
|
||||
deleteAction: `${idPath}/delete`,
|
||||
id: view!.id,
|
||||
nextLabel: view!.state === "inactive" ? "Reactivate" : "Deactivate",
|
||||
nextLabel: view!.state === "inactive" ? t("admin.users.reactivate") : t("admin.users.deactivate"),
|
||||
recoveryAction: `${idPath}/recovery`,
|
||||
state: view!.state,
|
||||
stateAction: `${idPath}/state`,
|
||||
} : undefined,
|
||||
error: opts.error,
|
||||
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? "Save changes" : "Create user" },
|
||||
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") },
|
||||
permissions: editing ? opts.permissions : undefined,
|
||||
recovery: opts.recovery,
|
||||
title: editing ? "Edit user" : "New user",
|
||||
title: editing ? t("admin.users.edit") : t("admin.users.new"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -264,39 +273,42 @@ function readUserInput(form: URLSearchParams): UserInput {
|
||||
};
|
||||
}
|
||||
|
||||
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (admin only) and
|
||||
// the Kratos capability (else a themed 503). Each route below is a thin handler over these.
|
||||
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
|
||||
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (`users:read` on a
|
||||
// GET, `users:write` on a POST) and the Kratos capability (else a themed 503). Each route below is a
|
||||
// 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
|
||||
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
|
||||
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler {
|
||||
// Resolve the shared deps, then run `inner`. The route's own `permission` already gated at the host;
|
||||
// `requirePermission` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
|
||||
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
|
||||
return async (ctx) => {
|
||||
const user = requireAdmin(ctx);
|
||||
const user = requirePermission(ctx, "users", action);
|
||||
const kratosAdmin = ctx.system?.kratosAdmin;
|
||||
if (!kratosAdmin) return unavailable(ctx, "Kratos identity admin");
|
||||
return inner({ ctx, kratosAdmin, revoke: ctx.system?.revoke, user });
|
||||
if (!kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.kratos"));
|
||||
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
|
||||
// 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) => {
|
||||
const id = deps.ctx.params["id"] ?? "";
|
||||
const identity = await deps.kratosAdmin.getIdentity(id);
|
||||
if (!identity) return notFound(deps.ctx);
|
||||
return inner(deps, identity, id);
|
||||
});
|
||||
}, action);
|
||||
}
|
||||
|
||||
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
|
||||
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "user-form" });
|
||||
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "user-form" });
|
||||
|
||||
// GET /admin/users — the filtered/sorted/paged list.
|
||||
export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
|
||||
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
||||
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ csrfToken: ctx.chrome.csrfToken, identities, url: ctx.url }) }, view: "users" };
|
||||
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ 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.
|
||||
@@ -305,7 +317,7 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
|
||||
try {
|
||||
await kratosAdmin.createIdentity(createIdentityPayload(input));
|
||||
} catch (err) {
|
||||
if (err instanceof KratosError) return { ...formResult(ctx, { error: createError(err), values: input }), status: 400 };
|
||||
if (err instanceof KratosError) return { ...formResult(ctx, { error: createError(err, ctx.t), values: input }), status: 400 };
|
||||
throw err;
|
||||
}
|
||||
ctx.log.info("admin: user created", { actor: user.id, email: input.email });
|
||||
@@ -313,18 +325,73 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
|
||||
});
|
||||
|
||||
// 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.
|
||||
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 — leaving a `curl` against Keto as the only way back in.
|
||||
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).
|
||||
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))!);
|
||||
try {
|
||||
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
|
||||
} catch (err) {
|
||||
if (err instanceof KratosError) return { ...formResult(ctx, { error: "Could not save changes — check the fields and try again.", identity }), status: 400 };
|
||||
// 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;
|
||||
}
|
||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||
@@ -334,7 +401,7 @@ export const usersUpdate = withTarget(async ({ ctx, kratosAdmin }, identity, id)
|
||||
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
|
||||
export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
||||
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
|
||||
if (id === user.id) return { ...formResult(ctx, { error: "You can't deactivate your own account.", identity }), status: 400 };
|
||||
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDeactivate"), identity }), status: 400 };
|
||||
const nextState = identity.state === "inactive" ? "active" : "inactive";
|
||||
await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState));
|
||||
if (nextState === "inactive") revoke?.(id);
|
||||
@@ -344,20 +411,21 @@ export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user },
|
||||
|
||||
// GET /admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
|
||||
export const usersDeleteConfirm = withTarget((deps, identity, id) => {
|
||||
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: "You can't delete your own account.", identity }), status: 400 });
|
||||
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 });
|
||||
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
|
||||
const view = toUserView(identity);
|
||||
const tt = deps.ctx.t;
|
||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }],
|
||||
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: "Delete user",
|
||||
message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, title: "Delete user",
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: tt("admin.users.title") }, { href: back, label: view.name }, { label: tt("common.delete") }],
|
||||
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: tt("admin.users.delete"),
|
||||
message: tt("admin.users.deleteMessage", { email: view.email }), title: tt("admin.users.delete"),
|
||||
}) }, view: "confirm" });
|
||||
});
|
||||
}, "write");
|
||||
|
||||
// POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
|
||||
export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
||||
await guardedForm(ctx); // CSRF-verify the POST
|
||||
if (id === user.id) return { ...formResult(ctx, { error: "You can't delete your own account.", identity }), status: 400 };
|
||||
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 };
|
||||
await kratosAdmin.deleteIdentity(id);
|
||||
revoke?.(id);
|
||||
ctx.log.info("admin: user deleted", { actor: user.id, target: id });
|
||||
@@ -365,14 +433,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.
|
||||
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
|
||||
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 {
|
||||
return err.status === 409
|
||||
? "A user with that email already exists."
|
||||
: "Could not create the user — check the email and try again.";
|
||||
// The picker as a spreadable fragment, so a re-render never silently drops the section.
|
||||
async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> {
|
||||
const permissions = await userPermissionPicker(deps, id);
|
||||
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,156 @@
|
||||
// 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.pluginSettings": "Plugin settings",
|
||||
"admin.nav.section": "Admin",
|
||||
"admin.nav.users": "Users",
|
||||
|
||||
"admin.pluginSettings.caption": "Settings declared by {{plugin}}",
|
||||
"admin.pluginSettings.column.description": "Description",
|
||||
"admin.pluginSettings.column.key": "Key",
|
||||
"admin.pluginSettings.column.required": "Required",
|
||||
"admin.pluginSettings.column.source": "Source",
|
||||
"admin.pluginSettings.column.type": "Type",
|
||||
"admin.pluginSettings.column.value": "Value",
|
||||
"admin.pluginSettings.column.variable": "Variable",
|
||||
"admin.pluginSettings.no": "No",
|
||||
"admin.pluginSettings.none": "This plugin declares no settings.",
|
||||
"admin.pluginSettings.secretSet": "Secret — set",
|
||||
"admin.pluginSettings.secretUnset": "Secret — not set",
|
||||
"admin.pluginSettings.source.default": "Default",
|
||||
"admin.pluginSettings.source.env": "Environment",
|
||||
"admin.pluginSettings.source.unset": "Not set",
|
||||
"admin.pluginSettings.title": "Plugin settings",
|
||||
"admin.pluginSettings.unset": "—",
|
||||
"admin.pluginSettings.yes": "Yes",
|
||||
|
||||
"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,154 @@
|
||||
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.pluginSettings": "Tilläggsinställningar",
|
||||
"admin.nav.section": "Administration",
|
||||
"admin.nav.users": "Användare",
|
||||
|
||||
"admin.pluginSettings.caption": "Inställningar som {{plugin}} deklarerar",
|
||||
"admin.pluginSettings.column.description": "Beskrivning",
|
||||
"admin.pluginSettings.column.key": "Nyckel",
|
||||
"admin.pluginSettings.column.required": "Obligatorisk",
|
||||
"admin.pluginSettings.column.source": "Källa",
|
||||
"admin.pluginSettings.column.type": "Typ",
|
||||
"admin.pluginSettings.column.value": "Värde",
|
||||
"admin.pluginSettings.column.variable": "Variabel",
|
||||
"admin.pluginSettings.no": "Nej",
|
||||
"admin.pluginSettings.none": "Det här tillägget deklarerar inga inställningar.",
|
||||
"admin.pluginSettings.secretSet": "Hemlighet — satt",
|
||||
"admin.pluginSettings.secretUnset": "Hemlighet — inte satt",
|
||||
"admin.pluginSettings.source.default": "Standardvärde",
|
||||
"admin.pluginSettings.source.env": "Miljövariabel",
|
||||
"admin.pluginSettings.source.unset": "Inte satt",
|
||||
"admin.pluginSettings.title": "Tilläggsinställningar",
|
||||
"admin.pluginSettings.unset": "—",
|
||||
"admin.pluginSettings.yes": "Ja",
|
||||
|
||||
"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,65 @@
|
||||
// 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 "@plainpages/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, 4);
|
||||
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 CRUD screens × read/write, plus read-only plugin settings — a screen that never writes
|
||||
// declares no `:write`, since a permission nothing gates on is one an operator can only mis-grant.
|
||||
// There is deliberately no `permissions:` pair either: 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",
|
||||
"plugin-settings:read",
|
||||
"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 CRUD screen; plugin settings has none
|
||||
});
|
||||
@@ -1,65 +1,77 @@
|
||||
// Admin example plugin: the Users / Groups / Roles / 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
|
||||
// GUI. Copy this folder to plugins/admin (then restart) to enable it — see README → Quick start.
|
||||
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
|
||||
// this folder to plugins/admin (then restart) to enable it — see README → Quick start.
|
||||
//
|
||||
// It is a *system* plugin: its handlers reach the host's Ory admin clients (Kratos/Keto/Hydra) and the
|
||||
// instant-revoke hook via ctx.system, which the host populates when those services are wired (the dev
|
||||
// stack wires all of them). Where a capability is absent the screen degrades to a themed 503.
|
||||
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
|
||||
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
|
||||
|
||||
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
|
||||
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
|
||||
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 { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-permissions.ts";
|
||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
|
||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
|
||||
import { pluginSettingsList } from "./admin-plugin-settings.ts";
|
||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||
import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
|
||||
|
||||
// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor
|
||||
// to /login, gives a signed-in non-admin 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.
|
||||
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION });
|
||||
// One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`,
|
||||
// derived through the same two helpers the in-handler guard uses, so the table below cannot drift
|
||||
// from it. The host redirects an anonymous visitor to /login, gives a signed-in user missing the
|
||||
// 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");
|
||||
const pluginSettings = on("plugin-settings");
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
|
||||
nav: [ADMIN_NAV],
|
||||
|
||||
permissions: [{ description: "Administer users, groups, permissions, and OAuth2 clients", name: 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" },
|
||||
{ description: "View the settings each installed plugin declares, and how they resolved", name: "plugin-settings:read" },
|
||||
],
|
||||
|
||||
routes: [
|
||||
// Users
|
||||
r("GET", "/users", usersList),
|
||||
r("POST", "/users", usersCreate),
|
||||
r("GET", "/users/new", usersNewForm),
|
||||
r("GET", "/users/:id", usersEditForm),
|
||||
r("POST", "/users/:id", usersUpdate),
|
||||
r("POST", "/users/:id/state", usersState),
|
||||
r("GET", "/users/:id/delete", usersDeleteConfirm),
|
||||
r("POST", "/users/:id/delete", usersDelete),
|
||||
r("POST", "/users/:id/recovery", usersRecovery),
|
||||
users("GET", "/users", usersList),
|
||||
users("POST", "/users", usersCreate),
|
||||
users("GET", "/users/new", usersNewForm, "write"),
|
||||
users("GET", "/users/:id", usersEditForm),
|
||||
users("POST", "/users/:id", usersUpdate),
|
||||
users("POST", "/users/:id/state", usersState),
|
||||
users("GET", "/users/:id/delete", usersDeleteConfirm, "write"),
|
||||
users("POST", "/users/:id/delete", usersDelete),
|
||||
users("POST", "/users/:id/recovery", usersRecovery),
|
||||
users("POST", "/users/:id/permissions", usersPermissions),
|
||||
// Groups
|
||||
r("GET", "/groups", groupsList),
|
||||
r("POST", "/groups", groupsCreate),
|
||||
r("GET", "/groups/new", groupsNewForm),
|
||||
r("GET", "/groups/:name", groupsDetail),
|
||||
r("POST", "/groups/:name/members", groupsAddMember),
|
||||
r("GET", "/groups/:name/delete", groupsDeleteConfirm),
|
||||
r("POST", "/groups/:name/delete", groupsDelete),
|
||||
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
||||
// Roles
|
||||
r("GET", "/permissions", rolesList),
|
||||
r("POST", "/permissions", rolesCreate),
|
||||
r("GET", "/permissions/new", rolesNewForm),
|
||||
r("GET", "/permissions/:name", rolesDetail),
|
||||
r("POST", "/permissions/:name/members", rolesAddMember),
|
||||
r("GET", "/permissions/:name/delete", rolesDeleteConfirm),
|
||||
r("POST", "/permissions/:name/delete", rolesDelete),
|
||||
r("POST", "/permissions/:name/members/delete", rolesRemoveMember),
|
||||
groups("GET", "/groups", groupsList),
|
||||
groups("POST", "/groups", groupsCreate),
|
||||
groups("GET", "/groups/new", groupsNewForm, "write"),
|
||||
groups("GET", "/groups/:name", groupsDetail),
|
||||
groups("POST", "/groups/:name/members", groupsAddMember),
|
||||
groups("GET", "/groups/:name/delete", groupsDeleteConfirm, "write"),
|
||||
groups("POST", "/groups/:name/delete", groupsDelete),
|
||||
groups("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
||||
groups("POST", "/groups/:name/permissions", groupsPermissions),
|
||||
// OAuth2 clients
|
||||
r("GET", "/clients", clientsList),
|
||||
r("POST", "/clients", clientsCreate),
|
||||
r("GET", "/clients/new", clientsNewForm),
|
||||
r("GET", "/clients/:id", clientsDetail),
|
||||
r("GET", "/clients/:id/delete", clientsDeleteConfirm),
|
||||
r("POST", "/clients/:id/delete", clientsDelete),
|
||||
clients("GET", "/clients", clientsList),
|
||||
clients("POST", "/clients", clientsCreate),
|
||||
clients("GET", "/clients/new", clientsNewForm, "write"),
|
||||
clients("GET", "/clients/:id", clientsDetail),
|
||||
clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"),
|
||||
clients("POST", "/clients/:id/delete", clientsDelete),
|
||||
// Plugin settings — read-only, so no :write route and no write-intent GET.
|
||||
pluginSettings("GET", "/plugin-settings", pluginSettingsList),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
shell. Doubles as the post-register page when `created`/`secret` are set.
|
||||
%><%
|
||||
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", {
|
||||
body,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<%#
|
||||
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
|
||||
the Permissions 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 filters = include("partials/filter-bar", model.filterBar);
|
||||
const table = include("partials/data-table", model.table);
|
||||
const pager = include("partials/pagination", model.pagination);
|
||||
const actions = '<a class="btn btn-primary" href="/admin/clients/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Register client</a>';
|
||||
// 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", {
|
||||
actions,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Group admin detail / membership page: the group-detail body in the app shell.
|
||||
%><%
|
||||
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", {
|
||||
body,
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
const filters = include("partials/filter-bar", model.filterBar);
|
||||
const table = include("partials/data-table", model.table);
|
||||
const pager = include("partials/pagination", model.pagination);
|
||||
const actions = '<a class="btn btn-primary" href="/admin/groups/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add group</a>';
|
||||
// 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", {
|
||||
actions,
|
||||
|
||||
@@ -11,28 +11,30 @@
|
||||
-%>
|
||||
<div class="form-page">
|
||||
<% if (locals.created) { -%>
|
||||
<%- include("partials/alert", { text: "Client registered.", tone: "pos" }) %>
|
||||
<%- include("partials/alert", { text: t("admin.clients.createdNotice"), tone: "pos" }) %>
|
||||
<% } -%>
|
||||
<% if (locals.secret) { -%>
|
||||
<section class="form-card" aria-labelledby="secret-h">
|
||||
<h2 class="card-title" id="secret-h">Client secret</h2>
|
||||
<p class="field-hint">Copy these now — the secret can't be shown again. Store them where the app reads its credentials.</p>
|
||||
<div class="field"><label for="cid">Client ID</label><input class="input" id="cid" type="text" value="<%= c.id %>" readonly></div>
|
||||
<div class="field"><label for="csecret">Client secret</label><input class="input" id="csecret" type="text" value="<%= locals.secret %>" readonly></div>
|
||||
<h2 class="card-title" id="secret-h"><%= t("admin.clients.secret") %></h2>
|
||||
<p class="field-hint"><%= t("admin.clients.secretHint") %></p>
|
||||
<div class="field"><label for="cid"><%= t("admin.clients.column.id") %></label><input class="input" id="cid" type="text" value="<%= c.id %>" readonly></div>
|
||||
<div class="field"><label for="csecret"><%= t("admin.clients.secret") %></label><input class="input" id="csecret" type="text" value="<%= locals.secret %>" readonly></div>
|
||||
</section>
|
||||
<% } -%>
|
||||
<section class="form-card" aria-labelledby="client-h">
|
||||
<h2 class="card-title" id="client-h"><%= c.name %></h2>
|
||||
<dl class="detail-list">
|
||||
<dt>Client ID</dt><dd><%= c.id %></dd>
|
||||
<dt>Type</dt><dd><%= c.public ? "Public (PKCE)" : "Confidential" %></dd>
|
||||
<dt>Consent</dt><dd><%= c.firstParty ? "First-party (auto-granted)" : "Shows the consent screen" %></dd>
|
||||
<dt>Scopes</dt><dd><%= c.scopes.length ? c.scopes.join(" ") : "—" %></dd>
|
||||
<dt>Redirect URIs</dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd>
|
||||
<dt><%= t("admin.clients.column.id") %></dt><dd><%= c.id %></dd>
|
||||
<dt><%= t("admin.clients.column.type") %></dt><dd><%= c.public ? t("admin.clients.publicPkce") : t("admin.clients.confidential") %></dd>
|
||||
<dt><%= t("admin.clients.consent.label") %></dt><dd><%= c.firstParty ? t("admin.clients.consent.firstParty") : t("admin.clients.consent.screen") %></dd>
|
||||
<dt><%= t("admin.clients.field.scopes") %></dt><dd><%= c.scopes.length ? c.scopes.join(" ") : "—" %></dd>
|
||||
<dt><%= t("admin.clients.field.redirectUris") %></dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="form-card admin-actions" aria-label="Client actions">
|
||||
<p class="field-hint">To change a client, delete and re-register — this issues a new client ID and secret. The secret is shown only once, at registration.</p>
|
||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete client</a>
|
||||
<% if (locals.canWrite !== false) { -%>
|
||||
<section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>">
|
||||
<p class="field-hint"><%= t("admin.clients.rereg") %></p>
|
||||
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a>
|
||||
</section>
|
||||
<% } -%>
|
||||
</div>
|
||||
|
||||
@@ -10,20 +10,20 @@
|
||||
<% if (locals.error) { -%>
|
||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
<%- include("partials/field", form.nameField) %>
|
||||
<div class="field">
|
||||
<label for="redirectUris">Redirect URIs</label>
|
||||
<label for="redirectUris"><%= t("admin.clients.field.redirectUris") %></label>
|
||||
<textarea class="input" id="redirectUris" name="redirectUris" rows="3" placeholder="https://app.example.com/callback"><%= form.redirectUris %></textarea>
|
||||
<span class="field-hint">One per line — where the app is sent back after sign-in.</span>
|
||||
<span class="field-hint"><%= t("admin.clients.field.redirectUrisHint") %></span>
|
||||
</div>
|
||||
<%- include("partials/field", form.scopeField) %>
|
||||
<label class="check"><input type="checkbox" name="public"<% if (form.public) { %> checked<% } %>> Public client (SPA / native app, PKCE — no secret)</label>
|
||||
<span class="field-hint">Browser and mobile apps can't keep a secret — choose Public. Server-side apps that can store one — leave it Confidential.</span>
|
||||
<span class="field-hint"><%= t("admin.clients.field.typeHint") %></span>
|
||||
<label class="check"><input type="checkbox" name="firstParty"<% if (form.firstParty) { %> checked<% } %>> First-party (auto-grant consent — skip the consent screen)</label>
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
||||
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
|
||||
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
csrfToken
|
||||
%>
|
||||
<div class="form-page">
|
||||
<section class="form-card admin-actions" aria-label="Confirm action">
|
||||
<section class="form-card admin-actions" aria-label="<%= t("admin.users.confirm") %>">
|
||||
<p><%= locals.message %></p>
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="<%= locals.cancelHref %>">Cancel</a>
|
||||
<form method="post" action="<%= locals.confirm.action %>"><input type="hidden" name="_csrf" value="<%= locals.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= locals.confirm.label %></button></form>
|
||||
<a class="btn" href="<%= localeHref(locals.cancelHref) %>"><%= t("common.cancel") %></a>
|
||||
<form method="post" action="<%= localeHref(locals.confirm.action) %>"><input type="hidden" name="_csrf" value="<%= locals.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= locals.confirm.label %></button></form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -17,26 +17,33 @@
|
||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<section class="form-card" aria-labelledby="members-h">
|
||||
<h2 class="card-title" id="members-h">Members</h2>
|
||||
<h2 class="card-title" id="members-h"><%= t("admin.groups.members") %></h2>
|
||||
<% if (members.rows.length) { -%>
|
||||
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= group.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>
|
||||
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: group.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
|
||||
<% members.rows.forEach((m) => { -%>
|
||||
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg>Remove</button></form></td></tr>
|
||||
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><% 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>
|
||||
<% } else { -%>
|
||||
<p class="cell-muted">No members yet.</p>
|
||||
<p class="cell-muted"><%= t("admin.groups.noMembers") %></p>
|
||||
<% } -%>
|
||||
</section>
|
||||
<% if (locals.canWrite !== false) { -%>
|
||||
<section class="form-card" aria-labelledby="add-h">
|
||||
<h2 class="card-title" id="add-h">Add a member</h2>
|
||||
<h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2>
|
||||
<% if (add.options.length) { -%>
|
||||
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add</button></form>
|
||||
<form class="inline-form" method="post" action="<%= localeHref(add.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("common.add") %></button></form>
|
||||
<% } else { -%>
|
||||
<p class="cell-muted">All users and groups are already members.</p>
|
||||
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
|
||||
<% } -%>
|
||||
</section>
|
||||
<section class="form-card admin-actions" aria-label="Group actions">
|
||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete group</a>
|
||||
<% } -%>
|
||||
<% 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>
|
||||
<% } -%>
|
||||
</div>
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
<% if (locals.error) { -%>
|
||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
<%- include("partials/field", form.nameField) %>
|
||||
<div class="field">
|
||||
<label for="member">First member</label>
|
||||
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a member…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
|
||||
<span class="field-hint">A group exists once it has a member; add more after creating it.</span>
|
||||
<label for="member"><%= t("admin.groups.firstMember") %></label>
|
||||
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>><%= t("admin.common.chooseMember") %></option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
|
||||
<span class="field-hint"><%= t("admin.groups.firstMemberHint") %></span>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
||||
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
|
||||
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<%#
|
||||
Admin permission detail body, captured into the shell content slot. Config:
|
||||
permission { name }
|
||||
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint
|
||||
effective { label }[] users who hold the permission (expand)
|
||||
add { action, options: {label,value}[] } action = assign endpoint
|
||||
del { action } delete the whole permission
|
||||
csrfToken, error?
|
||||
%><%
|
||||
const permission = locals.permission;
|
||||
const members = locals.members;
|
||||
const effective = locals.effective;
|
||||
const add = locals.add;
|
||||
const del = locals.del;
|
||||
const csrf = locals.csrfToken;
|
||||
-%>
|
||||
<div class="form-page">
|
||||
<% if (locals.error) { -%>
|
||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<section class="form-card" aria-labelledby="members-h">
|
||||
<h2 class="card-title" id="members-h">Assigned to</h2>
|
||||
<% if (members.rows.length) { -%>
|
||||
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= permission.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>
|
||||
<% 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 permission — 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 permission yet.</p>
|
||||
<% } -%>
|
||||
</section>
|
||||
<section class="form-card" aria-labelledby="add-h">
|
||||
<h2 class="card-title" id="add-h">Assign the permission</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 permission.</p>
|
||||
<% } -%>
|
||||
</section>
|
||||
<section class="form-card admin-actions" aria-label="Permission actions">
|
||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete permission</a>
|
||||
</section>
|
||||
</div>
|
||||
@@ -1,26 +0,0 @@
|
||||
<%#
|
||||
Admin permission 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 permission exists once assigned; add more users or groups after creating it.</span>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
||||
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -14,23 +14,28 @@
|
||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<% if (recovery) { -%>
|
||||
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong>Recovery code generated</strong><span>Give it to the user — they enter it on the <a href="/recovery">password-reset screen</a> to set a new password (generate a fresh one if it has expired).</span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
|
||||
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong><%= t("admin.users.recovery.title") %></strong><span><%= t("admin.users.recovery.body") %> <a href="<%= localeHref("/recovery") %>"><%= t("admin.users.recovery.link") %></a></span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
<% form.fields.forEach((field) => { -%>
|
||||
<%- include("partials/field", field) %>
|
||||
<% }) -%>
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
||||
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
|
||||
<% if (locals.canWrite !== false) { -%>
|
||||
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||
<% } -%>
|
||||
</div>
|
||||
</form>
|
||||
<% if (edit) { -%>
|
||||
<section class="form-card admin-actions" aria-label="Account actions">
|
||||
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg>Generate recovery code</button></form>
|
||||
<form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
|
||||
<a class="btn btn-danger" href="<%= edit.deleteAction %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete user</a>
|
||||
<% if (edit && locals.permissions) { -%>
|
||||
<%- include("partials/permission-picker", { csrfToken: form.csrfToken, permissions: locals.permissions }) %>
|
||||
<% } -%>
|
||||
<% if (edit && locals.canWrite !== false) { -%>
|
||||
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
|
||||
<form method="post" action="<%= localeHref(edit.recoveryAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
|
||||
<form method="post" action="<%= localeHref(edit.stateAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
|
||||
<a class="btn btn-danger" href="<%= localeHref(edit.deleteAction) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a>
|
||||
</section>
|
||||
<% } -%>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<%#
|
||||
Permission admin detail page: the permission-detail body (members · effective access) in the shell.
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const body = include("partials/permission-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, permission: model.permission });
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body,
|
||||
brand: chrome.brand,
|
||||
breadcrumbs: model.breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
nav,
|
||||
theme: chrome.theme,
|
||||
title: model.title,
|
||||
user: chrome.user,
|
||||
}) %>
|
||||
@@ -1,16 +0,0 @@
|
||||
<%#
|
||||
Permission admin create page: the permission-form body captured into the app shell.
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const body = include("partials/permission-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 @@
|
||||
<%#
|
||||
Permissions admin list: the same building blocks as the Groups screen, around the shell, backed
|
||||
by live Keto Permission subject sets (admin-permissions.ts). Filter/sort/page round-trip the URL.
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const filters = include("partials/filter-bar", model.filterBar);
|
||||
const table = include("partials/data-table", model.table);
|
||||
const pager = include("partials/pagination", model.pagination);
|
||||
const actions = '<a class="btn btn-primary" href="/admin/permissions/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add permission</a>';
|
||||
-%>
|
||||
<%- 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,
|
||||
}) %>
|
||||
@@ -0,0 +1,24 @@
|
||||
<%#
|
||||
Plugin settings admin list: one section per installed plugin, each a table of what it declares
|
||||
and how each key resolved (admin-plugin-settings.ts). Read-only — no actions, no forms.
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
let body = "";
|
||||
for (const group of model.groups) {
|
||||
// A plugin id is the folder name, which discovery constrains to [a-z0-9-] — no escaping needed.
|
||||
body += '<h2 class="h2">' + group.pluginId + "</h2>";
|
||||
body += group.table.rows.length === 0
|
||||
? '<p class="muted">' + group.emptyText + "</p>"
|
||||
: include("partials/data-table", group.table);
|
||||
}
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body,
|
||||
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.
|
||||
%><%
|
||||
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", {
|
||||
body,
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
const filters = include("partials/filter-bar", model.filterBar);
|
||||
const table = include("partials/data-table", model.table);
|
||||
const pager = include("partials/pagination", model.pagination);
|
||||
const actions = '<a class="btn btn-primary" href="/admin/users/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add user</a>';
|
||||
// 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", {
|
||||
actions,
|
||||
|
||||
@@ -17,13 +17,17 @@ What it demonstrates:
|
||||
reusing the core `field` partial.
|
||||
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
|
||||
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
|
||||
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
|
||||
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
|
||||
the views carry the visitor's language onto their links with `localeHref()`.
|
||||
(README → [Languages](../../../README.md#languages-i18n).)
|
||||
|
||||
The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and
|
||||
`fetch` is injectable, so they unit-test as pure functions (`shifts.test.ts`).
|
||||
|
||||
## Upstream
|
||||
|
||||
Set `SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
|
||||
Set `PLUGIN_SETTING_SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
|
||||
mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box.
|
||||
A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook).
|
||||
|
||||
|
||||
@@ -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 "@plainpages/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;
|
||||
@@ -2,32 +2,34 @@
|
||||
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
|
||||
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
||||
|
||||
import { definePlugin } from "#plugin-api";
|
||||
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
||||
import { definePlugin } from "@plainpages/plugin-api";
|
||||
import { createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
||||
|
||||
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
|
||||
// stateless). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream).
|
||||
const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000";
|
||||
const upstream = createUpstream(upstreamUrl);
|
||||
// stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
|
||||
// over — which is after this manifest is built, hence the getter.
|
||||
let upstreamUrl = "";
|
||||
const upstream = createUpstream(() => upstreamUrl);
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
|
||||
// onBoot runs after discovery, before the server listens: validate the plugin's own config so a
|
||||
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
|
||||
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") },
|
||||
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
|
||||
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
|
||||
hooks: { onBoot: ({ settings }) => { upstreamUrl = settings.upstream; } },
|
||||
|
||||
// Merged into the global menu + filtered per user. "Overview" is `public`, so the "Scheduling"
|
||||
// Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog
|
||||
// (i18n/<locale>.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling"
|
||||
// header shows for everyone (even signed out); "Shifts" needs `scheduling:read`, so the gated data
|
||||
// stays hidden until a reader signs in (a plugin may make a page + its menu option public).
|
||||
nav: [{
|
||||
children: [
|
||||
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
|
||||
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ },
|
||||
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true },
|
||||
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
|
||||
],
|
||||
icon: "i-cal",
|
||||
id: "scheduling",
|
||||
label: "Scheduling",
|
||||
label: "scheduling.nav.section",
|
||||
}],
|
||||
|
||||
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
||||
@@ -44,4 +46,15 @@ export default definePlugin({
|
||||
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
|
||||
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
|
||||
],
|
||||
|
||||
// Operator-supplied config: one PLUGIN_SETTING_SCHEDULING_UPSTREAM variable, validated as a URL at
|
||||
// boot. The default points at the mock backend the dev compose runs (examples/shifts-upstream).
|
||||
settings: [
|
||||
{
|
||||
default: "http://shifts-upstream:4000",
|
||||
description: "Base URL of the backend this plugin reads shifts from and writes them to",
|
||||
key: "upstream",
|
||||
type: "url",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -2,22 +2,25 @@ import assert from "node:assert/strict";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import test from "node:test";
|
||||
// Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
||||
// Import only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
||||
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
||||
import { GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api";
|
||||
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "@plainpages/plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
import {
|
||||
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
||||
buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
||||
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
||||
} from "./shifts.ts";
|
||||
|
||||
const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them
|
||||
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
||||
|
||||
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||
return {
|
||||
chrome: CHROME, user: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||
permissions: opts.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
||||
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
|
||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,27 +35,25 @@ const asView = (r: RouteResult | void) => {
|
||||
return r as { data: Record<string, unknown>; status?: number; view: string };
|
||||
};
|
||||
|
||||
// ---- upstream config validation (the onBoot hook) ----
|
||||
// ---- the upstream URL as a declared setting ----
|
||||
|
||||
test("assertHttpUrl accepts http(s) and fails loud on a malformed or non-http upstream URL", () => {
|
||||
assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM"));
|
||||
assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM"));
|
||||
assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable
|
||||
assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme
|
||||
assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme
|
||||
test("the manifest declares its upstream as a URL setting the host validates", async () => {
|
||||
const manifest = (await import("./plugin.ts")).default;
|
||||
assert.deepEqual(manifest.settings?.map((s) => s.key), ["upstream"]);
|
||||
assert.equal(manifest.settings?.[0]?.type, "url"); // so a typo'd URL fails the boot, not every request
|
||||
assert.equal(manifest.settings?.[0]?.default, "http://shifts-upstream:4000"); // the dev compose's mock
|
||||
assert.equal(typeof manifest.hooks?.onBoot, "function"); // without it the resolved value never arrives
|
||||
});
|
||||
|
||||
test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not just the helper)", async () => {
|
||||
const prev = process.env["SCHEDULING_UPSTREAM"];
|
||||
process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below
|
||||
try {
|
||||
const manifest = (await import("./plugin.ts")).default;
|
||||
assert.equal(typeof manifest.hooks?.onBoot, "function");
|
||||
assert.throws(() => manifest.hooks!.onBoot!(), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"];
|
||||
else process.env["SCHEDULING_UPSTREAM"] = prev;
|
||||
}
|
||||
test("the client re-reads its base URL, so onBoot can bind it after the manifest is built", async () => {
|
||||
let baseUrl = "http://first:4000";
|
||||
const seen: string[] = [];
|
||||
const http = (async (url) => { seen.push(String(url)); return new Response("[]", { status: 200 }); }) as typeof fetch;
|
||||
const upstream = createUpstream(() => baseUrl, http);
|
||||
await upstream.list();
|
||||
baseUrl = "http://second:4000";
|
||||
await upstream.list();
|
||||
assert.deepEqual(seen, ["http://first:4000/shifts", "http://second:4000/shifts"]);
|
||||
});
|
||||
|
||||
// ---- upstream client (fetch injected) ----
|
||||
@@ -64,21 +65,21 @@ test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", as
|
||||
assert.equal((init?.headers as Record<string, string>).accept, "application/json");
|
||||
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
const shifts = await createUpstream("http://up:4000/", http).list(); // trailing slash trimmed
|
||||
const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed
|
||||
assert.equal(seen, "http://up:4000/shifts");
|
||||
assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
|
||||
});
|
||||
|
||||
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
|
||||
const http = (async () => new Response("nope", { status: 503 })) as typeof fetch;
|
||||
await assert.rejects(createUpstream("http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
|
||||
await assert.rejects(createUpstream(() => "http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
|
||||
});
|
||||
|
||||
test("createUpstream.create POSTs the input as JSON", async () => {
|
||||
let body: unknown, method = "";
|
||||
const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch;
|
||||
const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" };
|
||||
await createUpstream("http://up:4000", http).create(input);
|
||||
await createUpstream(() => "http://up:4000", http).create(input);
|
||||
assert.equal(method, "POST");
|
||||
assert.deepEqual(body, input);
|
||||
});
|
||||
|
||||
@@ -5,8 +5,14 @@
|
||||
// Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as
|
||||
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
||||
|
||||
// One import from the host's #plugin-api barrel — the stable author surface (see README.md → Building plugins).
|
||||
import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, tracedFetch } from "#plugin-api";
|
||||
// One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins).
|
||||
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
|
||||
// The plugin's own English (its catalog, then the host's), for a view model built outside a request:
|
||||
// its unit tests. At runtime a handler passes ctx.t, which reads this catalog in the visitor's
|
||||
// locale first, then the host's.
|
||||
const EN: Translate = englishTranslator(enUS);
|
||||
|
||||
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||
@@ -43,26 +49,16 @@ export interface ShiftsUpstream {
|
||||
list(): Promise<Shift[]>;
|
||||
}
|
||||
|
||||
// Fail loud at boot (the plugin's onBoot hook) on a malformed/non-http upstream URL — a config
|
||||
// typo surfaces at startup, not as a degraded page later. Reachability stays a runtime concern.
|
||||
export function assertHttpUrl(value: string, name: string): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${name} is not a valid URL: ${JSON.stringify(value)}`);
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${name} must be an http(s) URL: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
|
||||
// defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client
|
||||
// span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network.
|
||||
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
|
||||
const base = baseUrl.replace(/\/+$/, "");
|
||||
// `baseUrl` is read per call: the plugin's settings arrive on onBoot, after the manifest that binds
|
||||
// these handlers has already been built.
|
||||
export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
|
||||
const base = (): string => baseUrl().replace(/\/+$/, "");
|
||||
return {
|
||||
async create(input) {
|
||||
const res = await fetchImpl(`${base}/shifts`, {
|
||||
const res = await fetchImpl(`${base()}/shifts`, {
|
||||
body: JSON.stringify(input),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
@@ -70,7 +66,7 @@ export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = traced
|
||||
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
|
||||
},
|
||||
async list() {
|
||||
const res = await fetchImpl(`${base}/shifts`, { headers: { accept: "application/json" } });
|
||||
const res = await fetchImpl(`${base()}/shifts`, { headers: { accept: "application/json" } });
|
||||
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
|
||||
const data: unknown = await res.json();
|
||||
return Array.isArray(data) ? data.map(toShift) : [];
|
||||
@@ -87,58 +83,63 @@ function toShift(raw: unknown): Shift {
|
||||
|
||||
// ---- view models (pure; the EJS views read these) -----------------------------------
|
||||
|
||||
export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[] }) {
|
||||
export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[]; t?: Translate }) {
|
||||
const t = opts.t ?? EN;
|
||||
return {
|
||||
breadcrumbs: [{ label: "Shifts" }], // SHIFTS_PATH is the list itself; the form links back to it as "Shifts"
|
||||
breadcrumbs: [{ label: t("scheduling.shifts.title") }], // SHIFTS_PATH is the list itself; the form links back to it
|
||||
canWrite: opts.canWrite,
|
||||
chrome: opts.chrome,
|
||||
// A plural message: one catalog key, the right form per locale and count (Intl.PluralRules).
|
||||
count: t("scheduling.shifts.count", { count: opts.shifts.length }),
|
||||
...(opts.error ? { error: opts.error } : {}),
|
||||
filterBar: {
|
||||
applyLabel: "Search",
|
||||
applyLabel: t("filter.search"),
|
||||
clearHref: SHIFTS_PATH,
|
||||
label: "Filter shifts",
|
||||
pills: opts.q ? [{ label: "Search", remove: SHIFTS_PATH, value: opts.q }] : [],
|
||||
label: t("scheduling.filter.label"),
|
||||
pills: opts.q ? [{ label: t("filter.search"), remove: SHIFTS_PATH, value: opts.q }] : [],
|
||||
rows: [[
|
||||
{ label: "Search shifts", name: "q", placeholder: "Search title or assignee…", type: "search", value: opts.q },
|
||||
{ label: t("scheduling.filter.searchLabel"), name: "q", placeholder: t("scheduling.filter.searchPlaceholder"), type: "search", value: opts.q },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
},
|
||||
newHref: `${SHIFTS_PATH}/new`,
|
||||
table: {
|
||||
caption: "Shifts",
|
||||
columns: [{ label: "Shift" }, { label: "Assignee" }, { label: "Start" }, { label: "End" }],
|
||||
caption: t("scheduling.shifts.title"),
|
||||
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.assignee") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
|
||||
rows: opts.shifts.map((s) => ({
|
||||
cells: [{ rowHeader: { text: s.title } }, s.assignee, s.start, s.end],
|
||||
name: s.title,
|
||||
})),
|
||||
},
|
||||
title: "Shifts",
|
||||
title: t("scheduling.shifts.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFormModel(opts: { chrome: PageChrome; errors?: Record<string, string>; formError?: string; values?: Partial<ShiftInput> }) {
|
||||
export function buildFormModel(opts: { chrome: PageChrome; errors?: Record<string, string>; formError?: string; t?: Translate; values?: Partial<ShiftInput> }) {
|
||||
const t = opts.t ?? EN;
|
||||
const v = opts.values ?? {};
|
||||
const e = opts.errors ?? {};
|
||||
const field = (cfg: { icon?: string; id: string; label: string; type?: string; value: string }) => ({
|
||||
...cfg, name: cfg.id, ...(e[cfg.id] ? { error: e[cfg.id] } : {}), ...(cfg.id === "title" || cfg.id === "assignee" ? { required: true } : {}),
|
||||
});
|
||||
return {
|
||||
breadcrumbs: [{ href: SHIFTS_PATH, label: "Shifts" }, { label: "New shift" }],
|
||||
breadcrumbs: [{ href: SHIFTS_PATH, label: t("scheduling.shifts.title") }, { label: t("scheduling.new.title") }],
|
||||
chrome: opts.chrome,
|
||||
...(opts.formError ? { formError: opts.formError } : {}),
|
||||
form: {
|
||||
action: SHIFTS_PATH,
|
||||
cancelHref: SHIFTS_PATH,
|
||||
csrfToken: opts.chrome.csrfToken,
|
||||
cancelLabel: t("common.cancel"),
|
||||
fields: [
|
||||
field({ icon: "i-cal", id: "title", label: "Shift title", value: v.title ?? "" }),
|
||||
field({ icon: "i-user", id: "assignee", label: "Assignee", value: v.assignee ?? "" }),
|
||||
field({ id: "start", label: "Start", type: "datetime-local", value: v.start ?? "" }),
|
||||
field({ id: "end", label: "End", type: "datetime-local", value: v.end ?? "" }),
|
||||
field({ icon: "i-cal", id: "title", label: t("scheduling.field.title"), value: v.title ?? "" }),
|
||||
field({ icon: "i-user", id: "assignee", label: t("scheduling.field.assignee"), value: v.assignee ?? "" }),
|
||||
field({ id: "start", label: t("scheduling.field.start"), type: "datetime-local", value: v.start ?? "" }),
|
||||
field({ id: "end", label: t("scheduling.field.end"), type: "datetime-local", value: v.end ?? "" }),
|
||||
],
|
||||
submitLabel: "Create shift",
|
||||
submitLabel: t("scheduling.form.submit"),
|
||||
},
|
||||
title: "New shift",
|
||||
title: t("scheduling.new.title"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,10 +156,10 @@ export function readInput(form: URLSearchParams): ShiftInput {
|
||||
|
||||
// Required-field validation → { field: message } or null. Kept deliberately small; the upstream
|
||||
// owns the real domain rules (overlap, capacity, …) and rejects with a 4xx the handler surfaces.
|
||||
export function validate(input: ShiftInput): Record<string, string> | null {
|
||||
export function validate(input: ShiftInput, t: Translate = EN): Record<string, string> | null {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!input.title) errors["title"] = "A shift needs a title.";
|
||||
if (!input.assignee) errors["assignee"] = "Assign the shift to someone.";
|
||||
if (!input.title) errors["title"] = t("scheduling.validation.title");
|
||||
if (!input.assignee) errors["assignee"] = t("scheduling.validation.assignee");
|
||||
return Object.keys(errors).length ? errors : null;
|
||||
}
|
||||
|
||||
@@ -173,16 +174,16 @@ export function listShifts(upstream: ShiftsUpstream): RouteHandler {
|
||||
shifts = await upstream.list();
|
||||
} catch (err) {
|
||||
ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); // plugin logging via ctx.log
|
||||
error = "Couldn't reach the scheduling service — try again shortly.";
|
||||
error = ctx.t("scheduling.upstream.list");
|
||||
}
|
||||
const needle = q.toLowerCase();
|
||||
const rows = needle ? shifts.filter((s) => s.title.toLowerCase().includes(needle) || s.assignee.toLowerCase().includes(needle)) : shifts;
|
||||
return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows }), view: "shifts" };
|
||||
return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows, t: ctx.t }), view: "shifts" };
|
||||
};
|
||||
}
|
||||
|
||||
export function newShiftForm(): RouteHandler {
|
||||
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome }), view: "shift-new" });
|
||||
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" });
|
||||
}
|
||||
|
||||
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
|
||||
@@ -191,7 +192,14 @@ export function newShiftForm(): RouteHandler {
|
||||
// else a prompt to sign in. ctx.user may be null here, so read the permission via can() (zero I/O).
|
||||
export function overview(): RouteHandler {
|
||||
return (ctx) => ({
|
||||
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
|
||||
data: {
|
||||
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
|
||||
canRead: can(ctx, READ),
|
||||
chrome: ctx.chrome,
|
||||
shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links
|
||||
signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`),
|
||||
title: ctx.t("scheduling.overview.title"),
|
||||
},
|
||||
view: "overview",
|
||||
});
|
||||
}
|
||||
@@ -202,13 +210,13 @@ export function createShift(upstream: ShiftsUpstream): RouteHandler {
|
||||
// A write is a first-party form, so guard it with the host's double-submit token (ctx.verifyCsrf).
|
||||
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token");
|
||||
const input = readInput(form);
|
||||
const errors = validate(input);
|
||||
if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, values: input }), status: 400, view: "shift-new" };
|
||||
const errors = validate(input, ctx.t);
|
||||
if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, t: ctx.t, values: input }), status: 400, view: "shift-new" };
|
||||
try {
|
||||
await upstream.create(input);
|
||||
} catch (err) {
|
||||
ctx.log.warn("scheduling shift create failed (upstream)", { error: String(err) });
|
||||
return { data: buildFormModel({ chrome: ctx.chrome, formError: "Couldn't save the shift — the scheduling service is unavailable.", values: input }), status: 502, view: "shift-new" };
|
||||
return { data: buildFormModel({ chrome: ctx.chrome, formError: ctx.t("scheduling.upstream.create"), t: ctx.t, values: input }), status: 502, view: "shift-new" };
|
||||
}
|
||||
ctx.log.info("scheduling shift created", { assignee: input.assignee, title: input.title });
|
||||
return { redirect: SHIFTS_PATH }; // POST-redirect-GET
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
|
||||
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
|
||||
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
|
||||
Data: chrome, title, breadcrumbs, canRead, shiftsHref
|
||||
Data: chrome, title, breadcrumbs, canRead, shiftsHref, signInHref
|
||||
%><%
|
||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const cta = canRead
|
||||
? '<a class="btn btn-primary" href="' + shiftsHref + '">View shifts</a>'
|
||||
: '<a class="btn btn-primary" href="/login?return_to=' + encodeURIComponent(shiftsHref) + '">Sign in to view shifts</a>';
|
||||
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
|
||||
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions: "",
|
||||
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.</p>' + cta + '</div>',
|
||||
body: '<div class="scheduling-page"><p>' + t("scheduling.overview.lead") + '</p>' + cta + '</div>',
|
||||
brand: chrome.brand,
|
||||
breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%#
|
||||
A plugin's own partial (resolved before the core ones). The new-shift form body, reusing the core
|
||||
`partials/field` + `partials/alert`. Config: form { action, csrfToken, submitLabel, cancelHref,
|
||||
fields: field.ejs config[] }, formError?
|
||||
cancelLabel, fields: field.ejs config[] }, formError?
|
||||
%><%
|
||||
const form = locals.form;
|
||||
-%>
|
||||
@@ -9,13 +9,13 @@
|
||||
<% if (locals.formError) { -%>
|
||||
<%- include("partials/alert", { text: locals.formError, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
<% form.fields.forEach((field) => { -%>
|
||||
<%- include("partials/field", field) %>
|
||||
<% }) -%>
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
||||
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= form.cancelLabel %></a>
|
||||
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
service; this view renders them with the core building blocks inside the native app shell
|
||||
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
|
||||
alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL.
|
||||
Data: chrome, title, breadcrumbs, filterBar, table, canWrite, newHref, error?
|
||||
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
|
||||
%><%
|
||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const filtersHtml = include("partials/filter-bar", filterBar);
|
||||
const tableHtml = include("partials/data-table", table);
|
||||
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
|
||||
const actions = canWrite
|
||||
? '<a class="btn btn-primary" href="' + newHref + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>New shift</a>'
|
||||
? '<a class="btn btn-primary" href="' + localeHref(newHref) + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("scheduling.shifts.new") + '</a>'
|
||||
: "";
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + tableHtml + '</div>',
|
||||
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>',
|
||||
brand: chrome.brand,
|
||||
breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Dev-only mock upstream for the reference plugin (examples/plugins/scheduling) — a stand-in for the
|
||||
// customer's real backend, ready for when you copy the reference plugin into plugins/. NOT part
|
||||
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point SCHEDULING_UPSTREAM
|
||||
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM
|
||||
// at your real service in production.
|
||||
//
|
||||
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"errors":null,"message":"not found","url":"https://gitea.larvit.se/api/swagger"}
|
||||
@@ -1,6 +1,14 @@
|
||||
-- Runs once on first boot (docker-entrypoint-initdb.d), as the POSTGRES_USER.
|
||||
-- One database per Ory service: each owns its schema and runs its own migrations,
|
||||
-- so they never collide. The web app never connects here (stateless — see README).
|
||||
-- so they never collide. A plugin's database does not belong here: bootstrap provisions those on
|
||||
-- every boot, so one dropped in later is picked up too (README → Plugin storage).
|
||||
CREATE DATABASE kratos;
|
||||
CREATE DATABASE keto;
|
||||
CREATE DATABASE hydra;
|
||||
|
||||
-- Postgres grants CONNECT to PUBLIC by default, so every plugin role could otherwise open the auth
|
||||
-- plane's databases and read pg_catalog; table data stays protected either way. Ory connects as the
|
||||
-- POSTGRES_USER, which owns these and keeps its access.
|
||||
REVOKE CONNECT ON DATABASE kratos FROM PUBLIC;
|
||||
REVOKE CONNECT ON DATABASE keto FROM PUBLIC;
|
||||
REVOKE CONNECT ON DATABASE hydra FROM PUBLIC;
|
||||
|
||||
Generated
+18
-6
@@ -1,16 +1,15 @@
|
||||
{
|
||||
"name": "plainpages",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "plainpages",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@larvit/log": "2.3.0",
|
||||
"ejs": "6.0.1",
|
||||
"lucide-static": "1.28.0"
|
||||
"lucide-static": "1.33.0",
|
||||
"postgres": "3.4.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ejs": "3.1.5",
|
||||
@@ -400,11 +399,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-static": {
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.28.0.tgz",
|
||||
"integrity": "sha512-dC3VJwRFsjEVX7Iaq4rY88pm7Fi2OmOb8P0WRzXsUMgbt7sCmFX8bLhaDBeNW6JdRjuele+jKqqFaam4yr+Ygg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.33.0.tgz",
|
||||
"integrity": "sha512-jNGgvTNcLUfVRX4N9PH9pVVTJzoph/BmYmgU838bYBQodkUJL4nAThkuymFz1x3OUYMhJxPndC7rdg1sxOPYKg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/postgres": {
|
||||
"version": "3.4.9",
|
||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
|
||||
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
|
||||
"license": "Unlicense",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/porsager"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
|
||||
+4
-5
@@ -1,26 +1,25 @@
|
||||
{
|
||||
"name": "plainpages",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"imports": {
|
||||
"#menu-config": "./src/ui/menu-config.ts",
|
||||
"#plugin-api": "./src/plugin-host/plugin-api.ts"
|
||||
"#menu-config": "./src/ui/menu-config.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/server.ts",
|
||||
"dev": "node --watch src/server.ts",
|
||||
"gen-jwks": "node src/auth/gen-jwks.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"auto-release/**/*.test.ts\""
|
||||
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"release-tooling/**/*.test.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@larvit/log": "2.3.0",
|
||||
"ejs": "6.0.1",
|
||||
"lucide-static": "1.28.0"
|
||||
"lucide-static": "1.33.0",
|
||||
"postgres": "3.4.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ejs": "3.1.5",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Re-export rather than the surface itself: a package's `exports` target may not escape its folder.
|
||||
export * from "../src/plugin-host/plugin-api.ts";
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@plainpages/plugin-api",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": "./index.ts"
|
||||
}
|
||||
+42
-15
@@ -126,7 +126,7 @@ summary { list-style: none; cursor: pointer; }
|
||||
border-radius: 3px;
|
||||
}
|
||||
@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 {
|
||||
@@ -282,7 +282,6 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
outline: 2px solid var(--focus); outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* profile / settings row */
|
||||
.footer-actions { display: flex; align-items: center; gap: 4px; }
|
||||
.profile {
|
||||
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);
|
||||
margin: 0; cursor: pointer; }
|
||||
.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> */
|
||||
.menu { position: relative; display: inline-flex; }
|
||||
.menu > summary { display: inline-flex; }
|
||||
.menu > summary::after { content: none; }
|
||||
/* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
|
||||
panel, wrapped so the pair is one element in any layout. The browser owns open/close, and the top
|
||||
layer keeps the panel clear of a scrolling ancestor's overflow. `position-anchor: auto` binds it to
|
||||
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 {
|
||||
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;
|
||||
background: var(--surface); border: 1px solid var(--border-2);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface); color: var(--text);
|
||||
border: 1px solid var(--border-2); border-radius: var(--radius);
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,.16);
|
||||
}
|
||||
.menu-pop.left { right: auto; left: 0; }
|
||||
.menu-pop.up { top: auto; bottom: calc(100% + 6px); }
|
||||
.menu-pop.left { right: auto; left: anchor(left); }
|
||||
.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;
|
||||
letter-spacing: .05em; color: var(--text-faint); font-weight: 600;
|
||||
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:hover { background: var(--surface-2); }
|
||||
.menu-item[aria-current] { font-weight: 600; color: var(--text); } /* the language you are in */
|
||||
.menu-item.danger { color: var(--neg); }
|
||||
.menu-item .ico { color: var(--text-faint); }
|
||||
.menu-item.danger .ico { color: var(--neg); }
|
||||
@@ -610,10 +634,13 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
||||
|
||||
/* row kebab */
|
||||
.col-actions { width: 44px; text-align: center; }
|
||||
.kebab summary { width: 26px; height: 26px; border-radius: var(--radius);
|
||||
display: grid; place-items: center; color: var(--text-faint); margin: 0 auto; }
|
||||
.kebab summary:hover { background: var(--surface-2); color: var(--text); }
|
||||
.kebab[open] summary { background: var(--surface-2); color: var(--text); }
|
||||
.kebab { width: 26px; height: 26px; border-radius: var(--radius);
|
||||
display: grid; place-items: center; color: var(--text-faint); margin: 0 auto;
|
||||
background: transparent; border: 0; padding: 0; cursor: pointer; }
|
||||
.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 --------------------------------- */
|
||||
.pager {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { checkTagMatchesContract, readHostApiVersion } from "./contract-version.ts";
|
||||
|
||||
test("readHostApiVersion pulls the constant out of the real source, and returns null when absent", () => {
|
||||
const real = readFileSync("src/plugin-host/plugin.ts", "utf8");
|
||||
assert.match(readHostApiVersion(real) ?? "", /^\d+\.\d+\.\d+$/);
|
||||
assert.equal(readHostApiVersion('export const SOMETHING_ELSE = "1.0.0";'), null);
|
||||
});
|
||||
|
||||
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
|
||||
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
|
||||
// makes an accidental edit fail here rather than at release time.
|
||||
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.2.0");
|
||||
});
|
||||
|
||||
test("every author-facing apiVersion sample matches the shipped contract", () => {
|
||||
// A plugin author copies these; a stale one produces a boot-aborting refuse on first run. The
|
||||
// examples deliberately write a literal rather than importing the constant (AGENTS.md), so this
|
||||
// is the only thing keeping the copies honest.
|
||||
const host = readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")) ?? "";
|
||||
const [major, minor] = host.split(".");
|
||||
for (const file of [
|
||||
"README.md",
|
||||
"examples/plugins/admin/plugin.ts",
|
||||
"examples/plugins/scheduling/plugin.ts",
|
||||
"release-tooling/dockerhub-overview.md.tmpl",
|
||||
"views/index.ejs",
|
||||
]) {
|
||||
const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]);
|
||||
assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`);
|
||||
for (const sample of found) {
|
||||
const [sMajor, sMinor] = (sample ?? "").split(".");
|
||||
assert.equal(`${sMajor}.${sMinor}`, `${major}.${minor}`, `${file} samples apiVersion ${sample}, host is ${host}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("checkTagMatchesContract: major.minor must agree, patch may lag", () => {
|
||||
assert.equal(checkTagMatchesContract("v0.1.0", "0.1.0").ok, true);
|
||||
assert.equal(checkTagMatchesContract("v0.1.7", "0.1.0").ok, true); // auto-release cut patches
|
||||
assert.equal(checkTagMatchesContract("0.1.0", "0.1.0").ok, true); // bare tag, no v
|
||||
assert.equal(checkTagMatchesContract("v0.2.0", "0.1.0").ok, false); // plugin-visible, needs a bump
|
||||
assert.equal(checkTagMatchesContract("v1.0.0", "0.1.0").ok, false);
|
||||
});
|
||||
|
||||
test("checkTagMatchesContract names what to fix rather than just failing", () => {
|
||||
const res = checkTagMatchesContract("v0.2.0", "0.1.0");
|
||||
assert.equal(res.ok, false);
|
||||
assert.match(res.ok === false ? res.error : "", /HOST_API_VERSION to 0\.2\.0/);
|
||||
});
|
||||
|
||||
test("checkTagMatchesContract rejects junk on either side without throwing", () => {
|
||||
assert.equal(checkTagMatchesContract("v0.1.0", null).ok, false); // constant not found
|
||||
assert.equal(checkTagMatchesContract("nope", "0.1.0").ok, false);
|
||||
assert.equal(checkTagMatchesContract("v0.1.0", "1.0").ok, false);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export type ContractCheck = { ok: true } | { ok: false; error: string };
|
||||
|
||||
const SEMVER = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||
|
||||
export function readHostApiVersion(source: string): string | null {
|
||||
return /^export const HOST_API_VERSION = "([^"]+)";/m.exec(source)?.[1] ?? null;
|
||||
}
|
||||
|
||||
// Patch is deliberately not compared: checkApiVersion ignores it, and auto-release cuts patch
|
||||
// releases with no commit to bump the constant in.
|
||||
export function checkTagMatchesContract(tag: string, hostApiVersion: string | null): ContractCheck {
|
||||
if (hostApiVersion === null) {
|
||||
return { error: "HOST_API_VERSION not found", ok: false };
|
||||
}
|
||||
const t = SEMVER.exec(tag);
|
||||
if (!t) return { error: `tag must be vX.Y.Z, got ${JSON.stringify(tag)}`, ok: false };
|
||||
const h = SEMVER.exec(hostApiVersion);
|
||||
if (!h) return { error: `HOST_API_VERSION must be X.Y.Z, got ${JSON.stringify(hostApiVersion)}`, ok: false };
|
||||
if (t[1] === h[1] && t[2] === h[2]) return { ok: true };
|
||||
return {
|
||||
error:
|
||||
`${tag} does not match HOST_API_VERSION ${hostApiVersion} — the contract version IS the release ` +
|
||||
`version. Set HOST_API_VERSION to ${t[1]}.${t[2]}.0 in src/plugin-host/plugin.ts, merge that, ` +
|
||||
"then tag.",
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
// CLI: node release-tooling/contract-version.ts <tag> <path/to/plugin.ts | -> → exits 1 on
|
||||
// mismatch. `-` reads the source on stdin, so a caller checking a ref other than its checkout
|
||||
// (`git show origin/main:… | …`) needs no scratch file in the workspace.
|
||||
if (process.argv[1]?.endsWith("/contract-version.ts")) {
|
||||
const [, , tag, pluginPath = "src/plugin-host/plugin.ts"] = process.argv;
|
||||
let source = "";
|
||||
try {
|
||||
source = readFileSync(pluginPath === "-" ? 0 : pluginPath, "utf8");
|
||||
} catch (err) {
|
||||
process.stderr.write(`${pluginPath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source));
|
||||
if (!result.ok) {
|
||||
process.stderr.write(`${pluginPath}: ${result.error}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`${tag} matches HOST_API_VERSION\n`);
|
||||
}
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
A self-hostable foundation for server-rendered web apps — public or gated pages from a
|
||||
zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in.
|
||||
Every domain feature is a drop-in plugin folder; the app is stateless, no build step.
|
||||
Every domain feature is a drop-in plugin folder, with a Postgres database of its own if it wants
|
||||
one; the host itself is stateless, and there is no build step.
|
||||
|
||||
**Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>**
|
||||
([GitHub mirror](https://github.com/larvit/plainpages))
|
||||
|
||||
## Tags
|
||||
|
||||
`X.Y.Z` · `X.Y` · `X` · `latest` — each is a release promoted from a CI-gated build.
|
||||
`X.Y.Z` · `X.Y` · `latest` — each is a release promoted from a CI-gated build.
|
||||
Pin the exact `X.Y.Z` you deploy.
|
||||
|
||||
## Quick start
|
||||
@@ -21,7 +22,7 @@ so there is nothing to clone. In an empty directory, save this as `compose.yml`:
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
image: larvit/plainpages:0.0.2
|
||||
image: larvit/plainpages:{{VERSION}}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
@@ -40,7 +41,7 @@ services:
|
||||
|
||||
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
|
||||
bootstrap:
|
||||
image: larvit/plainpages:0.0.2
|
||||
image: larvit/plainpages:{{VERSION}}
|
||||
command: node src/auth/bootstrap.ts
|
||||
depends_on:
|
||||
kratos:
|
||||
@@ -53,7 +54,7 @@ services:
|
||||
restart: "on-failure:5"
|
||||
|
||||
postgres:
|
||||
image: postgres:18.4-alpine3.23
|
||||
image: postgres:18.6-alpine3.23
|
||||
environment:
|
||||
POSTGRES_DB: ory
|
||||
POSTGRES_PASSWORD: ory
|
||||
@@ -130,7 +131,7 @@ services:
|
||||
|
||||
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.30.1
|
||||
image: axllent/mailpit:v1.31.0
|
||||
ports:
|
||||
- "8025:8025"
|
||||
restart: unless-stopped
|
||||
@@ -142,7 +143,7 @@ volumes:
|
||||
Extract the Ory config the image ships, then start:
|
||||
|
||||
```bash
|
||||
docker run --rm larvit/plainpages:0.0.2 tar -cf - ory | tar -xf -
|
||||
docker run --rm larvit/plainpages:{{VERSION}} tar -cf - ory | tar -xf -
|
||||
mkdir -p plugins
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -178,10 +179,10 @@ Everything domain-specific is a plugin folder — the compose above mounts `./pl
|
||||
into the app. Create `plugins/hello/plugin.ts`:
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "#plugin-api";
|
||||
import { definePlugin } from "@plainpages/plugin-api";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "1.0.0",
|
||||
apiVersion: "0.2.0",
|
||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||
routes: [
|
||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { jwtFrom, leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
|
||||
|
||||
const TEMPLATE = "release-tooling/dockerhub-overview.md.tmpl";
|
||||
const template = () => readFileSync(TEMPLATE, "utf8");
|
||||
|
||||
test("renderOverview substitutes every occurrence, not just the first", () => {
|
||||
const out = renderOverview("pull a:{{VERSION}} then b:{{VERSION}}", "1.2.3");
|
||||
assert.equal(out, "pull a:1.2.3 then b:1.2.3");
|
||||
});
|
||||
|
||||
test("leftoverPlaceholders catches a typo'd placeholder, deduped, and passes clean text", () => {
|
||||
assert.deepEqual(leftoverPlaceholders("a {{VERISON}} b {{VERISON}}"), ["{{VERISON}}"]);
|
||||
assert.deepEqual(leftoverPlaceholders(renderOverview("x {{VERSION}}", "0.1.0")), []);
|
||||
});
|
||||
|
||||
test("the real template renders clean, and the release owns its own image tag", () => {
|
||||
const rendered = renderOverview(template(), "9.9.9");
|
||||
assert.deepEqual(leftoverPlaceholders(rendered), []);
|
||||
assert.match(rendered, /larvit\/plainpages:9\.9\.9/); // the placeholder actually reaches the examples
|
||||
assert.doesNotMatch(rendered, /larvit\/plainpages:\d+\.\d+\.\d+(?<!9\.9\.9)/);
|
||||
});
|
||||
|
||||
test("the quick start's sidecars are pinned to the same versions this repo runs", () => {
|
||||
// The page is published automatically, so a drifted pin here ships a topology CI never tested.
|
||||
const pins = (source: string) =>
|
||||
new Map([...source.matchAll(/image: ([^:\s]+):(v?\d\S*)/g)].map((m) => [m[1] ?? "", m[2] ?? ""]));
|
||||
const ours = new Map([
|
||||
...pins(readFileSync("compose.override.yml", "utf8")),
|
||||
...pins(readFileSync("compose.yml", "utf8")), // production wins: the template is the prod quick start
|
||||
]);
|
||||
const published = pins(template());
|
||||
assert.ok(published.size > 0, "the template should pin sidecars");
|
||||
for (const [image, tag] of published) {
|
||||
assert.equal(tag, ours.get(image), `${TEMPLATE} pins ${image}:${tag}, this repo runs ${ours.get(image)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("jwtFrom accepts only a non-empty string token, never throwing on a hostile body", () => {
|
||||
assert.equal(jwtFrom({ token: "abc" }), "abc");
|
||||
assert.equal(jwtFrom(null), null); // valid JSON, and the shape a proxy can return
|
||||
assert.equal(jwtFrom("<html>rate limited</html>"), null);
|
||||
assert.equal(jwtFrom({}), null);
|
||||
assert.equal(jwtFrom({ token: "" }), null);
|
||||
assert.equal(jwtFrom({ token: 42 }), null);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// Publishes the Docker Hub repository overview from dockerhub-overview.md.tmpl, rendering
|
||||
// `{{VERSION}}` to the release being published.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const HUB = "https://hub.docker.com/v2";
|
||||
const TIMEOUT_MS = 30_000;
|
||||
const VERSION = /^\d+\.\d+\.\d+$/;
|
||||
|
||||
export function renderOverview(source: string, version: string): string {
|
||||
return source.replaceAll("{{VERSION}}", version);
|
||||
}
|
||||
|
||||
// A typo'd placeholder would publish literal braces to a public page, so fail the release instead.
|
||||
export function leftoverPlaceholders(rendered: string): string[] {
|
||||
return [...new Set(rendered.match(/\{\{[^}]*\}\}/g) ?? [])];
|
||||
}
|
||||
|
||||
export function jwtFrom(body: unknown): string | null {
|
||||
if (typeof body !== "object" || body === null || !("token" in body)) return null;
|
||||
return typeof body.token === "string" && body.token !== "" ? body.token : null;
|
||||
}
|
||||
|
||||
type Fetched = { error: string } | { json: unknown; ok: boolean; status: number; text: string };
|
||||
|
||||
// fetch and its body readers throw; this is the one edge that converts that into a value.
|
||||
async function post(url: string, init: RequestInit): Promise<Fetched> {
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
||||
const text = await res.text();
|
||||
let json: unknown = null;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { json, ok: res.ok, status: res.status, text };
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const fail = (message: string): number => {
|
||||
process.stderr.write(`${message}\n`);
|
||||
return 1;
|
||||
};
|
||||
const [, , version] = process.argv;
|
||||
const repo = process.env["DOCKERHUB_REPO"];
|
||||
const user = process.env["DOCKERHUB_USER"];
|
||||
const token = process.env["DOCKERHUB_TOKEN"];
|
||||
if (!version || !repo || !user || !token) {
|
||||
return fail(
|
||||
"usage: dockerhub-overview.ts <X.Y.Z>; needs DOCKERHUB_REPO, DOCKERHUB_USER and " +
|
||||
"DOCKERHUB_TOKEN (README -> CI/CD)",
|
||||
);
|
||||
}
|
||||
// The page is public, so never render a version that resolves to no image.
|
||||
if (!VERSION.test(version)) return fail(`version must be X.Y.Z, got ${JSON.stringify(version)}`);
|
||||
|
||||
const templatePath = join(import.meta.dirname, "dockerhub-overview.md.tmpl");
|
||||
let template = "";
|
||||
try {
|
||||
template = readFileSync(templatePath, "utf8");
|
||||
} catch (err) {
|
||||
return fail(`${templatePath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
const body = renderOverview(template, version);
|
||||
const leftover = leftoverPlaceholders(body);
|
||||
if (leftover.length > 0) return fail(`${templatePath} has unrendered placeholders: ${leftover.join(", ")}`);
|
||||
|
||||
const login = await post(`${HUB}/users/login`, {
|
||||
body: JSON.stringify({ password: token, username: user }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
if ("error" in login) return fail(`Docker Hub login unreachable: ${login.error}`);
|
||||
if (!login.ok) return fail(`Docker Hub login failed: ${login.status} ${login.text}`);
|
||||
const jwt = jwtFrom(login.json);
|
||||
if (!jwt) return fail("Docker Hub login returned no token");
|
||||
|
||||
const res = await post(`${HUB}/repositories/${repo}/`, {
|
||||
body: JSON.stringify({ full_description: body }),
|
||||
headers: { authorization: `Bearer ${jwt}`, "content-type": "application/json" },
|
||||
method: "PATCH",
|
||||
});
|
||||
if ("error" in res) return fail(`Docker Hub unreachable: ${res.error}`);
|
||||
if (!res.ok) {
|
||||
return fail(
|
||||
`Docker Hub overview PATCH failed: ${res.status} ${res.text}` +
|
||||
(res.status === 403
|
||||
? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " +
|
||||
"read/write/delete, which pushing images alone does not (README -> CI/CD)."
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
process.stdout.write(`Docker Hub overview updated for ${repo} at ${version}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1]?.endsWith("/dockerhub-overview.ts")) {
|
||||
process.exitCode = await main();
|
||||
}
|
||||
@@ -35,6 +35,8 @@ test("nextVersion at/after 1.0.0: literal semver", () => {
|
||||
assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0");
|
||||
assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0");
|
||||
assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4");
|
||||
// the whole chain: a major dependency bump releases a major host, once the 0.x shift-down is gone
|
||||
assert.equal(nextVersion("v1.2.3", maxLevel(["patch", "major"])), "v2.0.0");
|
||||
});
|
||||
|
||||
test("nextVersion rejects a tag that is not vX.Y.Z", () => {
|
||||
@@ -36,7 +36,7 @@ export function nextVersion(latestTag: string, level: Bump): string {
|
||||
return `v${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
|
||||
// CLI: node auto-release/next-version.ts <latestTag> [updateType...] → prints the next tag.
|
||||
// CLI: node release-tooling/next-version.ts <latestTag> [updateType...] → prints the next tag.
|
||||
if (process.argv[1]?.endsWith("/next-version.ts")) {
|
||||
const [, , latestTag, ...updateTypes] = process.argv;
|
||||
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
|
||||
+42
-3
@@ -1,9 +1,41 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"description": "ignorePaths overrides config:recommended's :ignoreModulesAndTests, which ignores **/examples/** — an example plugin's dependencies get update PRs like any other manifest here",
|
||||
"ignorePaths": ["**/node_modules/**"],
|
||||
"automerge": true,
|
||||
"commitBody": "Release-Bump: {{{updateType}}}",
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "The host's own runtime deps. Release-Bump is opt-in per surface (README → CI/CD): updateType rates the dependency's own jump, not whether it reaches a running Plainpages",
|
||||
"matchDepTypes": ["dependencies"],
|
||||
"matchFileNames": ["package.json"],
|
||||
"matchManagers": ["npm"],
|
||||
"commitBody": "Release-Bump: {{{updateType}}}"
|
||||
},
|
||||
{
|
||||
"description": "The shipped image's base — e2e-tests/Dockerfile is test-only",
|
||||
"matchFileNames": ["Dockerfile"],
|
||||
"matchManagers": ["dockerfile"],
|
||||
"commitBody": "Release-Bump: {{{updateType}}}"
|
||||
},
|
||||
{
|
||||
"description": "The production topology — compose.override.yml is dev, e2e-tests/compose.*.yml are test",
|
||||
"matchFileNames": ["compose.yml"],
|
||||
"matchManagers": ["docker-compose"],
|
||||
"commitBody": "Release-Bump: {{{updateType}}}"
|
||||
},
|
||||
{
|
||||
"description": "The production sidecars, wherever they are pinned — compose.yml and the published quick start move in one branch, so the trailer must not depend on which upgrade sorts first. mailpit is dev-only and stays out",
|
||||
"matchDatasources": ["docker"],
|
||||
"matchPackageNames": ["oryd/hydra", "oryd/keto", "oryd/kratos", "postgres"],
|
||||
"commitBody": "Release-Bump: {{{updateType}}}"
|
||||
},
|
||||
{
|
||||
"description": "node is pinned to one version across Dockerfile, dev, E2E and CI, so Renovate moves them in a single branch whose commitBody would otherwise depend on upgrade order — the Dockerfile copy ships, so any node bump is a product change",
|
||||
"matchDatasources": ["docker"],
|
||||
"matchPackageNames": ["node"],
|
||||
"commitBody": "Release-Bump: {{{updateType}}}"
|
||||
},
|
||||
{
|
||||
"description": "Ory services share one release train - update kratos, keto and hydra together",
|
||||
"matchDatasources": ["docker"],
|
||||
@@ -27,8 +59,15 @@
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)",
|
||||
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"],
|
||||
"description": "The published quick start ships a compose file, so its sidecars move with the repo's own pins. The version group starts at a digit, which skips the {{VERSION}} placeholder the release renders",
|
||||
"managerFilePatterns": ["release-tooling/dockerhub-overview.md.tmpl"],
|
||||
"matchStrings": ["image: (?<depName>[^:\\s]+):(?<currentValue>v?\\d[^\\s]*)"],
|
||||
"datasourceTemplate": "docker"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, renovate auto-release, release)",
|
||||
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/release.yml", ".gitea/workflows/renovate.yml"],
|
||||
"matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||
"depNameTemplate": "node",
|
||||
"datasourceTemplate": "docker"
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, seedAdmin, seedPermissions } from "./bootstrap.ts";
|
||||
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, provisionPluginStorage, seedAdmin, seedPermissions, serverMismatch } from "./bootstrap.ts";
|
||||
import { createLogger } from "../logger.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import type { ProvisionOptions, ProvisionResult } from "../plugin-host/storage-provisioning.ts";
|
||||
|
||||
const json = (status: number, body?: unknown) =>
|
||||
new Response(body === undefined ? null : JSON.stringify(body), {
|
||||
@@ -30,14 +33,33 @@ test("permissionTuple grants a permission to user:<id> in the Permission namespa
|
||||
});
|
||||
});
|
||||
|
||||
test("seedPermissions unions ADMIN_PERMISSIONS (default 'admin') with the discovered plugins' declared permissions", () => {
|
||||
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two tokens → the demo admin
|
||||
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
|
||||
assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
|
||||
assert.deepEqual(seedPermissions(undefined, []), ["admin"]); // no plugins → just the base admin permission
|
||||
assert.deepEqual(seedPermissions("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
|
||||
assert.deepEqual(seedPermissions("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
|
||||
assert.deepEqual(seedPermissions("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
|
||||
test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the discovered plugins' declared permissions", () => {
|
||||
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two names → the demo admin
|
||||
// holds exactly what the installed plugins gate on, derived from discovery, not hardcoded here.
|
||||
const names = (env: string | undefined, declared: string[]): string[] => seedPermissions(env, declared).permissions;
|
||||
assert.deepEqual(names(undefined, ["scheduling:read", "scheduling:write"]), ["scheduling:read", "scheduling:write"]);
|
||||
// No plugins → nothing to grant. A host-invented base would be a permission that gates nothing.
|
||||
assert.deepEqual(names(undefined, []), []);
|
||||
assert.deepEqual(names("ops:read, ops:write ", ["inventory:read"]), ["ops:read", "ops:write", "inventory:read"]); // env trimmed + extended
|
||||
assert.deepEqual(names("scheduling:read", ["scheduling:read"]), ["scheduling:read"]); // dedup, no double grant
|
||||
assert.deepEqual(names(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
|
||||
});
|
||||
|
||||
// Bootstrap gates `web`, so it must never refuse to start over operator env — a leftover
|
||||
// ADMIN_PERMISSIONS would otherwise brick the whole stack. Drop what it can't use, report it, seed
|
||||
// the rest.
|
||||
test("seedPermissions drops an ADMIN_PERMISSIONS name that isn't <resource>:<action>, and never throws", () => {
|
||||
const legacy = seedPermissions("admin", ["users:read"]);
|
||||
assert.deepEqual(legacy, { ignored: ["admin"], permissions: ["users:read"] });
|
||||
|
||||
const mixed = seedPermissions("admin, ops:read ,Bad Name", ["users:read"]);
|
||||
assert.deepEqual(mixed, { ignored: ["admin", "Bad Name"], permissions: ["ops:read", "users:read"] });
|
||||
|
||||
// Whatever an operator puts there, the boot survives it — that is the property, not the parsing.
|
||||
for (const value of ["admin", "Bad Name", ":", "::", "a".repeat(200), ",,,", "ADMIN", "1"]) {
|
||||
assert.doesNotThrow(() => seedPermissions(value, ["users:read"]), value);
|
||||
assert.deepEqual(seedPermissions(value, ["users:read"]).permissions.includes("users:read"), true, value);
|
||||
}
|
||||
});
|
||||
|
||||
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
|
||||
@@ -132,3 +154,63 @@ test("ensureJwks generates a key only when the file is absent", () => {
|
||||
assert.equal(ensureJwks(path, { exists: () => true, write }), false);
|
||||
assert.equal(writes.length, 1); // present → nothing written
|
||||
});
|
||||
|
||||
// --- Plugin storage provisioning -----------------------------------------------------
|
||||
// The provisioner is injected, so the branch decisions are testable without a Postgres.
|
||||
|
||||
const SILENT = createLogger({ level: "none" });
|
||||
const storagePlugin = (id: string): Plugin => ({ apiVersion: "1.0.0", id, storage: true });
|
||||
const EMPTY: ProvisionResult = { orphans: [], provisioned: [] };
|
||||
|
||||
function recordingProvisioner(result: ProvisionResult = EMPTY) {
|
||||
const calls: ProvisionOptions[] = [];
|
||||
return { calls, provision: async (options: ProvisionOptions) => { calls.push(options); return result; } };
|
||||
}
|
||||
|
||||
test("provisioning is skipped entirely when nothing declares storage and none is configured", async () => {
|
||||
const { calls, provision } = recordingProvisioner();
|
||||
await provisionPluginStorage({}, [{ apiVersion: "1.0.0", id: "plain" }], SILENT, provision);
|
||||
assert.deepEqual(calls, []); // no connection attempted, so an unconfigured stack still boots
|
||||
});
|
||||
|
||||
// Uninstalling the last storage plugin is exactly when a left-behind database needs naming.
|
||||
test("provisioning still runs with nothing to provision, so orphans are reported", async () => {
|
||||
const { calls, provision } = recordingProvisioner({ orphans: ["plugin_gone"], provisioned: [] });
|
||||
await provisionPluginStorage({ PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory" }, [], SILENT, provision);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(calls[0]?.pluginIds, []);
|
||||
});
|
||||
|
||||
test("a plugin declaring storage without a provisioning DSN fails loud, naming the plugin", async () => {
|
||||
const { calls, provision } = recordingProvisioner();
|
||||
await assert.rejects(
|
||||
provisionPluginStorage({}, [storagePlugin("things")], SILENT, provision),
|
||||
/PLUGIN_DB_ADMIN_URL.*things/s,
|
||||
);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test("the connection limit and derived secret reach the provisioner", async () => {
|
||||
const { calls, provision } = recordingProvisioner();
|
||||
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory", PLUGIN_DB_CONNECTION_LIMIT: "25", PLUGIN_DB_SECRET: "real" };
|
||||
await provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision);
|
||||
assert.equal(calls[0]?.connectionLimit, 25);
|
||||
assert.equal(calls[0]?.secret, "real");
|
||||
assert.deepEqual(calls[0]?.pluginIds, ["things"]);
|
||||
});
|
||||
|
||||
// bootstrap creates the role on one server; web tells the plugin to connect to another. Left
|
||||
// unsaid it surfaces inside a plugin as "password authentication failed", naming neither. Warned
|
||||
// rather than refused: web reaching a pooler bootstrap cannot provision through is legitimate.
|
||||
test("a storage URL mismatch is reported, and provisioning still runs", async () => {
|
||||
const { calls, provision } = recordingProvisioner();
|
||||
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db-a:5432/ory", PLUGIN_DB_URL: "postgres://db-b:5432" };
|
||||
await provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test("the same server spelled with an implicit port still agrees", () => {
|
||||
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", "postgres://db"), null); // 5432 is the default
|
||||
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", undefined), null); // web's own boot error to raise
|
||||
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", "postgres://db:6543"), "db:5432 vs db:6543");
|
||||
});
|
||||
|
||||
+89
-18
@@ -2,15 +2,21 @@
|
||||
// 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);
|
||||
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
|
||||
// 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — `admin` plus
|
||||
// every discovered plugin's declared permission names, so a dropped-in plugin is usable by
|
||||
// the demo admin with no host config edit (the host stays plugin-agnostic).
|
||||
// 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — every
|
||||
// discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so a dropped-in
|
||||
// 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.
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts";
|
||||
import { discoverPlugins } from "../plugin-host/discovery.ts";
|
||||
import { declaredPermissions, isValidPermissionName, type Plugin } from "../plugin-host/plugin.ts";
|
||||
import { provisionStorage } from "../plugin-host/storage-provisioning.ts";
|
||||
import { storagePluginIds } from "../plugin-host/storage.ts";
|
||||
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
|
||||
import { createLogger, runWithLog, tracedFetch } from "../logger.ts";
|
||||
import { createLogger, runWithLog, tracedFetch, type Log } from "../logger.ts";
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
// --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
|
||||
|
||||
@@ -28,13 +34,20 @@ export function permissionTuple(userId: string, permission: string) {
|
||||
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
|
||||
}
|
||||
|
||||
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`)
|
||||
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
|
||||
// coarse permission — granted as a Keto `Permission:<token>#members` tuple). So the host names no plugin, yet a
|
||||
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped.
|
||||
export function seedPermissions(adminRolesEnv: string | undefined, declaredPermissions: string[]): string[] {
|
||||
// ADMIN_PERMISSIONS (empty by default) unioned with every discovered plugin's declared names, so
|
||||
// the host names no plugin yet a dropped-in one is seeded out of the box.
|
||||
//
|
||||
// ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
|
||||
// same `<resource>:<action>` rule as a manifest — but *dropped with a warning*, never fatal:
|
||||
// fail-loud belongs at the manifest boundary where a developer authored the mistake, whereas this
|
||||
// is operator env and bootstrap gates `web`, so the whole stack must not refuse to start over a
|
||||
// stale variable. The name it would have written gates nothing anyway.
|
||||
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): { ignored: string[]; permissions: string[] } {
|
||||
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
||||
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredPermissions)])];
|
||||
const configured = clean((adminPermissionsEnv ?? "").split(","));
|
||||
const ignored = configured.filter((name) => !isValidPermissionName(name));
|
||||
const valid = configured.filter((name) => isValidPermissionName(name));
|
||||
return { ignored, permissions: [...new Set([...valid, ...clean(declaredNames)])] };
|
||||
}
|
||||
|
||||
// --- JWKS safety net -----------------------------------------------------------------
|
||||
@@ -122,7 +135,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
|
||||
const rule = "─".repeat(58);
|
||||
return [
|
||||
`┌${rule}`,
|
||||
`│ Plainpages is ready — log in at ${opts.appUrl}`,
|
||||
`│ Plainpages is ready — sign in at ${opts.appUrl}`,
|
||||
`│ email: ${opts.email}`,
|
||||
`│ password: ${opts.password}`,
|
||||
`│ ⚠ Demo admin credentials — change them before production.`,
|
||||
@@ -133,7 +146,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
|
||||
// --- CLI (the bootstrap container entrypoint) ----------------------------------------
|
||||
|
||||
async function main() {
|
||||
const env = process.env;
|
||||
const env = { ...process.env }; // snapshot: the storage credentials leave process.env before discovery
|
||||
// Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME.
|
||||
const log = createLogger({
|
||||
format: env["LOG_FORMAT"] === "json" ? "json" : "text",
|
||||
@@ -142,11 +155,71 @@ async function main() {
|
||||
// runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls.
|
||||
await runWithLog(log, async () => {
|
||||
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
|
||||
// Discovery imports every plugin module — and its dependencies — into *this* process, which holds
|
||||
// the credential that may CREATE DATABASE/ROLE. Same move as server.ts, on the stronger secret.
|
||||
delete process.env["PLUGIN_DB_ADMIN_URL"];
|
||||
delete process.env["PLUGIN_DB_SECRET"];
|
||||
const plugins = await discoverPlugins();
|
||||
await provisionPluginStorage(env, plugins, log);
|
||||
await seedAdminAndPermissions(env, plugins, log);
|
||||
});
|
||||
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
|
||||
}
|
||||
|
||||
// Seed `admin` (or ADMIN_PERMISSIONS) + every discovered plugin's declared permission names, so the
|
||||
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
||||
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.name));
|
||||
const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
|
||||
// A database and login role for each plugin that asked for one. It happens here because bootstrap
|
||||
// holds the stack's only provisioning credentials — web derives the same password and connects as
|
||||
// the plugin's own role.
|
||||
export async function provisionPluginStorage(env: Env, plugins: Plugin[], log: Log, provision = provisionStorage): Promise<void> {
|
||||
const ids = storagePluginIds(plugins);
|
||||
const adminUrl = env["PLUGIN_DB_ADMIN_URL"];
|
||||
// Still connect with nothing to provision, as long as storage is configured: uninstalling the
|
||||
// last storage plugin is exactly when an orphaned database needs naming.
|
||||
if (ids.length === 0 && !adminUrl) return;
|
||||
if (!adminUrl) throw new Error(`bootstrap: PLUGIN_DB_ADMIN_URL must be set — these plugins declare storage: ${ids.join(", ")}`);
|
||||
// Provisioned here, connected to from web: a different server means the role is created in one
|
||||
// place and looked for in another, surfacing inside a plugin as "password authentication failed".
|
||||
// Warned, not refused — web reaching a pooler that cannot run CREATE DATABASE is a legitimate split.
|
||||
const mismatch = serverMismatch(adminUrl, env["PLUGIN_DB_URL"]);
|
||||
if (mismatch) log.warn("PLUGIN_DB_ADMIN_URL and PLUGIN_DB_URL name different servers", { servers: mismatch });
|
||||
const result = await provision({
|
||||
adminUrl,
|
||||
connectionLimit: resolvePluginDbConnectionLimit(env),
|
||||
pluginIds: ids,
|
||||
secret: resolvePluginDbSecret(env),
|
||||
});
|
||||
if (result.provisioned.length > 0) log.info("plugin storage provisioned", { databases: result.provisioned.join(", ") });
|
||||
// Never dropped, so an uninstalled plugin's data outlives it — say so, or nobody can find it.
|
||||
if (result.orphans.length > 0) {
|
||||
log.warn("plugin databases no installed plugin claims", { databases: result.orphans.join(", ") });
|
||||
}
|
||||
}
|
||||
|
||||
// Describes the disagreement, or null when they agree (or when web's URL is unset — that is web's
|
||||
// own boot error to raise, naming the plugin that wanted storage).
|
||||
export function serverMismatch(adminUrl: string, webUrl: string | undefined): string | null {
|
||||
if (!webUrl) return null;
|
||||
const [admin, web] = [safeHostPort(adminUrl), safeHostPort(webUrl)];
|
||||
if (admin === null || web === null || admin === web) return null; // a malformed URL fails in config.ts
|
||||
return `${admin} vs ${web}`;
|
||||
}
|
||||
|
||||
function safeHostPort(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return `${parsed.hostname}:${parsed.port || "5432"}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
async function seedAdminAndPermissions(env: Env, plugins: Plugin[], log: Log): Promise<void> {
|
||||
const declared = declaredPermissions(plugins).map((decl) => decl.name);
|
||||
const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
|
||||
if (ignored.length > 0) {
|
||||
log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") });
|
||||
}
|
||||
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
||||
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
||||
const result = await seedAdmin({
|
||||
@@ -160,8 +233,6 @@ async function main() {
|
||||
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.
|
||||
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
|
||||
});
|
||||
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
||||
|
||||
+8
-16
@@ -1,20 +1,12 @@
|
||||
// Optional revocation denylist: instant permission/session revoke without putting Keto
|
||||
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
|
||||
// Optional revocation denylist: instant permission/session revoke without putting Keto back on the
|
||||
// hot path. Off by default — enable with REVOCATION_DENYLIST=true. An admin action records the
|
||||
// subject as revoked-now; the hot path then rejects that subject's pre-revoke tokens at once,
|
||||
// forcing a re-mint (which re-reads permissions from Keto, or clears a now-dead session).
|
||||
//
|
||||
// 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) —
|
||||
// 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
|
||||
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
|
||||
// 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
|
||||
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
|
||||
// the revoke) passes while every token minted before the revoke is rejected. Entries self-evict
|
||||
// after one token TTL, by which point any pre-revoke token has expired anyway. Single-process:
|
||||
// instant on the instance that handled the revoke; across replicas/restarts the guarantee
|
||||
// falls back to the token TTL (the gap is just no longer closed early). Back it with a shared
|
||||
// store for hard multi-instance instant-revoke.
|
||||
// An in-memory, auto-evicting Map — no database, so it stays inside the stateless model. Entries
|
||||
// self-evict after one token TTL, by which point any pre-revoke token has expired anyway.
|
||||
// Single-process: instant on the instance that handled the revoke, elsewhere the guarantee falls
|
||||
// back to the token TTL. Back it with a shared store for hard multi-instance instant-revoke.
|
||||
|
||||
export interface Denylist {
|
||||
// Hot-path check: is a token for `sub`, issued at `iat` (unix sec), revoked? A token minted
|
||||
|
||||
@@ -34,9 +34,11 @@ test("maps a password login flow: csrf hidden, themed email/password fields, a s
|
||||
assert.equal(view.method, "post");
|
||||
assert.deepEqual(view.hidden, [{ name: "csrf_token", value: "tok123" }]);
|
||||
|
||||
// Visible fields carry label, type, required, autocomplete + a themed input icon.
|
||||
// Visible fields carry label, type, required, autocomplete + a themed input icon. The label is
|
||||
// ours (auth.field.identifier) rather than Kratos' wording — Kratos' generic trait-label id is
|
||||
// ambiguous, so field labels are keyed on the input name.
|
||||
assert.equal(view.fields.length, 2);
|
||||
assert.deepEqual(view.fields[0], { autocomplete: "username", icon: "i-mail", id: "field-identifier", label: "E-Mail", name: "identifier", required: true, type: "email" });
|
||||
assert.deepEqual(view.fields[0], { autocomplete: "username", icon: "i-mail", id: "field-identifier", label: "Email", name: "identifier", required: true, type: "email" });
|
||||
assert.equal(view.fields[1]?.icon, "i-lock");
|
||||
assert.equal(view.fields[1]?.type, "password");
|
||||
|
||||
@@ -53,7 +55,7 @@ test("maps a password login flow: csrf hidden, themed email/password fields, a s
|
||||
assert.equal(view.messages.length, 0);
|
||||
});
|
||||
|
||||
test("maps field errors and flow-level messages by tone", () => {
|
||||
test("maps field errors and flow-level messages by tone, translating the ids we cover", () => {
|
||||
const view = buildFlowView(
|
||||
flow(
|
||||
[
|
||||
@@ -65,13 +67,15 @@ test("maps field errors and flow-level messages by tone", () => {
|
||||
"login",
|
||||
);
|
||||
|
||||
// Submitted value is preserved; the node's error rides on the field.
|
||||
// Submitted value is preserved; the node's error rides on the field — with our wording for the
|
||||
// id (4000002), since Kratos writes "Property password is missing." for every required field.
|
||||
assert.equal(view.fields[0]?.value, "taken@example.com");
|
||||
assert.deepEqual(view.fields[0]?.error, { text: "This email is already in use." });
|
||||
assert.deepEqual(view.fields[0]?.error, { text: "This field is required." });
|
||||
|
||||
// Flow messages map error→neg, info→info (success→pos covered by the tone map).
|
||||
// Flow messages map error→neg, info→info (success→pos covered by the tone map). A mapped id
|
||||
// (4000006) is replaced; an id we hold no key for keeps Kratos' own text.
|
||||
assert.deepEqual(view.messages, [
|
||||
{ text: "The provided credentials are invalid.", tone: "neg" },
|
||||
{ text: "The credentials are invalid. Check for typos in your email address or password.", tone: "neg" },
|
||||
{ text: "Check your email.", tone: "info" },
|
||||
]);
|
||||
});
|
||||
@@ -109,6 +113,7 @@ test("the code field guards a pasted space: one-time-code autofill + numeric inp
|
||||
);
|
||||
assert.deepEqual(view.fields.find((f) => f.name === "code"), {
|
||||
autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill
|
||||
hint: "Digits only — no spaces.", // the pattern refusal alone reads as a bare "match the requested format"
|
||||
icon: "i-shield",
|
||||
id: "field-code",
|
||||
inputmode: "numeric",
|
||||
|
||||
+46
-15
@@ -4,11 +4,14 @@
|
||||
// configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos
|
||||
// owns its CSRF; we only render and map errors. No providers configured ⇒ no SSO buttons.
|
||||
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
|
||||
|
||||
export interface FlowField {
|
||||
autocomplete?: string;
|
||||
error?: { text: string };
|
||||
hint?: string; // muted helper text under the input
|
||||
icon?: string; // Lucide sprite id for the input
|
||||
id: string;
|
||||
inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code)
|
||||
@@ -66,14 +69,39 @@ export const AUTH_FLOWS: Record<string, FlowType> = {
|
||||
"/verification": "verification",
|
||||
};
|
||||
|
||||
const CHROME: Record<FlowType, FlowChrome> = {
|
||||
login: { alt: { href: "/registration", label: "Create one", text: "Don't have an account?" }, sub: "Welcome back. Enter your details to continue.", title: "Sign in" },
|
||||
recovery: { alt: { href: "/login", label: "Sign in", text: "Remembered it?" }, back: { href: "/login", label: "Back to sign in" }, sub: "Enter your email and we'll send you a recovery code.", title: "Reset password" },
|
||||
registration: { alt: { href: "/login", label: "Sign in", text: "Already have an account?" }, sub: "Get started — it only takes a minute.", title: "Create account" },
|
||||
settings: { sub: "Update your account details.", title: "Account settings" },
|
||||
verification: { back: { href: "/login", label: "Back to sign in" }, sub: "Enter the code we sent you.", title: "Verify your email" },
|
||||
// Where each flow's card links; its words come from the catalog under `auth.<flow>.*`.
|
||||
const LINKS: Record<FlowType, { alt?: string; back?: boolean }> = {
|
||||
login: { alt: "/registration" },
|
||||
recovery: { alt: "/login", back: true },
|
||||
registration: { alt: "/login" },
|
||||
settings: {},
|
||||
verification: { back: true },
|
||||
};
|
||||
|
||||
function chromeFor(type: FlowType, t: Translate): FlowChrome {
|
||||
const links = LINKS[type];
|
||||
return {
|
||||
...(links.alt ? { alt: { href: links.alt, label: t(`auth.${type}.altLabel`), text: t(`auth.${type}.altText`) } } : {}),
|
||||
...(links.back ? { back: { href: "/login", label: t(`auth.${type}.back`) } } : {}),
|
||||
sub: t(`auth.${type}.sub`),
|
||||
title: t(`auth.${type}.title`),
|
||||
};
|
||||
}
|
||||
|
||||
// A string Kratos authored (a field label, a button, a validation message). Kratos writes English
|
||||
// and tags it with a stable numeric id, so the first key we hold a translation for wins and
|
||||
// anything unmapped keeps Kratos' own words — never a bare key on screen.
|
||||
function kratosText(t: Translate, fallback: string, ...keys: (string | undefined)[]): string {
|
||||
for (const key of keys) {
|
||||
if (key === undefined) continue;
|
||||
const text = t(key);
|
||||
if (text !== key) return text;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const idKey = (id: number | undefined): string | undefined => (id === undefined ? undefined : `kratos.${id}`);
|
||||
|
||||
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
||||
|
||||
// Themed input icon by field semantics; undefined ⇒ no icon.
|
||||
@@ -93,7 +121,7 @@ function tone(type: string): FlowMessage["tone"] {
|
||||
|
||||
const ssoLogo = (value: string): string => (value.charAt(0) || "?").toUpperCase();
|
||||
|
||||
function toField(node: UiNode, name: string, type: string): FlowField {
|
||||
function toField(node: UiNode, name: string, type: string, t: Translate): FlowField {
|
||||
const value = str(node.attributes["value"]);
|
||||
// The recovery/verification one-time code: numeric, and Kratos doesn't trim it, so a stray pasted
|
||||
// space makes it reject the code as "invalid". A digits-only pattern + numeric keypad block that in
|
||||
@@ -104,19 +132,21 @@ function toField(node: UiNode, name: string, type: string): FlowField {
|
||||
const errorMsg = node.messages.find((m) => m.type === "error");
|
||||
return {
|
||||
id: "field-" + name.replace(/[^a-z0-9]+/gi, "-"),
|
||||
label: node.meta.label?.text ?? name,
|
||||
// Kratos' generic trait label (id 1070002) is "Email" here and "First name" on a schema with
|
||||
// that trait, so a field falls back to its input name — the one thing that is unambiguous.
|
||||
label: kratosText(t, node.meta.label?.text ?? name, idKey(node.meta.label?.id), `auth.field.${name}`),
|
||||
name,
|
||||
type,
|
||||
...(autocomplete ? { autocomplete } : {}),
|
||||
...(errorMsg ? { error: { text: errorMsg.text } } : {}),
|
||||
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
|
||||
...(icon ? { icon } : {}),
|
||||
...(isCode ? { inputmode: "numeric", pattern: "[0-9]*" } : {}),
|
||||
...(isCode ? { hint: t("auth.field.code.hint"), inputmode: "numeric", pattern: "[0-9]*" } : {}),
|
||||
...(node.attributes["required"] === true ? { required: true } : {}),
|
||||
...(value ? { value } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFlowView(flow: Flow, type: FlowType): FlowView {
|
||||
export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH): FlowView {
|
||||
const hidden: { name: string; value: string }[] = [];
|
||||
const fields: FlowField[] = [];
|
||||
const buttons: FlowButton[] = [];
|
||||
@@ -136,9 +166,10 @@ export function buildFlowView(flow: Flow, type: FlowType): FlowView {
|
||||
hidden.push({ name, value: str(node.attributes["value"]) ?? "" });
|
||||
} else if (inputType === "submit" || inputType === "button") {
|
||||
const value = str(node.attributes["value"]);
|
||||
buttons.push({ label: node.meta.label?.text ?? "Continue", ...(name ? { name } : {}), ...(value != null ? { value } : {}) });
|
||||
const label = kratosText(t, node.meta.label?.text ?? t("auth.continue"), idKey(node.meta.label?.id));
|
||||
buttons.push({ label, ...(name ? { name } : {}), ...(value != null ? { value } : {}) });
|
||||
} else {
|
||||
fields.push(toField(node, name, inputType));
|
||||
fields.push(toField(node, name, inputType, t));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,10 +178,10 @@ export function buildFlowView(flow: Flow, type: FlowType): FlowView {
|
||||
buttons,
|
||||
fields,
|
||||
hidden,
|
||||
messages: (flow.ui.messages ?? []).map((m) => ({ text: m.text, tone: tone(m.type) })),
|
||||
messages: (flow.ui.messages ?? []).map((m) => ({ text: kratosText(t, m.text, idKey(m.id)), tone: tone(m.type) })),
|
||||
method: flow.ui.method || "post",
|
||||
sso,
|
||||
...(type === "login" ? { recoverHref: "/recovery" } : {}),
|
||||
...CHROME[type],
|
||||
...chromeFor(type, t),
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user