Compare commits
229 Commits
58398481ca
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cf48b3014c | |||
| 43bb004ae7 | |||
| f4693af3df | |||
| c702a347dc | |||
| fb480973b7 | |||
| 26ba278eb3 | |||
| 952af3f107 | |||
| 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 | |||
| c30cd95ebd | |||
| f38b5373bd | |||
| 9966b6bd46 | |||
| 096720904e | |||
| 04fe5b1e06 | |||
| 3486e0ad00 | |||
| 8f9f79ac30 | |||
| b580f7d06e | |||
| f0662cbd0f | |||
| 4b4ac178ab | |||
| 73a78d4404 | |||
| 5a5803b265 | |||
| a2204782fa | |||
| eb5aafdfaa | |||
| 5c3af63847 | |||
| 21cd878447 | |||
| 1abaa22a97 | |||
| 0919adf4ef | |||
| f5f4455b81 | |||
| 3f30f889c3 | |||
| ae1479f55b | |||
| bb6021adf7 | |||
| c4e6212189 | |||
| 6f6aafad39 | |||
| 4aada6eed9 | |||
| a574effb37 | |||
| c259497627 | |||
| 8e74532a77 | |||
| 62f95afe63 | |||
| ffeec70f8f | |||
| 62d4c8b7cd | |||
| 12cc2d54c2 | |||
| bb6adc40af | |||
| c64156a9d5 | |||
| de2ad42f5a | |||
| 9213e5a0de | |||
| 45054db5e6 | |||
| 23bafd247d | |||
| 7c66599f35 | |||
| 6db0f57bf4 | |||
| 175717f04d | |||
| 6c850b8923 | |||
| af4a70d904 | |||
| a0244a32cd | |||
| 6559f40142 | |||
| d3154819f8 | |||
| 1cba6d470c | |||
| 194c090bd1 | |||
| ff5094f7e9 | |||
| 419ee1750b | |||
| cedac950be | |||
| ff455f1ef2 | |||
| 5bd26d773d | |||
| 4f60bad119 | |||
| aea568ea1c | |||
| 145db5b4cd | |||
| 7c39056188 | |||
| 9719586f51 | |||
| 67d8a095a5 | |||
| 476ef6fce2 | |||
| 93fa751d6d | |||
| cc886936ed | |||
| e3e582afef | |||
| 0644ec8f5a | |||
| 058280934b | |||
| 50006dd1a7 | |||
| 6e60df7008 | |||
| c8981c12d3 | |||
| 7a3161d3ff | |||
| c78770a713 | |||
| 94654a2adc | |||
| 8afaeccf2c | |||
| 535902e69b | |||
| 8621cf24b6 | |||
| e8ea911b80 | |||
| 2202bdbaa0 | |||
| 4adf14f386 | |||
| fe97c3854a | |||
| d8cf257940 | |||
| 2b88bf1c0d | |||
| de22f51c12 | |||
| 6d316c4888 | |||
| 913bd6813a | |||
| bb612baa2c | |||
| a9f25a7692 | |||
| e22d24aa8a | |||
| af097a8885 | |||
| 166f38d5dd | |||
| c8b4c3c23b | |||
| 1d198acc97 |
+9
-2
@@ -1,8 +1,15 @@
|
||||
.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
|
||||
html-css-foundation
|
||||
|
||||
e2e/artifacts
|
||||
# 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
|
||||
e2e-tests/compose.*.yml
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches-ignore: [main]
|
||||
|
||||
jobs:
|
||||
full-gate:
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
- 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
|
||||
- name: Push app image tagged with the commit hash
|
||||
env:
|
||||
IMAGE: gitea.larvit.se/${{ github.repository }}:${{ github.sha }}
|
||||
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
|
||||
run: |
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
|
||||
docker build -t "$IMAGE" .
|
||||
docker push "$IMAGE"
|
||||
- name: Log out of the registry
|
||||
if: always()
|
||||
run: docker logout gitea.larvit.se
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Mirror
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['**']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
github-mirror:
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
- 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 --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/*'
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Registry cleanup
|
||||
on:
|
||||
schedule:
|
||||
- cron: '43 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
prune-stale-images:
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
- 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 }}
|
||||
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
|
||||
REPO_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
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.19.0-alpine3.24 node registry-cleanup/cleanup.ts
|
||||
@@ -0,0 +1,106 @@
|
||||
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@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 }}
|
||||
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||
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; }
|
||||
# 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_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
|
||||
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: |
|
||||
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"
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Renovate
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
- name: Run Renovate against this repo
|
||||
env:
|
||||
GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e GITHUB_COM_TOKEN \
|
||||
-e LOG_LEVEL=info \
|
||||
-e RENOVATE_ENDPOINT=https://gitea.larvit.se/api/v1 \
|
||||
-e RENOVATE_GIT_AUTHOR="Renovate Bot <renovate@larvit.se>" \
|
||||
-e RENOVATE_PLATFORM=gitea \
|
||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||
-e RENOVATE_TOKEN \
|
||||
renovate/renovate:44.41.1
|
||||
|
||||
# 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), 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 (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@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Tag a release for what Renovate merged
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --force --quiet origin '+refs/heads/main:refs/remotes/origin/main' '+refs/tags/*:refs/tags/*'
|
||||
if [ "$(git log -1 --format='%ae' origin/main)" != "renovate@larvit.se" ]; then
|
||||
echo "main tip not authored by Renovate — a human owns this release; skipping"; exit 0
|
||||
fi
|
||||
LATEST=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1)
|
||||
LATEST=${LATEST:-v0.0.0}
|
||||
if [ -z "$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' --format='%H')" ]; then
|
||||
echo "No untagged renovate commits since ${LATEST} — nothing to release"; exit 0
|
||||
fi
|
||||
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
|
||||
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
|
||||
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"
|
||||
+16
-2
@@ -3,5 +3,19 @@
|
||||
*.log
|
||||
node_modules
|
||||
|
||||
# Playwright E2E outputs (screenshots, html report, traces)
|
||||
e2e/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/*
|
||||
!/plugins/.gitkeep
|
||||
|
||||
# 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,34 +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.
|
||||
|
||||
## Project priorities (do not erode)
|
||||
|
||||
1. **Simplicity** — prefer the smallest, most readable solution.
|
||||
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.
|
||||
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`,
|
||||
`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.
|
||||
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`.
|
||||
`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.
|
||||
|
||||
### 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 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 only — no host tooling
|
||||
|
||||
@@ -44,19 +327,101 @@ docker compose run --rm --no-deps web npm test # tests
|
||||
docker compose -f compose.yml up --build -d # production
|
||||
```
|
||||
|
||||
## README structure (keep it this way)
|
||||
|
||||
`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 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.
|
||||
|
||||
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.
|
||||
(`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.
|
||||
- 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`).
|
||||
- Run the stability reviewer agent after every implementation of something that can be like
|
||||
a PR. That includes an implementation from the todo file that is pushed directly to master.
|
||||
Skip this if the changes are purely documentation and/or comments.
|
||||
- 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.16.0-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"]
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e/.
|
||||
# Built/run via compose.e2e.yml; targets the `web` service over the network.
|
||||
FROM mcr.microsoft.com/playwright:v1.49.1-noble
|
||||
|
||||
WORKDIR /e2e
|
||||
|
||||
COPY e2e/package.json e2e/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY e2e/ ./
|
||||
|
||||
CMD ["npx", "playwright", "test"]
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env bash
|
||||
# The full CI gate: typecheck → unit tests → every E2E suite, each against a FRESH stack
|
||||
# that is always torn down. One reproducible command — run it locally or wire it into your CI
|
||||
# service. Docker-only (it drives `docker compose`; node/npm/tsc run inside containers, never the host).
|
||||
#
|
||||
# bash ci.sh
|
||||
#
|
||||
# Exits non-zero on the first failure. Each E2E suite OWNS a clean stack — never point two suites at
|
||||
# one backend (auth-refresh revokes the admin's sessions; full-flow writes users/groups/roles to Keto).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
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 --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$'
|
||||
}
|
||||
|
||||
if docs_only; then
|
||||
step "Only *.md changed since main — nothing to test, skipping the gate"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Pins that MUST move in lockstep: a browser/runner mismatch yields confusing E2E failures.
|
||||
step "Playwright pin lockstep (e2e-tests/Dockerfile image == e2e-tests/package.json @playwright/test)"
|
||||
# `|| true` so a no-match doesn't trip `set -e`/`pipefail` before the explicit check below can report.
|
||||
img=$(grep -oE 'playwright:v[0-9.]+' e2e-tests/Dockerfile | grep -oE '[0-9.]+$' || true)
|
||||
pkg=$(grep -oE '"@playwright/test": "[0-9.]+"' e2e-tests/package.json | grep -oE '[0-9.]+' || true)
|
||||
[ -n "$img" ] && [ "$img" = "$pkg" ] || { echo "Playwright pin mismatch/unreadable: image v$img vs @playwright/test $pkg"; exit 1; }
|
||||
echo "ok ($img)"
|
||||
|
||||
# Explicit rebuild: without it a stale web image from a previous branch supplies node_modules
|
||||
# (the source is bind-mounted but deps are baked in), so a dep bump gets typechecked/tested
|
||||
# against the OLD packages. Cheap when deps are unchanged (npm ci layer is cache-keyed).
|
||||
step "Build web image"
|
||||
docker compose build web
|
||||
|
||||
step "Typecheck"
|
||||
docker compose run --rm --no-deps web npm run typecheck
|
||||
|
||||
step "Unit tests"
|
||||
units=$(docker compose run --rm --no-deps web npm test 2>&1) || { echo "$units"; exit 1; }
|
||||
echo "$units" | grep -E '^. (tests|pass|fail) ' || true
|
||||
# Sanity floor: catch a glob that matches too few files (a full empty glob already exits non-zero above).
|
||||
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 --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"; }
|
||||
}
|
||||
|
||||
e2e e2e-tests/compose.visual.yml # visual / design-system parity (Ory-free)
|
||||
e2e e2e-tests/compose.auth.yml # token timeout + silent re-mint
|
||||
e2e e2e-tests/compose.oauth.yml # OAuth2 login + consent
|
||||
e2e e2e-tests/compose.full.yml # full browser flow: login (password + SSO), menu, CRUD, plugin, logout
|
||||
|
||||
# Dev-stack login regression — runs against the PLAIN `docker compose up` topology (base + override)
|
||||
# with the runner on the HOST network, so it can't use the shared e2e() helper (which merges only
|
||||
# compose.yml + the suite). Needs host networking + the host ports 3000/4433 free (Linux CI).
|
||||
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 --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"; }
|
||||
|
||||
step "ALL GREEN"
|
||||
@@ -1,41 +0,0 @@
|
||||
# Playwright E2E. Brings up the app + a Playwright runner, screenshots the live pages and the
|
||||
# html-css-foundation mockups, and asserts the live DOM computes the same design styles.
|
||||
# docker compose -f compose.yml -f compose.e2e.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f compose.e2e.yml down -v # tear down after
|
||||
# --build rebuilds the runner (the image bakes in e2e/) so spec edits are picked up.
|
||||
# Screenshots + HTML report land in ./e2e/artifacts/ (git-ignored).
|
||||
services:
|
||||
web:
|
||||
# The dashboard renders mock data — no Ory needed. Drop the base file's kratos/keto
|
||||
# dependency so the visual suite stays fast and doesn't boot Postgres + the Ory stack.
|
||||
depends_on: !reset []
|
||||
# Dev throwaways are fine for tests; cache templates for production-like rendering.
|
||||
environment:
|
||||
CACHE_TEMPLATES: "true"
|
||||
REQUIRE_SECURE_SECRETS: "false"
|
||||
SECURE_COOKIES: "false" # the suite hits web over http — Secure cookies wouldn't be stored
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
|
||||
interval: 2s
|
||||
timeout: 4s
|
||||
retries: 15
|
||||
|
||||
e2e:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.e2e
|
||||
# Just the Ory-free visual suite; the full-stack auth spec runs via compose.e2e-auth.yml.
|
||||
command: ["npx", "playwright", "test", "visual.spec.ts"]
|
||||
depends_on:
|
||||
web:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BASE_URL: http://web:3000
|
||||
volumes:
|
||||
# The mockups + their stylesheet, kept as siblings so file:// ../public/css resolves.
|
||||
- ./html-css-foundation:/repo/html-css-foundation:ro
|
||||
- ./public:/repo/public:ro
|
||||
# The committed dev tokenizer key — the spec signs a session JWT with it so the gated
|
||||
# dashboard (§10) renders; web verifies it with the same key (the file it mounts read-only).
|
||||
- ./ory/kratos/tokenizer/jwks.json:/repo/jwks.json:ro
|
||||
- ./e2e/artifacts:/e2e/artifacts
|
||||
+59
-10
@@ -1,25 +1,56 @@
|
||||
# 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
|
||||
# Dev overrides the base toggles: live template edits, dev-throwaway secrets allowed.
|
||||
environment:
|
||||
# Canonical public URL — the ONE knob. The web app redirects off-host visitors here, so
|
||||
# localhost / 127.0.0.1 / any alias all funnel to one cookie host (Kratos' browser URLs below
|
||||
# derive from it too). Override for a non-default host and the stack follows; see the note on
|
||||
# kratos.SERVE_PUBLIC_BASE_URL for the single dev caveat (the published Ory port).
|
||||
APP_URL: ${APP_URL:-http://localhost:3000}
|
||||
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" # reference plugin → the dev mock backend
|
||||
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
|
||||
|
||||
# Dev mock backend for the reference plugin (plugins/scheduling). A stand-in for the customer's
|
||||
# real scheduling service — stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at
|
||||
# the real backend instead. Uses the pinned app image so there's nothing extra to build/pull.
|
||||
# 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 (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.16.0-alpine3.24
|
||||
command: node /srv/server.mjs
|
||||
image: node:24.19.0-alpine3.24
|
||||
command: node /srv/server.ts
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./examples/shifts-upstream:/srv:ro
|
||||
@@ -27,17 +58,35 @@ 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.1
|
||||
image: axllent/mailpit:v1.31.0
|
||||
ports:
|
||||
- "8025:8025"
|
||||
restart: unless-stopped
|
||||
|
||||
# Ory Kratos dev: expose the public API so the browser can POST self-service flows to
|
||||
# flow.ui.action (kratos.yml base_url = 127.0.0.1:4433). Prod fronts Ory same-origin,
|
||||
# so the base file publishes no Ory ports.
|
||||
# flow.ui.action. Prod fronts Ory same-origin, so the base file publishes no Ory ports.
|
||||
kratos:
|
||||
ports:
|
||||
- "4433:4433"
|
||||
# Every browser-facing Kratos URL derives from APP_URL — one knob, no second place to edit (the
|
||||
# localhost-vs-127.0.0.1 disagreement that broke login was exactly this drift). Env overrides the
|
||||
# kratos.yml defaults (Ory: env wins over config files).
|
||||
environment:
|
||||
# The public API the login form POSTs to. Its HOST must match APP_URL's (cookies are host-scoped,
|
||||
# port-agnostic) but its PORT is the published Ory one (4433), so it can't be APP_URL verbatim.
|
||||
# This is the ONE dev value to also change for a non-localhost APP_URL host (e.g. a LAN IP).
|
||||
SERVE_PUBLIC_BASE_URL: ${KRATOS_PUBLIC_BROWSER_URL:-http://localhost:4433/}
|
||||
SELFSERVICE_DEFAULT_BROWSER_RETURN_URL: ${APP_URL:-http://localhost:3000}/
|
||||
SELFSERVICE_ALLOWED_RETURN_URLS: ${APP_URL:-http://localhost:3000}
|
||||
SELFSERVICE_FLOWS_ERROR_UI_URL: ${APP_URL:-http://localhost:3000}/error
|
||||
SELFSERVICE_FLOWS_LOGIN_UI_URL: ${APP_URL:-http://localhost:3000}/login
|
||||
SELFSERVICE_FLOWS_LOGIN_AFTER_DEFAULT_BROWSER_RETURN_URL: ${APP_URL:-http://localhost:3000}/auth/complete
|
||||
SELFSERVICE_FLOWS_REGISTRATION_UI_URL: ${APP_URL:-http://localhost:3000}/registration
|
||||
SELFSERVICE_FLOWS_SETTINGS_UI_URL: ${APP_URL:-http://localhost:3000}/settings
|
||||
SELFSERVICE_FLOWS_RECOVERY_UI_URL: ${APP_URL:-http://localhost:3000}/recovery
|
||||
SELFSERVICE_FLOWS_VERIFICATION_UI_URL: ${APP_URL:-http://localhost:3000}/verification
|
||||
SELFSERVICE_FLOWS_VERIFICATION_AFTER_DEFAULT_BROWSER_RETURN_URL: ${APP_URL:-http://localhost:3000}/
|
||||
SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL: ${APP_URL:-http://localhost:3000}/login
|
||||
|
||||
# Ory Hydra dev: --dev permits the http issuer/redirect URLs; expose the public port
|
||||
# so OAuth2 flows reach the host. Prod (base file) drops --dev for an https issuer.
|
||||
|
||||
+35
-12
@@ -9,13 +9,24 @@ services:
|
||||
# Supply CSRF_SECRET via env; the dev-throwaway fallback boots a clean clone but
|
||||
# REQUIRE_SECURE_SECRETS refuses it in prod (config.ts), so a forgotten secret fails loud.
|
||||
environment:
|
||||
# Canonical public URL — set it to your domain to enable the canonical-host redirect (off when
|
||||
# empty/unset, so a forgotten value never bounces real users). Your reverse proxy must preserve
|
||||
# the public Host (or forward it) or a Host-rewriting proxy can loop. Kratos browser URLs and
|
||||
# the banner derive from the same APP_URL. The dev override sets it to localhost.
|
||||
APP_URL: ${APP_URL:-}
|
||||
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 §6 OAuth2 login/
|
||||
# consent handler) + the one-shot bootstrap (admin + JWKS seed).
|
||||
# Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
|
||||
# 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
|
||||
@@ -25,17 +36,20 @@ services:
|
||||
condition: service_healthy
|
||||
hydra:
|
||||
condition: service_healthy
|
||||
# §4 verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
|
||||
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}
|
||||
@@ -112,7 +126,7 @@ services:
|
||||
retries: 20
|
||||
restart: unless-stopped
|
||||
|
||||
# One-shot first-boot seed (§3, the MVP bar); see src/bootstrap.ts. Idempotent, re-runs
|
||||
# One-shot first-boot seed (the MVP bar); see src/auth/bootstrap.ts. Idempotent, re-runs
|
||||
# cleanly. Runs once kratos+keto are healthy; web waits for it. Tokenizer dir mounted
|
||||
# read-write (the only writer) so the absent-JWKS safety net can land the key.
|
||||
bootstrap:
|
||||
@@ -122,26 +136,35 @@ 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 roles for the demo admin; bootstrap also grants every discovered plugin's declared
|
||||
# permission tokens (so the reference plugin — and any drop-in — works out of the box).
|
||||
ADMIN_ROLES: ${ADMIN_ROLES:-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:-}
|
||||
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/bootstrap.ts
|
||||
command: node src/auth/bootstrap.ts
|
||||
# Bounded retry: the seed is idempotent, so transient Ory blips recover — but a permanent
|
||||
# error must give up, not loop forever and hang `web` (gates on completion).
|
||||
restart: "on-failure:5"
|
||||
|
||||
# Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README).
|
||||
# DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml. web implements the
|
||||
# login challenge at /oauth2/login (§6, consent next). Dev permits the http issuer via --dev
|
||||
# login challenge at /oauth2/login (consent next). Dev permits the http issuer via --dev
|
||||
# (compose.override.yml); prod sets an https issuer via env (URLS_SELF_ISSUER).
|
||||
hydra-migrate:
|
||||
image: oryd/hydra:v26.2.0
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
# The Plainpages plugin contract
|
||||
|
||||
The authoritative reference for the plugin API — the product's main surface. A plugin is a
|
||||
self-contained folder under `plugins/` that the host discovers at boot; there is no
|
||||
registration step. The contract is **TypeScript** (`src/plugin.ts`), so the types here are the
|
||||
single source of truth — this document explains them, the guarantees around them, and the rules
|
||||
the host enforces.
|
||||
|
||||
**Design stance.** The audience is experienced developers. The API optimises for being
|
||||
**powerful, predictable, and overloadable** — a plugin can take over as much of a page as it
|
||||
wants. The host **fails loud at boot/discovery** rather than sandboxing at runtime: a malformed
|
||||
manifest, a version mismatch, or a conflict stops startup with a clear message. Runtime
|
||||
crash-isolation (one bad plugin can't take the host down) is a *non-goal* — diagnose at deploy
|
||||
time, not in production.
|
||||
|
||||
> **Status.** This is the contract the §2 host implements. The types and pure rules
|
||||
> (`checkApiVersion`, `findConflicts`, `isValidPluginId`) live in `src/plugin.ts`; **discovery**
|
||||
> (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params,
|
||||
> permission gate, `RouteResult` → response), and the **per-plugin view resolver**
|
||||
> (`src/view-resolver.ts` — a `view` result renders `plugins/<id>/views/`, with the core partials
|
||||
> reachable via `include()`), **per-plugin static serving** (`/public/<id>/` → the plugin's
|
||||
> `public/`, `routePublic` in `src/static.ts`), and the **central menu override + branding**
|
||||
> (`config/menu.ts`, loaded by `src/menu-config.ts`, with branding — name, logo, default theme —
|
||||
> rendered in the app shell) are wired and in use by the built-in screens and the reference plugin.
|
||||
> Later phases extended this contract: the replaceable [landing pages](#the-landing-pages-home--dashboard)
|
||||
> and [public pages & menu items](#public-pages--menu-items) (§10), both documented below.
|
||||
|
||||
## Anatomy of a plugin
|
||||
|
||||
```
|
||||
plugins/scheduling/ # folder name = the plugin id → mounted at /scheduling
|
||||
plugin.ts # default export: the manifest (definePlugin(...))
|
||||
shifts.ts # handlers, helpers — plain modules
|
||||
views/ # EJS templates for this plugin's pages
|
||||
shifts.ejs
|
||||
public/ # static assets, served at /public/scheduling/
|
||||
scheduling.css
|
||||
```
|
||||
|
||||
**Identity comes from the folder.** The folder name *is* the plugin `id`, and the mount path is
|
||||
`/<id>` — neither is written in the manifest, so they can't drift or be claimed twice. The id
|
||||
must be **URL/path-safe** (`isValidPluginId`: lowercase `a–z`, digits, and dashes — dashes
|
||||
anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name
|
||||
at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` assets, and (by
|
||||
convention) its nav/permission tokens.
|
||||
|
||||
A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the
|
||||
Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
|
||||
the `admin` screens, the `oauth2` provider routes, and `public` (static). Since plugin routes resolve
|
||||
first, a folder claiming one would silently shadow a built-in route, so discovery refuses it loud
|
||||
(`RESERVED_PLUGIN_IDS`). (`/` is owned by the `home` field, not a route, so it needs no reservation.)
|
||||
|
||||
Installing a plugin is "drop the folder, restart." Removing one is "delete the folder, restart."
|
||||
Nothing else references it; the operator stays in control through the central menu override
|
||||
(`config/menu.ts`).
|
||||
|
||||
## The manifest
|
||||
|
||||
A plugin imports its host surface from one module — `src/plugin-api.ts`, the **stable author
|
||||
barrel** (`definePlugin`, the manifest/handler types, `RequestContext`, the guards, and the
|
||||
body/CSRF/list-query helpers). That barrel *is* the contract boundary; don't reach into deeper
|
||||
`src/*` modules — the host may refactor those freely as long as the barrel holds.
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "../../src/plugin-api.ts";
|
||||
import { listShifts, createShift } from "./shifts.ts";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see Versioning)
|
||||
|
||||
// Nav fragment, merged into the global menu and permission-filtered per user.
|
||||
// `icon` is a Lucide icon by its sprite id (src/icons.ts).
|
||||
nav: [{
|
||||
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
|
||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
||||
}],
|
||||
|
||||
// Permission tokens this plugin introduces. Declared for documentation, conflict detection, and
|
||||
// bootstrap seeding (the demo admin is granted every discovered plugin's tokens). Optional.
|
||||
permissions: [
|
||||
{ token: "scheduling:read", description: "View shifts" },
|
||||
{ token: "scheduling:write", description: "Create and edit shifts" },
|
||||
],
|
||||
|
||||
// Route handlers, mounted under the plugin's path (/scheduling). `permission` gates first.
|
||||
routes: [
|
||||
{ method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts },
|
||||
{ method: "POST", path: "/shifts", permission: "scheduling:write", handler: createShift },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
`definePlugin()` only types the object and returns it unchanged — a manifest may equally be a
|
||||
plain typed object. It types the authored shape (`PluginManifest`); the host attaches the
|
||||
folder-derived `id` to produce the loaded `Plugin`. All validation happens at discovery. Note
|
||||
there is **no `id` or `basePath`** in the manifest — both come from the folder
|
||||
([Anatomy](#anatomy-of-a-plugin)).
|
||||
|
||||
| Field | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). |
|
||||
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/icons.ts`); node `id`s must be globally unique. |
|
||||
| `permissions` | no | Tokens this plugin introduces; declared for docs, conflict detection, and bootstrap seeding (see [Nav & permissions](#nav--permissions)). |
|
||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||
| `hooks` | no | See [Hooks](#hooks). |
|
||||
|
||||
A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional.
|
||||
|
||||
## Routes & handlers
|
||||
|
||||
A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's
|
||||
mount path `/<id>`** (so `/shifts` in the `scheduling` plugin serves `/scheduling/shifts`); the host
|
||||
matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`,
|
||||
runs the `permission` gate (a coarse JWT-claim check — see the README), and only then calls the
|
||||
handler with the [request context](#requestcontext). When the gate fails, an **anonymous** visitor
|
||||
is redirected to `/login` to sign in (same as the built-in admin screens); the requested page is
|
||||
preserved as `return_to`, so after signing in they land **back on the page they asked for**, not the
|
||||
dashboard. A **signed-in** user who simply lacks the role gets the **403** page. A route marked
|
||||
**`public: true`** has no gate at all — anyone reaches it (see [Public pages & menu
|
||||
items](#public-pages--menu-items)).
|
||||
|
||||
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
|
||||
|
||||
A handler returns a **`RouteResult`** (or a `Promise` of one); the host turns it into the HTTP
|
||||
response. Returning `void` is the escape hatch — the handler wrote to `ctx.res` itself.
|
||||
|
||||
```ts
|
||||
type RouteResult =
|
||||
| { view: string; data?: Record<string, unknown>; status?: number; headers?: Record<string, string> }
|
||||
| { html: string; status?: number; headers?: Record<string, string> }
|
||||
| { json: unknown; status?: number; headers?: Record<string, string> } // opt-in JS enhancement
|
||||
| { redirect: string; status?: number }; // 303 unless status set
|
||||
```
|
||||
|
||||
```ts
|
||||
// shifts.ts
|
||||
import { parseListQuery, type RequestContext } from "../../src/plugin-api.ts";
|
||||
|
||||
export async function listShifts(ctx: RequestContext) {
|
||||
const q = parseListQuery(ctx.url);
|
||||
const rows = await fetch(`${upstream}/shifts?${ctx.url.searchParams}`).then((r) => r.json());
|
||||
return { view: "shifts", data: { rows, q } }; // renders plugins/scheduling/views/shifts.ejs
|
||||
}
|
||||
```
|
||||
|
||||
- **`view`** resolves against the plugin's own `views/` (`src/view-resolver.ts`) — nested names
|
||||
like `"shifts/edit"` work, and an out-of-bounds name is refused. The template may `include()`
|
||||
the core building-block partials (app shell, nav tree, data table, …) and its own
|
||||
partials/subfolders to render a full page — exactly as the built-in screens do. To load the
|
||||
plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
|
||||
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
|
||||
- **Finer authorization than the route `permission`** uses the guards from `src/plugin-api.ts`:
|
||||
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
|
||||
to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
|
||||
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
|
||||
signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check`
|
||||
to render the 403 page.
|
||||
- The handler **fetches its own data** from upstream and renders it; plugins hold no state
|
||||
(see the README's *Stateless* section). The partials only need rows.
|
||||
- `default` status: `200` for `view`/`html`/`json`, `303` for `redirect`.
|
||||
|
||||
### Escaping & the trust boundary
|
||||
|
||||
The host does not sandbox plugin output (crash-isolation is a non-goal), so a handler **owns the
|
||||
safety of the data it renders**:
|
||||
|
||||
- **Raw HTML is raw.** An `{ html }` result and the `*.html` partial fields (`cell.html`,
|
||||
`error.html`, a menu `trigger.html`) are emitted **unescaped** — that's their purpose (slot
|
||||
composition). Escape any untrusted content yourself before putting it there.
|
||||
- **Text is auto-escaped; URLs are not scheme-checked.** Partials escape text fields (labels,
|
||||
names), so those are injection-safe. But a URL field — nav `href`, a table cell link, a menu
|
||||
item, a breadcrumb, `brand.logo` — is emitted as-is inside the attribute: a `javascript:` or
|
||||
`data:` URL from upstream/user data becomes live XSS. When a URL comes from data you don't
|
||||
control, pass it through **`safeUrl()`** from `src/plugin-api.ts` first — it returns the URL when
|
||||
it's relative or `http(s):` and collapses anything else to `"#"`:
|
||||
```ts
|
||||
import { safeUrl } from "../../src/plugin-api.ts";
|
||||
return { view: "list", data: { rows: rows.map((r) => ({ ...r, href: safeUrl(r.href) })) } };
|
||||
```
|
||||
|
||||
## The landing pages (`home` & `dashboard`)
|
||||
|
||||
The host has two replaceable landing slots, and a plugin may own either or both:
|
||||
|
||||
| Slot | Path | Gate | Default |
|
||||
| --- | --- | --- | --- |
|
||||
| `home` | `/` | **public** — anyone | An intro page with prominent sign-in / register links. |
|
||||
| `dashboard` | `/dashboard` | **signed-in session** (anonymous → `/login`, with `/dashboard` as `return_to`) | The built-in mock-data People list. |
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "../../src/plugin-api.ts";
|
||||
import { landing, board } from "./pages.ts";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "1.0.0",
|
||||
home: landing, // owns "/" — the public front page
|
||||
dashboard: board, // owns "/dashboard" — the post-login app home
|
||||
});
|
||||
```
|
||||
|
||||
Each is a `RouteHandler` like any route's — it receives the [`RequestContext`](#requestcontext) and
|
||||
returns a `RouteResult`, typically a `view` from the plugin's own `views/`. A `dashboard` handler
|
||||
renders against the native app shell via `ctx.chrome` exactly as a route handler does; a `home`
|
||||
handler is a **public** page, so `ctx.user` may be `null` (use it to show a "go to dashboard" link to
|
||||
a signed-in visitor, or sign-in / register to an anonymous one). After login the user lands on
|
||||
`/dashboard` (or the `return_to` they were headed to), and the global menu's **Dashboard** link
|
||||
points there.
|
||||
|
||||
For the gated `dashboard`, the host enforces the session gate first, so `ctx.user` is non-null;
|
||||
branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a
|
||||
single permission — there's no second dashboard to fall back to, so a user lacking it would land on a
|
||||
403. (Both slots answer `GET` and `HEAD`.)
|
||||
|
||||
Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a
|
||||
boot-stopping conflict ([below](#conflict-rules)), never last-write-wins. Neither needs a `routes`
|
||||
entry — the host mounts them above the `/<id>` route namespace, and `/` can't be shadowed by a plugin
|
||||
route at all (route paths always carry the `/<id>` prefix).
|
||||
|
||||
## RequestContext
|
||||
|
||||
Every handler receives one argument, the `RequestContext` (`src/context.ts`), built once per
|
||||
request:
|
||||
|
||||
```ts
|
||||
interface RequestContext {
|
||||
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
|
||||
log: Log; // request-scoped logger, in this request's trace (§9)
|
||||
params: Record<string, string>; // path params from the route match, e.g. /shifts/:id → { id }
|
||||
query: URLSearchParams; // alias of url.searchParams
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
||||
url: URL;
|
||||
user: User | null; // { id, email, roles } from the verified session JWT, or null
|
||||
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
|
||||
}
|
||||
```
|
||||
|
||||
**`ctx.chrome`** is the page chrome the host builds per request — `{ brand, csrfToken, nav, signInHref,
|
||||
theme, user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same
|
||||
sidebar, branding, theme switch and signed-in profile as the built-in screens); `chrome.nav` is the
|
||||
global menu — your plugin's nav fragment plus the others and the admin section — already composed,
|
||||
role-filtered, and current-marked for this request (the gated **Dashboard** link is omitted for an
|
||||
anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the
|
||||
current page baked in as `return_to`. Map each `chrome.*` to the matching `partials/shell` local —
|
||||
`brand`, `csrfToken`, `nav` (the rendered nav-tree), `signInHref`, `theme`, `user` — exactly as the
|
||||
reference `plugins/scheduling/views/overview.ejs` does; a value you forget simply falls back to its
|
||||
shell default (e.g. a bare `/login`), it does not error. **`ctx.verifyCsrf(submitted)`** guards a
|
||||
state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then on POST read your own
|
||||
body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the
|
||||
secret and sets the cookie; the plugin never touches it. (See the reference: `plugins/scheduling/`.)
|
||||
|
||||
**`ctx.log`** is a structured, request-scoped logger ([`@larvit/log`](https://www.npmjs.com/package/@larvit/log),
|
||||
§9) already in this request's trace: `ctx.log.info("…", { key: "value" })` (also `warn`/`error`/`debug`,
|
||||
metadata values are string/number/boolean), and **`ctx.log.fetch(url, init?)`** — a drop-in `fetch`
|
||||
for upstream calls that adds a client span and propagates the trace (W3C `traceparent`) downstream.
|
||||
The barrel also exports a standalone **`tracedFetch`** (same behaviour, reads the ambient request log)
|
||||
to default an upstream client's `fetch` to — the reference plugin's `createUpstream` does exactly this,
|
||||
so its calls are traced with no per-handler wiring. Lines are correlated by a `requestId` and carry
|
||||
`service.name`; output/level/OTLP export are the host's config (it logs to console always, and to an
|
||||
OpenTelemetry Collector when `OTLP_ENDPOINT` is set).
|
||||
|
||||
**Stability guarantee.** The fields above are the stable contract — present and non-breaking
|
||||
across a major `apiVersion`. New fields may be **added** within a major version (additive, never
|
||||
breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine,
|
||||
but prefer the typed fields so a handler keeps working as the host evolves. `user`/`roles` come
|
||||
from the §4 JWT middleware and are `null`/`[]` until a session exists.
|
||||
|
||||
## Nav & permissions
|
||||
|
||||
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/nav.ts`), which
|
||||
applies the central override and then **filters per user** by the roles in the session JWT — a
|
||||
node shows iff it is `public`, declares no `permission`, or the user's roles include that token. Use
|
||||
arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a
|
||||
**Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids
|
||||
are `ICON_NAMES` in `src/icons.ts`, and adding one means registering its lucide name there.
|
||||
|
||||
### Public pages & menu items
|
||||
|
||||
A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**,
|
||||
and the menu item shows for everyone. This is the same as omitting `permission` (a no-permission
|
||||
route/node is already open) but stated outright, so "public" is a **deliberate choice, not the
|
||||
accident of a forgotten gate**. `public` and `permission` are **mutually exclusive** — declaring
|
||||
both is contradictory and discovery refuses the plugin at boot.
|
||||
|
||||
A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor
|
||||
`ctx.user` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page)
|
||||
in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.roles` is
|
||||
empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling`
|
||||
**Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone,
|
||||
while the actual shifts list stays behind `scheduling:read`.
|
||||
|
||||
**A `permission` token is a coarse role.** The route/nav gate passes iff the user's JWT `roles`
|
||||
include the token; those roles come from Keto at login, so an operator grants a token by writing the
|
||||
Keto tuple `Role:<token>#members@user:<id>` (or to a group) — the admin **Roles** screen does this.
|
||||
(The fine-grained, per-row tier is the separate Keto `Resource` namespace — see the README's *Three
|
||||
tiers of "may I?"*; it is not what a route `permission` checks.)
|
||||
|
||||
Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants
|
||||
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace
|
||||
your tokens as `<id>:<action>` to avoid accidental clashes. Declaring them in `permissions` is
|
||||
optional but recommended: it documents them, feeds conflict detection, and lets the one-command
|
||||
bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens (§3), so
|
||||
a dropped-in plugin works out of the box without editing host config.
|
||||
|
||||
## Contract versioning
|
||||
|
||||
Each manifest declares `apiVersion` — a **semver** string naming the host contract it was built
|
||||
against — and the host exposes the current `HOST_API_VERSION` (e.g. `"1.0.0"`). The host bumps
|
||||
**major** on a breaking manifest/handler change and **minor** on an additive one. At discovery
|
||||
the host parses both with `parseSemver` (the official semver core regex — strict: no ranges,
|
||||
`v` prefixes, or leading zeros) and applies provider/consumer semantics in `checkApiVersion`:
|
||||
|
||||
| Plugin `apiVersion` vs host | Result | Host action |
|
||||
| --- | --- | --- |
|
||||
| same major, same minor (patch ignored) | `ok` | load |
|
||||
| same major, plugin minor **<** host minor | `warn` | load, log — additive-compatible, newer features exist |
|
||||
| same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host |
|
||||
| different major | `refuse` | **abort boot** — incompatible contract |
|
||||
| missing / not a valid semver | `refuse` | **abort boot** — must be declared |
|
||||
|
||||
The plugin pins one exact version (no ranges — in keeping with the project's pinning rules); the
|
||||
*host* supplies the caret-style compatibility. `parseSemver`/`checkApiVersion` are tight,
|
||||
dependency-free functions (the `semver` package's ranges/coercion/prerelease-precedence are more
|
||||
than the contract needs).
|
||||
|
||||
**Write a literal, never `HOST_API_VERSION`.** `apiVersion` records the version the plugin was
|
||||
*built against*. Importing the host's current constant would make every plugin always equal the
|
||||
host — the check could never fire, and a future breaking change would slip through silently.
|
||||
|
||||
## Conflict rules
|
||||
|
||||
Plugins are independent folders, so the host detects collisions across all discovered plugins
|
||||
with `findConflicts` and resolves them **loudly — never last-write-wins**. `error` aborts boot;
|
||||
`warn` logs and continues.
|
||||
|
||||
| Kind | Level | Rule |
|
||||
| --- | --- | --- |
|
||||
| `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. |
|
||||
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
|
||||
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
|
||||
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
|
||||
| `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `<id>:<action>` if unintended. |
|
||||
|
||||
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its
|
||||
uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns
|
||||
rather than aborts; everything else is an error an author fixes before the host will start.
|
||||
|
||||
Beyond cross-plugin conflicts, discovery also rejects **per-manifest shape errors** at boot: a
|
||||
non-array `nav`/`routes`/`permissions`, a non-function `home`/`dashboard`, or a route/nav node that
|
||||
sets both `public` and `permission` (mutually exclusive — [Public pages](#public-pages--menu-items)).
|
||||
|
||||
## Hooks
|
||||
|
||||
Optional, for reacting to system actions. A plugin's `hooks` may implement:
|
||||
|
||||
| Hook | When | May |
|
||||
| --- | --- | --- |
|
||||
| `onBoot()` | after discovery, before the server listens | warm caches, validate upstream config |
|
||||
| `onRequest(ctx)` | before route matching | inspect, or **short-circuit** by returning a `RouteResult` |
|
||||
| `onResponse(ctx, result)` | after the handler | observe/log; cannot change the response |
|
||||
|
||||
Hooks run in **discovery order** (plugins sorted by id). `onRequest` fires on every request that
|
||||
reaches routing (static assets bypass it); the **first** hook to return a `RouteResult` wins and
|
||||
short-circuits — later `onRequest` hooks and the route handler are skipped, and that result renders
|
||||
against its own plugin's views. `onResponse` runs for a matched route after its handler, with the
|
||||
handler's result; its return value is ignored. Hooks run with no sandbox — a throwing hook fails
|
||||
loud (boot for `onBoot`, the request for the others). Keep them cheap; `onRequest` is on the hot
|
||||
path (the host skips the pipeline entirely when no plugin declares a hook). This surface is
|
||||
intentionally small and may grow additively within the major version.
|
||||
|
||||
## Local dev & test story
|
||||
|
||||
A plugin is a normal folder of TypeScript, so an author tests it the same way the core is tested
|
||||
— everything in Docker, no host tooling. The shipped reference (`plugins/scheduling/`) is the
|
||||
worked example: thin handlers bound to an injectable upstream client, unit-tested in
|
||||
`shifts.test.ts` with a mocked `fetch` and a hand-built `ctx` (no host).
|
||||
|
||||
1. **Unit-test handlers as pure functions.** Keep a handler thin: parse `ctx`, fetch upstream,
|
||||
return a `RouteResult`. Test the data-shaping in isolation (mock `fetch`/upstream) with
|
||||
`node --test`, exactly like `src/dashboard.test.ts` tests the dashboard model. No host needed.
|
||||
|
||||
```bash
|
||||
docker compose run --rm web npm test
|
||||
```
|
||||
|
||||
2. **Run one plugin against the host.** Get the folder into the container's `/app/plugins/<id>`
|
||||
— either in your clone (the dev compose bind-mounts the tree) or by bind-mounting an external
|
||||
folder (README → *Where plugins live*) — and `docker compose up`; the host discovers it. For
|
||||
an isolated harness, the §2 host exposes plugin injection (`createApp({ plugins: [myPlugin] })`)
|
||||
so a test can mount a single manifest and assert its routes, nav, and gating without the rest
|
||||
of the stack.
|
||||
|
||||
3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in
|
||||
`e2e/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web`
|
||||
service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`;
|
||||
its authenticated list/form happy-path is the §8 full-E2E item (needs cross-host login infra).
|
||||
|
||||
The validation an author hits is the same the host runs: bad `apiVersion` or a conflict
|
||||
([above](#conflict-rules)) stops boot with a precise message naming the plugin(s) involved.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
|
||||
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
|
||||
FROM mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
|
||||
WORKDIR /e2e-tests
|
||||
|
||||
COPY e2e-tests/package.json e2e-tests/package-lock.json ./
|
||||
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,15 +1,15 @@
|
||||
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", §4). Runs against the
|
||||
// real Ory stack via compose.e2e-auth.yml, where the session→JWT TTL is shortened to 8s and the
|
||||
// Full-stack auth E2E: token timeout + silent re-mint ("stay signed in"). Runs against the
|
||||
// real Ory stack via e2e-tests/compose.auth.yml, where the session→JWT TTL is shortened to 8s and the
|
||||
// web clock skew is 0 — so the ~10m token lapses in seconds and the hot path re-mints it from the
|
||||
// still-live Kratos session. We drive the flow over HTTP (fetch, manual cookies) because Kratos
|
||||
// and web sit on different hosts here; web's own server-side cookie relay is what we exercise.
|
||||
// The browser-UI login is owned by §8; this proves the timeout/refresh server behaviour end-to-end.
|
||||
// The browser-UI login is owned by the full-flow E2E; this proves the timeout/refresh server behaviour end-to-end.
|
||||
const WEB = process.env.BASE_URL ?? "http://web:3000";
|
||||
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
||||
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3); admin role granted in Keto
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin permission granted in Keto
|
||||
const ADMIN_PASSWORD = "admin";
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -29,8 +29,8 @@ function relayCookies(res: Response): string {
|
||||
.filter((kv) => kv.split("=")[1] !== "")
|
||||
.join("; ");
|
||||
}
|
||||
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/roles).
|
||||
function jwtClaims(jwt: string): { email: string; exp: number; roles: string[]; sub: string } {
|
||||
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/permissions).
|
||||
function jwtClaims(jwt: string): { email: string; exp: number; permissions: string[]; sub: string } {
|
||||
return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ async function awaitJwtSetCookie(session: string, jwt: string): Promise<string>
|
||||
test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => {
|
||||
test.setTimeout(90_000); // two short-TTL windows (8s each) + Ory round-trips
|
||||
|
||||
// 1. Log in for real, then complete login on web → our session JWT (roles read from Keto).
|
||||
// 1. Log in for real, then complete login on web → our session JWT (permissions read from Keto).
|
||||
const session = await kratosLogin();
|
||||
const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
|
||||
expect(complete.status, "auth/complete redirects home").toBe(303);
|
||||
@@ -83,7 +83,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
|
||||
const claims1 = jwtClaims(jwt1);
|
||||
expect(claims1.email).toBe(ADMIN_EMAIL);
|
||||
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
|
||||
expect(claims1.roles, "roles are projected from Keto").toContain("admin");
|
||||
expect(claims1.permissions, "permissions are projected from Keto").toContain("users:read");
|
||||
|
||||
// 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
|
||||
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.roles, "re-mint re-reads roles from Keto").toContain("admin");
|
||||
expect(claims2.permissions, "re-mint re-reads permissions from Keto").toContain("users:read");
|
||||
|
||||
// 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared.
|
||||
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
|
||||
@@ -1,9 +1,9 @@
|
||||
# Full-stack auth E2E — token timeout + silent re-mint ("stay signed in", §4). The Ory-free
|
||||
# visual suite (compose.e2e.yml) covers the design system; this is its full-stack counterpart:
|
||||
# Full-stack auth E2E — token timeout + silent re-mint ("stay signed in"). The Ory-free
|
||||
# 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 compose.e2e-auth.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f compose.e2e-auth.yml down -v # tear down after
|
||||
# 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:
|
||||
# This suite exercises only the Kratos session → JWT re-mint; it needs Kratos + Keto + bootstrap,
|
||||
@@ -19,6 +19,7 @@ services:
|
||||
# Dev throwaways are fine for the test stack; the runner hits web over http; treat the JWT as
|
||||
# expired the instant its TTL lapses (no 60s leeway) so the re-mint fires promptly.
|
||||
environment:
|
||||
APP_URL: http://web:3000 # the runner calls web on this host → canonical-host redirect stays inert
|
||||
CACHE_TEMPLATES: "true"
|
||||
JWT_CLOCK_SKEW_SEC: "0"
|
||||
REQUIRE_SECURE_SECRETS: "false"
|
||||
@@ -29,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:
|
||||
@@ -37,7 +50,7 @@ services:
|
||||
e2e:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.e2e
|
||||
dockerfile: e2e-tests/Dockerfile
|
||||
depends_on:
|
||||
web:
|
||||
condition: service_healthy
|
||||
@@ -47,4 +60,4 @@ services:
|
||||
KRATOS_PUBLIC_URL: http://kratos:4433
|
||||
command: ["npx", "playwright", "test", "auth-refresh.spec.ts"]
|
||||
volumes:
|
||||
- ./e2e/artifacts:/e2e/artifacts
|
||||
- ./e2e-tests/artifacts:/e2e-tests/artifacts
|
||||
@@ -0,0 +1,48 @@
|
||||
# Dev-stack login regression — guards the from-scratch experience the banner advertises (login from
|
||||
# http://localhost:3000 works, and entering on 127.0.0.1 is canonicalised). Unlike the proxied
|
||||
# full-flow suite (which fronts web + Kratos on ONE origin and so can't see this class of bug), this
|
||||
# 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 --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:
|
||||
# Pin APP_URL so the regression is deterministic regardless of any APP_URL exported in the shell
|
||||
# running ci.sh (overrides the dev override's ${APP_URL:-…}); the runner's BASE_URL matches it.
|
||||
environment:
|
||||
APP_URL: http://localhost:3000
|
||||
# Base web has no healthcheck; add one so the runner waits for a ready app (deps come via base).
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
|
||||
interval: 2s
|
||||
timeout: 4s
|
||||
retries: 30
|
||||
|
||||
# Pin Kratos' browser URLs to localhost too (literal, not ${APP_URL}) so the whole suite is
|
||||
# hermetic — the 127.0.0.1 sub-test asserts canonicalisation onto localhost, which only holds if
|
||||
# web AND Kratos agree on localhost regardless of the ambient shell env.
|
||||
kratos:
|
||||
environment:
|
||||
SERVE_PUBLIC_BASE_URL: http://localhost:4433/
|
||||
SELFSERVICE_DEFAULT_BROWSER_RETURN_URL: http://localhost:3000/
|
||||
SELFSERVICE_ALLOWED_RETURN_URLS: http://localhost:3000
|
||||
SELFSERVICE_FLOWS_ERROR_UI_URL: http://localhost:3000/error
|
||||
SELFSERVICE_FLOWS_LOGIN_UI_URL: http://localhost:3000/login
|
||||
SELFSERVICE_FLOWS_LOGIN_AFTER_DEFAULT_BROWSER_RETURN_URL: http://localhost:3000/auth/complete
|
||||
|
||||
e2e:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: e2e-tests/Dockerfile
|
||||
command: ["npx", "playwright", "test", "devstack-login.spec.ts"]
|
||||
# Host network: reach the host-published ports (web 3000, Kratos public 4433) at the very
|
||||
# hostnames a user types — localhost / 127.0.0.1 — so the cross-host CSRF-cookie split reproduces.
|
||||
network_mode: "host"
|
||||
depends_on:
|
||||
web:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BASE_URL: http://localhost:3000
|
||||
volumes:
|
||||
- ./e2e-tests/artifacts:/e2e-tests/artifacts
|
||||
@@ -1,24 +1,20 @@
|
||||
# Full browser E2E (todo §8) — the real Playwright UI flow against the live stack: password +
|
||||
# mocked-SSO login, menu filtering by role, users/groups/roles CRUD, a plugin page, logout. A tiny
|
||||
# same-origin gateway (proxy, e2e/proxy.mjs) fronts web + Kratos on one host so the browser's cookies
|
||||
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
||||
# 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 compose.e2e-full.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f compose.e2e-full.yml down -v # tear down after
|
||||
# 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:
|
||||
# First-party + SSO flows need Kratos + Keto + bootstrap, not Hydra — drop it so the stack is
|
||||
# leaner. SSO is enabled here only (clean clone stays password-only): the mock provider's whole
|
||||
# array is the env-settable form Kratos offers, mapped through the committed claims jsonnet.
|
||||
depends_on: !override
|
||||
bootstrap:
|
||||
condition: service_completed_successfully
|
||||
kratos:
|
||||
condition: service_healthy
|
||||
keto:
|
||||
condition: service_healthy
|
||||
# The base's full depends_on applies (Hydra included — the admin plugin's OAuth2-clients
|
||||
# screen needs it); only the reference plugin's upstream is added. SSO is enabled here only
|
||||
# (clean clone stays password-only): the mock provider's whole array is the env-settable form
|
||||
# Kratos offers, mapped through the committed claims jsonnet.
|
||||
depends_on:
|
||||
shifts-upstream:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
APP_URL: http://proxy # the browser reaches web through the same-origin gateway → canonical-host redirect stays inert
|
||||
CACHE_TEMPLATES: "true"
|
||||
REQUIRE_SECURE_SECRETS: "false"
|
||||
SECURE_COOKIES: "false" # the browser hits the gateway over http — Secure cookies wouldn't be stored
|
||||
@@ -27,6 +23,19 @@ services:
|
||||
interval: 2s
|
||||
timeout: 4s
|
||||
retries: 30
|
||||
# plugins/ is empty in the image; bind the example plugins in so the browser flow can open the
|
||||
# gated /scheduling/shifts page and the /admin/* screens (the admin screens ship as a drop-in
|
||||
# plugin, mounted at /app/plugins/admin, reaching the host's Ory clients via ctx.system).
|
||||
volumes:
|
||||
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
||||
- ./examples/plugins/admin:/app/plugins/admin:ro
|
||||
|
||||
# bootstrap grants the demo admin every discovered plugin's permission names, so it needs the
|
||||
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
|
||||
bootstrap:
|
||||
volumes:
|
||||
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
||||
- ./examples/plugins/admin:/app/plugins/admin:ro
|
||||
|
||||
# Browser-facing URLs (base_url, every ui_url, the after-login redirect) move to the gateway host.
|
||||
# `--dev`: the browser hits the gateway over http, but Kratos marks cookies Secure for a
|
||||
@@ -38,12 +47,16 @@ services:
|
||||
SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS: >-
|
||||
[{"id":"mock","provider":"generic","label":"Mock SSO","client_id":"plainpages-e2e","client_secret":"e2e-secret","issuer_url":"http://mock-oidc:9000","scope":["openid","email"],"mapper_url":"file:///etc/config/kratos/oidc/claims.jsonnet"}]
|
||||
|
||||
# --dev permits the http issuer (the base file drops it for an https prod issuer).
|
||||
hydra:
|
||||
command: serve all --dev -c /etc/config/hydra/hydra.yml
|
||||
|
||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||
shifts-upstream:
|
||||
image: node:24.16.0-alpine3.24
|
||||
command: ["node", "/server.mjs"]
|
||||
image: node:24.19.0-alpine3.24
|
||||
command: ["node", "/server.ts"]
|
||||
volumes:
|
||||
- ./examples/shifts-upstream/server.mjs:/server.mjs:ro
|
||||
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:4000/shifts"]
|
||||
interval: 2s
|
||||
@@ -53,23 +66,23 @@ 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.16.0-alpine3.24
|
||||
command: ["node", "/mock-oidc.mjs"]
|
||||
image: node:24.19.0-alpine3.24
|
||||
command: ["node", "/mock-oidc.ts"]
|
||||
environment:
|
||||
ISSUER: http://mock-oidc:9000
|
||||
SSO_EMAIL: sso-user@plainpages.local
|
||||
volumes:
|
||||
- ./e2e/mock-oidc.mjs:/mock-oidc.mjs:ro
|
||||
- ./e2e-tests/mock-oidc.ts:/mock-oidc.ts:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9000/.well-known/openid-configuration"]
|
||||
interval: 2s
|
||||
timeout: 4s
|
||||
retries: 15
|
||||
|
||||
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e/proxy.mjs).
|
||||
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
||||
proxy:
|
||||
image: node:24.16.0-alpine3.24
|
||||
command: ["node", "/proxy.mjs"]
|
||||
image: node:24.19.0-alpine3.24
|
||||
command: ["node", "/proxy.ts"]
|
||||
depends_on:
|
||||
web:
|
||||
condition: service_healthy
|
||||
@@ -77,7 +90,7 @@ services:
|
||||
KRATOS_URL: http://kratos:4433
|
||||
WEB_URL: http://web:3000
|
||||
volumes:
|
||||
- ./e2e/proxy.mjs:/proxy.mjs:ro
|
||||
- ./e2e-tests/proxy.ts:/proxy.ts:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost/public/css/styles.css"]
|
||||
interval: 2s
|
||||
@@ -87,7 +100,7 @@ services:
|
||||
e2e:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.e2e
|
||||
dockerfile: e2e-tests/Dockerfile
|
||||
command: ["npx", "playwright", "test", "full-flow.spec.ts"]
|
||||
depends_on:
|
||||
mock-oidc:
|
||||
@@ -98,4 +111,4 @@ services:
|
||||
BASE_URL: http://proxy
|
||||
KRATOS_ADMIN_URL: http://kratos:4434
|
||||
volumes:
|
||||
- ./e2e/artifacts:/e2e/artifacts
|
||||
- ./e2e-tests/artifacts:/e2e-tests/artifacts
|
||||
@@ -1,14 +1,15 @@
|
||||
# Full-stack OAuth2 E2E — the §6 login-challenge handler. Another app logs in *through* us:
|
||||
# Full-stack OAuth2 E2E — the login-challenge handler. Another app logs in *through* us:
|
||||
# Hydra starts an authorization flow and hands the browser to web's /oauth2/login; web resolves
|
||||
# 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 compose.e2e-oauth.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f compose.e2e-oauth.yml down -v # tear down after
|
||||
# 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:
|
||||
# Dev throwaways are fine for the test stack; the runner hits web over http.
|
||||
environment:
|
||||
APP_URL: http://web:3000 # the runner/browser reach web on this host → canonical-host redirect stays inert
|
||||
CACHE_TEMPLATES: "true"
|
||||
REQUIRE_SECURE_SECRETS: "false"
|
||||
SECURE_COOKIES: "false"
|
||||
@@ -31,7 +32,7 @@ services:
|
||||
e2e:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.e2e
|
||||
dockerfile: e2e-tests/Dockerfile
|
||||
depends_on:
|
||||
web:
|
||||
condition: service_healthy
|
||||
@@ -42,4 +43,4 @@ services:
|
||||
KRATOS_PUBLIC_URL: http://kratos:4433
|
||||
command: ["npx", "playwright", "test", "oauth-login.spec.ts"]
|
||||
volumes:
|
||||
- ./e2e/artifacts:/e2e/artifacts
|
||||
- ./e2e-tests/artifacts:/e2e-tests/artifacts
|
||||
@@ -0,0 +1,45 @@
|
||||
# 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, 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).
|
||||
services:
|
||||
web:
|
||||
# The dashboard renders mock data — no Ory needed. Drop the base file's kratos/keto
|
||||
# dependency so the visual suite stays fast and doesn't boot Postgres + the Ory stack.
|
||||
depends_on: !reset []
|
||||
# Dev throwaways are fine for tests; cache templates for production-like rendering.
|
||||
environment:
|
||||
APP_URL: http://web:3000 # the suite reaches web on this host → canonical-host redirect stays inert
|
||||
CACHE_TEMPLATES: "true"
|
||||
REQUIRE_SECURE_SECRETS: "false"
|
||||
SECURE_COOKIES: "false" # the suite hits web over http — Secure cookies wouldn't be stored
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
|
||||
interval: 2s
|
||||
timeout: 4s
|
||||
retries: 15
|
||||
# plugins/ is empty in the image; bind the reference example in as the `scheduling` plugin so the
|
||||
# nav-gating spec has a drop-in plugin to assert against.
|
||||
volumes:
|
||||
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
||||
|
||||
e2e:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: e2e-tests/Dockerfile
|
||||
# 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
|
||||
environment:
|
||||
BASE_URL: http://web:3000
|
||||
volumes:
|
||||
# The committed dev tokenizer key — the spec signs a session JWT with it so the gated
|
||||
# dashboard renders; web verifies it with the same key (the file it mounts read-only).
|
||||
- ./ory/kratos/tokenizer/jwks.json:/repo/jwks.json:ro
|
||||
- ./e2e-tests/artifacts:/e2e-tests/artifacts
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "./console-guard.ts";
|
||||
|
||||
// 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 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";
|
||||
|
||||
async function signIn(page: import("@playwright/test").Page): Promise<void> {
|
||||
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
|
||||
await page.fill('input[name="password"]', ADMIN_PASSWORD);
|
||||
await page.locator('.auth-form button[type="submit"]').click();
|
||||
}
|
||||
|
||||
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 the landing's "Sign in" action.
|
||||
await page.goto("/");
|
||||
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.
|
||||
await expect(page).not.toHaveURL(/\/error(\?|$)/);
|
||||
await expect(page.locator("h1"), 'must not land on the "Page not found" 404 view').not.toHaveText("Page not found");
|
||||
await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL);
|
||||
});
|
||||
|
||||
test("entering on the wrong host (http://127.0.0.1:3000) is canonicalised to APP_URL and login still works", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
// The exact trigger from the bug report: a user types 127.0.0.1 instead of the advertised localhost.
|
||||
// The canonical-host redirect sends them to localhost before the flow starts, so the CSRF cookie
|
||||
// and the cross-origin Kratos POST share one host and login succeeds.
|
||||
await page.goto("http://127.0.0.1:3000/login");
|
||||
await expect(page).toHaveURL(/^http:\/\/localhost:3000\//); // 308'd onto the canonical host
|
||||
await signIn(page);
|
||||
|
||||
await expect(page).not.toHaveURL(/\/error(\?|$)/);
|
||||
await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL);
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
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
|
||||
// gateway (e2e-tests/compose.full.yml) — the browser-UI login the earlier full-stack suites deferred here.
|
||||
// Coverage is the test titles below, plus the standalone SSO test.
|
||||
//
|
||||
// Runs on a fresh stack (`down -v` after, like the other full-stack suites). The serial admin
|
||||
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
|
||||
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
|
||||
// (no cross-group shared backend writes) or serialise the file if that ever changes.
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin permission in Keto
|
||||
const ADMIN_PASSWORD = "admin";
|
||||
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
|
||||
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
|
||||
|
||||
// Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in.
|
||||
async function loginPassword(page: Page): Promise<void> {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByRole("link", { name: "Forgot password?" })).toBeVisible(); // a path to password reset
|
||||
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
|
||||
await page.fill('input[name="password"]', ADMIN_PASSWORD);
|
||||
await page.locator('.auth-form button[type="submit"]').click();
|
||||
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 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 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);
|
||||
});
|
||||
|
||||
test("users CRUD: create a user, see it listed, then delete it via the confirm step", async () => {
|
||||
const email = `e2e-${suffix}@plainpages.local`;
|
||||
await page.goto("/admin/users/new");
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.fill('input[name="first"]', "E2E");
|
||||
await page.fill('input[name="last"]', "User");
|
||||
await page.locator('.form-card button[type="submit"]').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/); // PRG back to the list
|
||||
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`);
|
||||
await page.getByRole("button", { name: "Delete user" }).click(); // the confirm form's danger button
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
|
||||
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
||||
});
|
||||
|
||||
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}`;
|
||||
await page.goto("/admin/groups/new");
|
||||
await page.fill('input[name="name"]', group);
|
||||
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
||||
await page.locator('.form-card button[type="submit"]').click();
|
||||
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
|
||||
await expect(page.locator("main")).toContainText(group);
|
||||
|
||||
// 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 () => {
|
||||
const name = `e2e-client-${suffix}`;
|
||||
await page.goto("/admin/clients");
|
||||
await page.getByRole("link", { name: "Register client" }).click();
|
||||
await page.fill('input[name="name"]', name);
|
||||
await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback");
|
||||
await page.locator('.form-card button[type="submit"]').click();
|
||||
|
||||
// Hydra returns the secret exactly once, so the POST renders the detail directly (no PRG).
|
||||
await expect(page.locator("h1")).toHaveText("Client registered");
|
||||
const clientId = await page.locator("#cid").inputValue();
|
||||
expect(clientId).toBeTruthy();
|
||||
await expect(page.locator("#csecret")).toHaveValue(/.+/);
|
||||
|
||||
// Listed; the row header links to the plain detail, which never shows the secret again.
|
||||
await page.goto("/admin/clients");
|
||||
const row = page.locator("tr", { hasText: name });
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole("link", { name }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/admin/clients/${clientId}`));
|
||||
await expect(page.locator("#csecret")).toHaveCount(0);
|
||||
|
||||
// Delete through the confirm interstitial (danger link on the detail → confirm form's button).
|
||||
await page.getByRole("link", { name: "Delete client" }).click();
|
||||
await page.getByRole("button", { name: "Delete client" }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/clients(\?|$)/);
|
||||
await expect(page.locator("tr", { hasText: name })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("plugin page: the reference plugin renders its upstream shifts inside the native shell", async () => {
|
||||
await page.goto("/scheduling/shifts");
|
||||
await expect(page.locator("h1")).toHaveText("Shifts");
|
||||
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("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).
|
||||
await page.goto("/dashboard");
|
||||
await expect(page).toHaveURL(/\/login(\?|$)/);
|
||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("return_to: a deep link while logged out returns to that page after login", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
|
||||
// flow server-side, so it's consumed, not shown in the settled URL).
|
||||
await page.goto("/admin/users");
|
||||
await expect(page).toHaveURL(/\/login(\?|$)/);
|
||||
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
|
||||
await page.fill('input[name="password"]', ADMIN_PASSWORD);
|
||||
await page.locator('.auth-form button[type="submit"]').click();
|
||||
// Completion routes through /auth/complete (mints the JWT) and on to the requested page, not the dashboard.
|
||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
|
||||
await expect(page.locator("h1")).toHaveText("Users");
|
||||
});
|
||||
|
||||
test("mocked SSO login: the provider button signs a user in via OIDC", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto("/login");
|
||||
await expect(page.locator(".sso-btn")).toBeVisible(); // the configured provider renders a button
|
||||
await page.locator(".sso-btn").click();
|
||||
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
|
||||
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
|
||||
// A fresh SSO identity holds no permissions, so the gated Admin section stays hidden.
|
||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
||||
});
|
||||
@@ -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 @@
|
||||
// Mock OIDC provider for the SSO browser E2E (todo §8) — a stand-in for Google/etc. so the test
|
||||
// Mock OIDC provider for the SSO browser E2E — a stand-in for Google/etc. so the test
|
||||
// never leaves the compose network. Auto-approves /authorize (no provider login UI), then signs an
|
||||
// RS256 id_token Kratos verifies against /jwks. stdlib only, in-memory, NOT app code. The single
|
||||
// host (mock-oidc:9000) is reachable by both the browser (/authorize) and Kratos (token/jwks).
|
||||
@@ -1,16 +1,16 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./console-guard.ts";
|
||||
|
||||
// Full-stack OAuth2 login + consent E2E (§6): another app logs in *through* plainpages. Hydra
|
||||
// Full-stack OAuth2 login + consent E2E: another app logs in *through* plainpages. Hydra
|
||||
// starts an authorization flow and hands the browser to web's /oauth2/login; web resolves it via
|
||||
// the Kratos session and accepts, Hydra continues to web's /oauth2/consent, web shows the themed
|
||||
// consent screen, and Allow drives Hydra to issue the authorization code. We drive the flow over
|
||||
// HTTP (fetch, per-host cookie jars) because the browser hosts differ on the compose network; this
|
||||
// exercises web's server-side challenge handling. The browser-UI login is owned by §8.
|
||||
// exercises web's server-side challenge handling. The browser-UI login is owned by the full-flow E2E (full-flow.spec.ts).
|
||||
const WEB = process.env.BASE_URL ?? "http://web:3000";
|
||||
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
||||
const HYDRA_PUBLIC = process.env.HYDRA_PUBLIC_URL ?? "http://hydra:4444";
|
||||
const HYDRA_ADMIN = process.env.HYDRA_ADMIN_URL ?? "http://hydra:4445";
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3)
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
|
||||
const ADMIN_PASSWORD = "admin";
|
||||
|
||||
function setCookieLine(res: Response, name: string): string | undefined {
|
||||
+15
-17
@@ -1,30 +1,28 @@
|
||||
{
|
||||
"name": "plainpages-e2e",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "plainpages-e2e",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.49.1"
|
||||
"@playwright/test": "1.62.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
|
||||
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.49.1"
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
@@ -43,35 +41,35 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
|
||||
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.49.1"
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
|
||||
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -8,6 +7,6 @@
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.49.1"
|
||||
"@playwright/test": "1.62.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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",
|
||||
fullyParallel: true,
|
||||
forbidOnly: true,
|
||||
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL ?? "http://localhost:3000",
|
||||
colorScheme: "light",
|
||||
screenshot: "only-on-failure",
|
||||
viewport: { width: 1280, height: 800 },
|
||||
},
|
||||
// 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"] } },
|
||||
],
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Same-origin gateway for the browser E2E (todo §8). The themed login form posts straight to
|
||||
// Same-origin gateway for the browser E2E. The themed login form posts straight to
|
||||
// Kratos' flow action and Kratos sets the session cookie for its own base_url host — so for a real
|
||||
// browser, web and Kratos must look like ONE origin (cookies are host-scoped). This tiny stdlib
|
||||
// reverse proxy fronts both on a single host (the browser's only origin), exactly as a production
|
||||
@@ -1,48 +1,40 @@
|
||||
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";
|
||||
|
||||
// The mockups are bind-mounted at /repo (sibling to /repo/public so their ../public/css/ resolves).
|
||||
const MOCKUP = "file:///repo/html-css-foundation";
|
||||
const APP_SHELL = `${MOCKUP}/App%20Shell.html`;
|
||||
const AUTH = `${MOCKUP}/Auth.html`;
|
||||
const SHOTS = "artifacts/screenshots";
|
||||
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
|
||||
const SESSION_COOKIE = "plainpages_jwt"; // src/login.ts — web verifies it against the committed dev JWKS
|
||||
const SESSION_COOKIE = "plainpages_jwt"; // src/auth/login.ts — web verifies it against the committed dev JWKS
|
||||
|
||||
// Per engine: the three projects run this suite in parallel and would otherwise write one file.
|
||||
const shot = (page: Page, name: string): Promise<Buffer> =>
|
||||
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 (§10) renders for a "signed-in" user without standing up Ory — web verifies it
|
||||
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
|
||||
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
|
||||
function devSession(roles: string[] = []): string {
|
||||
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, roles, sub: "visual-demo" })}`;
|
||||
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "visual-demo" })}`;
|
||||
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
|
||||
}
|
||||
|
||||
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
|
||||
|
||||
// The dashboard is gated (§10): a page navigation needs a session. Plant one per test — a plain
|
||||
// member (no roles) so the gated scheduling/admin nav stays filtered out, matching the mockup.
|
||||
// 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 }) => {
|
||||
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
||||
});
|
||||
|
||||
test("captures live pages + reference mockups for side-by-side review", async ({ page }) => {
|
||||
test("captures the live pages for review", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
await expect(page.locator("table.table tbody tr").first()).toBeVisible();
|
||||
// the default /dashboard is the instructional starter, not a mock-data list.
|
||||
await expect(page.getByRole("heading", { name: "Starter dashboard" })).toBeVisible();
|
||||
await shot(page, "live-01-dashboard");
|
||||
|
||||
await page.goto("/dashboard?sort=-name&status=active");
|
||||
await shot(page, "live-02-sorted-filtered");
|
||||
|
||||
await page.goto("/dashboard");
|
||||
await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio
|
||||
await shot(page, "live-03-dark");
|
||||
@@ -51,31 +43,6 @@ test("captures live pages + reference mockups for side-by-side review", async ({
|
||||
await page.goto("/dashboard");
|
||||
await shot(page, "live-04-mobile");
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
|
||||
await page.goto(APP_SHELL);
|
||||
await shot(page, "mockup-01-app-shell");
|
||||
await page.goto(AUTH);
|
||||
await shot(page, "mockup-02-auth");
|
||||
});
|
||||
|
||||
// The live DOM reuses the foundation's classes, so the same styles.css must compute identically
|
||||
// on both — proof we render the intended graphics, independent of the (different) row data.
|
||||
const PROPS = ["backgroundColor", "borderRadius", "borderTopColor", "color", "fontSize", "fontWeight"] as const;
|
||||
const styleOf = (page: Page, selector: string): Promise<Record<string, string>> =>
|
||||
page.locator(selector).first().evaluate((el, props) => {
|
||||
const cs = getComputedStyle(el as Element);
|
||||
return Object.fromEntries(props.map((p) => [p, cs.getPropertyValue(p) || (cs as unknown as Record<string, string>)[p]]));
|
||||
}, PROPS as unknown as string[]);
|
||||
|
||||
test("live components compute the same design-system styles as the reference mockup", async ({ page, context }) => {
|
||||
await page.goto("/dashboard");
|
||||
const ref = await context.newPage();
|
||||
await ref.goto(APP_SHELL);
|
||||
|
||||
for (const selector of [".sidebar", ".topbar", ".brand", ".btn.btn-primary", ".theme-switch", ".filters", ".pager"]) {
|
||||
expect(await styleOf(page, selector), `computed style mismatch for ${selector}`).toEqual(await styleOf(ref, selector));
|
||||
}
|
||||
await ref.close();
|
||||
});
|
||||
|
||||
test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => {
|
||||
@@ -89,20 +56,8 @@ test("every icon <use> resolves to a defined <symbol> (no broken graphics)", asy
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test("sorting and search drive the list through the URL (zero-JS)", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
const total = await page.locator("tbody tr").count();
|
||||
|
||||
await page.getByRole("link", { name: /Name/ }).first().click();
|
||||
await expect(page).toHaveURL(/sort=name/);
|
||||
await expect(page.locator("thead th").filter({ hasText: "Name" })).toHaveAttribute("aria-sort", "ascending");
|
||||
|
||||
await page.goto("/dashboard");
|
||||
await page.locator('input[name="q"]').fill("Avery");
|
||||
await page.getByRole("button", { name: /Apply filters/ }).click();
|
||||
await expect(page).toHaveURL(/q=Avery/);
|
||||
expect(await page.locator("tbody tr").count()).toBeLessThan(total);
|
||||
});
|
||||
// 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");
|
||||
@@ -112,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");
|
||||
@@ -137,17 +121,21 @@ test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a t
|
||||
expect(res.status()).toBe(403);
|
||||
});
|
||||
|
||||
test("the public landing at / is ungated and links to sign in + register (§10)", async ({ page, context }) => {
|
||||
test("the public landing at / is ungated and links to sign in + register", async ({ page, context }) => {
|
||||
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
|
||||
await page.goto("/");
|
||||
await expect(page.locator(".landing")).toBeVisible(); // the standalone landing, not the app shell
|
||||
await expect(page.locator(".sidebar")).toHaveCount(0);
|
||||
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(".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.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();
|
||||
@@ -156,9 +144,9 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
|
||||
|
||||
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
|
||||
// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
|
||||
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the §8 full
|
||||
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
|
||||
// E2E (full-flow.spec). Side-effect-free.
|
||||
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login (§10)", async ({ page, request }) => {
|
||||
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
|
||||
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
|
||||
// probes are genuinely anonymous.
|
||||
// The public overview is reachable with no session (200), not bounced to sign in.
|
||||
@@ -166,21 +154,21 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
|
||||
expect(pub.status()).toBe(200);
|
||||
const body = await pub.text();
|
||||
expect(body).toContain("Scheduling");
|
||||
// Anonymous in the native shell (§10): the gated Dashboard link is hidden (it would only dead-end at
|
||||
// Anonymous in the native shell: the gated Dashboard link is hidden (it would only dead-end at
|
||||
// /login), and the shell's Sign-in link carries the current page as return_to.
|
||||
expect(body).not.toContain('href="/dashboard"');
|
||||
expect(body).toContain('href="/login?return_to=%2Fscheduling"');
|
||||
|
||||
// The gated shifts list still bounces (don't follow — this Ory-free suite has no /login handler);
|
||||
// assert the gate's 303 with the requested page preserved as return_to (§9).
|
||||
// assert the gate's 303 with the requested page preserved as return_to.
|
||||
const res = await request.get("/scheduling/shifts", { maxRedirects: 0 });
|
||||
expect(res.status()).toBe(303);
|
||||
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
||||
|
||||
// The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav,
|
||||
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
|
||||
// but the gated Shifts leaf is filtered out.
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.locator(".sidebar")).toContainText("People"); // dashboard nav renders
|
||||
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
||||
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
|
||||
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import { type Browser, type Page, expect, test } from "@playwright/test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// Full browser E2E (todo §8): the real Playwright UI against the live stack via the same-origin
|
||||
// gateway (compose.e2e-full.yml) — the browser-UI login the earlier full-stack suites deferred here.
|
||||
// Coverage is the test titles below, plus the standalone SSO test.
|
||||
//
|
||||
// Runs on a fresh stack (`down -v` after, like the other full-stack suites). The serial admin
|
||||
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
|
||||
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
|
||||
// (no cross-group shared backend writes) or serialise the file if that ever changes.
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3), holds the admin role in Keto
|
||||
const ADMIN_PASSWORD = "admin";
|
||||
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
|
||||
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
|
||||
|
||||
// Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in.
|
||||
async function loginPassword(page: Page): Promise<void> {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByRole("link", { name: "Forgot password?" })).toBeVisible(); // a path to password reset
|
||||
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
|
||||
await page.fill('input[name="password"]', ADMIN_PASSWORD);
|
||||
await page.locator('.auth-form button[type="submit"]').click();
|
||||
await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL); // waits through the redirect chain
|
||||
}
|
||||
|
||||
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();
|
||||
test.setTimeout(90_000);
|
||||
await loginPassword(page);
|
||||
});
|
||||
test.afterAll(async () => { await page.context().close(); });
|
||||
|
||||
test("menu filters by role: 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).
|
||||
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);
|
||||
});
|
||||
|
||||
test("users CRUD: create a user, see it listed, then delete it via the confirm step", async () => {
|
||||
const email = `e2e-${suffix}@plainpages.local`;
|
||||
await page.goto("/admin/users/new");
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.fill('input[name="first"]', "E2E");
|
||||
await page.fill('input[name="last"]', "User");
|
||||
await page.locator('.form-card button[type="submit"]').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/); // PRG back to the list
|
||||
const row = page.locator("tr", { hasText: email });
|
||||
await expect(row).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`);
|
||||
await page.getByRole("button", { name: "Delete user" }).click(); // the confirm form's danger button
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
|
||||
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("groups + roles CRUD: create one of each (writes go to Keto) and see them listed", async () => {
|
||||
// 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}`;
|
||||
await page.goto("/admin/groups/new");
|
||||
await page.fill('input[name="name"]', group);
|
||||
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
||||
await page.locator('.form-card button[type="submit"]').click();
|
||||
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
|
||||
await expect(page.locator("main")).toContainText(group);
|
||||
|
||||
const role = `e2e-role-${suffix}`;
|
||||
await page.goto("/admin/roles/new");
|
||||
await page.fill('input[name="name"]', role);
|
||||
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
||||
await page.locator('.form-card button[type="submit"]').click();
|
||||
await expect(page).toHaveURL(/\/admin\/roles(\?|\/|$)/);
|
||||
await expect(page.locator("main")).toContainText(role);
|
||||
});
|
||||
|
||||
test("plugin page: the reference plugin renders its upstream shifts inside the native shell", async () => {
|
||||
await page.goto("/scheduling/shifts");
|
||||
await expect(page.locator("h1")).toHaveText("Shifts");
|
||||
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
|
||||
});
|
||||
|
||||
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('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).
|
||||
await page.goto("/dashboard");
|
||||
await expect(page).toHaveURL(/\/login(\?|$)/);
|
||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("return_to: a deep link while logged out returns to that page after login (§9)", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
|
||||
// flow server-side, so it's consumed, not shown in the settled URL).
|
||||
await page.goto("/admin/users");
|
||||
await expect(page).toHaveURL(/\/login(\?|$)/);
|
||||
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
|
||||
await page.fill('input[name="password"]', ADMIN_PASSWORD);
|
||||
await page.locator('.auth-form button[type="submit"]').click();
|
||||
// Completion routes through /auth/complete (mints the JWT) and on to the requested page, not the dashboard.
|
||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
|
||||
await expect(page.locator("h1")).toHaveText("Users");
|
||||
});
|
||||
|
||||
test("mocked SSO login: the provider button signs a user in via OIDC", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto("/login");
|
||||
await expect(page.locator(".sso-btn")).toBeVisible(); // the configured provider renders a button
|
||||
await page.locator(".sso-btn").click();
|
||||
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
|
||||
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
|
||||
// A fresh SSO identity holds no roles, so the gated Admin section stays hidden.
|
||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// Visual + functional checks against the live app (the `web` compose service, BASE_URL) and the
|
||||
// static html-css-foundation mockups (bind-mounted at /repo). Run via compose.e2e.yml. Parallel
|
||||
// per the project's E2E principle (todo §1.1); deterministic colorScheme/viewport so the
|
||||
// computed-style parity vs the reference design is stable.
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
outputDir: "artifacts/test-output",
|
||||
fullyParallel: true,
|
||||
forbidOnly: true,
|
||||
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL ?? "http://localhost:3000",
|
||||
colorScheme: "light",
|
||||
screenshot: "only-on-failure",
|
||||
viewport: { width: 1280, height: 800 },
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
# examples/
|
||||
|
||||
Copy-in reference material. Each subfolder mirrors a **drop-in mount dir** at the repo root — copy it
|
||||
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 `@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 `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. |
|
||||
@@ -1,22 +1,25 @@
|
||||
// Central menu override + branding (todo §2). 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/menu-config.ts (types), src/nav.ts (NavOverride), docs/plugin-contract.md.
|
||||
// Reference config/menu.ts — copy into the empty config/ mount at the repo root:
|
||||
// cp examples/config/menu.ts config/menu.ts
|
||||
// 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.
|
||||
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system.
|
||||
|
||||
import { defineMenu } from "../src/menu-config.ts";
|
||||
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", "roles"] }],
|
||||
// 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)
|
||||
},
|
||||
@@ -0,0 +1,65 @@
|
||||
# Admin — the system-administration plugin
|
||||
|
||||
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 up -d
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Most plugins fetch their data from an upstream service of their own (see the [scheduling
|
||||
reference](../scheduling/README.md)). The admin screens instead administer **Plainpages' own identity
|
||||
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 (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. 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 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
|
||||
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 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`.
|
||||
@@ -1,4 +1,4 @@
|
||||
// Built-in OAuth2 clients admin screen (§6): the pure view-model + Hydra-payload builders. A client
|
||||
// Built-in OAuth2 clients admin screen: the pure view-model + Hydra-payload builders. A client
|
||||
// is an Ory Hydra OAuth2 client (apps that log in *through* us); writes go only to Hydra. The
|
||||
// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in app.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
@@ -62,7 +62,7 @@ test("buildClientsListModel filters by search, paginates; the name links to the
|
||||
const all = buildClientsListModel({ clients, url: "http://x/admin/clients" });
|
||||
assert.equal(all.pagination.summary.total, 30);
|
||||
assert.equal(all.table.rows.length, 25); // default page size
|
||||
assert.equal(all.shell.title, "OAuth2 clients");
|
||||
assert.equal(all.title, "OAuth2 clients");
|
||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||
assert.equal(first.rowHeader.text, "app-00");
|
||||
assert.equal(first.rowHeader.href, "/admin/clients/id-00");
|
||||
@@ -74,7 +74,7 @@ test("buildClientsListModel filters by search, paginates; the name links to the
|
||||
|
||||
test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => {
|
||||
const m = buildClientFormModel({ csrfToken: "tok.sig" });
|
||||
assert.equal(m.shell.title, "Register client");
|
||||
assert.equal(m.title, "Register client");
|
||||
assert.equal(m.form.action, "/admin/clients");
|
||||
assert.equal(m.form.submitLabel, "Register client");
|
||||
assert.equal(m.form.csrfToken, "tok.sig");
|
||||
@@ -93,7 +93,7 @@ test("buildClientDetailModel: client info + delete action; the one-time secret +
|
||||
const client = toClientView({ client_id: "c1", client_name: "Acme", redirect_uris: ["https://a/cb"], scope: "openid", token_endpoint_auth_method: "client_secret_basic" });
|
||||
|
||||
const plain = buildClientDetailModel({ client });
|
||||
assert.equal(plain.shell.title, "Acme");
|
||||
assert.equal(plain.title, "Acme");
|
||||
assert.equal(plain.delete.action, "/admin/clients/c1/delete");
|
||||
assert.equal(plain.created, false);
|
||||
assert.equal(plain.secret, undefined);
|
||||
@@ -101,5 +101,5 @@ test("buildClientDetailModel: client info + delete action; the one-time secret +
|
||||
const fresh = buildClientDetailModel({ client, created: true, secret: "s3cr3t" });
|
||||
assert.equal(fresh.created, true);
|
||||
assert.equal(fresh.secret, "s3cr3t");
|
||||
assert.equal(fresh.shell.title, "Client registered");
|
||||
assert.equal(fresh.title, "Client registered");
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
// OAuth2 clients admin screen: register / list / delete the OAuth2 clients other
|
||||
// apps log in *through* us with (Ory Hydra, the login+consent handlers). A client is an Ory Hydra
|
||||
// OAuth2 client; writes go only to Hydra. Hydra returns the client_secret once, on create — so the
|
||||
// register POST renders the new client's detail page (with the one-time secret) directly instead of a
|
||||
// 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 { 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;
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
// One Hydra page is fetched and filtered/paged in memory — its list API has no search. Ample for an
|
||||
// admin tool (the OAuth2 clients of a deployment number in the dozens); raise if one outgrows it.
|
||||
const LIST_FETCH_SIZE = 250;
|
||||
const DEFAULT_SCOPE = "openid offline_access";
|
||||
|
||||
export interface ClientView {
|
||||
firstParty: boolean;
|
||||
id: string; // client_id
|
||||
name: string;
|
||||
public: boolean; // public (PKCE, no secret) vs confidential
|
||||
redirectUris: string[];
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export interface ClientInput {
|
||||
firstParty: boolean;
|
||||
name: string;
|
||||
public: boolean;
|
||||
redirectUris: string[];
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export function toClientView(client: OAuth2Client): ClientView {
|
||||
const id = client.client_id ?? "";
|
||||
return {
|
||||
firstParty: (client.metadata as { first_party?: unknown } | undefined)?.first_party === true,
|
||||
id,
|
||||
name: client.client_name?.trim() || id || "(unnamed)",
|
||||
public: client.token_endpoint_auth_method === "none",
|
||||
redirectUris: client.redirect_uris ?? [],
|
||||
scopes: (client.scope ?? "").split(/\s+/).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
// Split a textarea value into redirect URIs (one per line / whitespace / comma), dropping empties.
|
||||
export function parseRedirectUris(raw: string): string[] {
|
||||
return raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// Hydra's create body. We register a standard authorization-code web/native client (+ refresh);
|
||||
// the type (confidential vs public/PKCE) and auto-consent ride the auth method + metadata.
|
||||
export function clientPayload(input: ClientInput): Record<string, unknown> {
|
||||
return {
|
||||
client_name: input.name,
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
metadata: { first_party: input.firstParty },
|
||||
redirect_uris: input.redirectUris,
|
||||
response_types: ["code"],
|
||||
scope: input.scope,
|
||||
token_endpoint_auth_method: input.public ? "none" : "client_secret_basic",
|
||||
};
|
||||
}
|
||||
|
||||
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 t("admin.clients.validation.redirectUri", { uri });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- list view model ----
|
||||
|
||||
interface ListState {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
q: string;
|
||||
}
|
||||
|
||||
function detailHref(id: string): string {
|
||||
return `${ADMIN_CLIENTS_BASE}/${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
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.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_CLIENTS_BASE}?${qs}` : ADMIN_CLIENTS_BASE;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
const all = opts.clients.map(toClientView);
|
||||
const list = all.filter((c) => !needle || c.name.toLowerCase().includes(needle) || c.id.toLowerCase().includes(needle));
|
||||
|
||||
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 };
|
||||
|
||||
return {
|
||||
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[], t: Translate) {
|
||||
return {
|
||||
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 ? t("admin.clients.public") : t("admin.clients.confidential"), tone: c.public ? "warn" : "info" } },
|
||||
],
|
||||
name: c.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function listFilterBar(state: ListState, t: Translate) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
return {
|
||||
applyLabel: t("filter.apply"),
|
||||
clearHref: ADMIN_CLIENTS_BASE,
|
||||
label: t("admin.clients.filter"),
|
||||
pills,
|
||||
rows: [[
|
||||
{ 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>, t: Translate) {
|
||||
const hidden: { name: string; value: string }[] = [];
|
||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||
return {
|
||||
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: 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 },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- register form + detail view models ----
|
||||
|
||||
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: t("admin.clients.field.name"), name: "name", required: true, value: v?.name ?? "",
|
||||
};
|
||||
const scopeField: FieldConfig = {
|
||||
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: t("admin.clients.title") }, { label: t("admin.clients.register") }],
|
||||
error: opts.error,
|
||||
form: {
|
||||
action: ADMIN_CLIENTS_BASE,
|
||||
cancelHref: ADMIN_CLIENTS_BASE,
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
firstParty: v?.firstParty ?? false,
|
||||
nameField,
|
||||
public: v?.public ?? false,
|
||||
redirectUris: (v?.redirectUris ?? []).join("\n"),
|
||||
scopeField,
|
||||
submitLabel: t("admin.clients.registerClient"),
|
||||
},
|
||||
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: 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 ? t("admin.clients.created") : opts.client.name,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- request handler (imperative shell) ----
|
||||
|
||||
function readClientInput(form: URLSearchParams): ClientInput {
|
||||
return {
|
||||
firstParty: form.get("firstParty") === "on",
|
||||
name: (form.get("name") ?? "").trim(),
|
||||
public: form.get("public") === "on",
|
||||
redirectUris: parseRedirectUris(form.get("redirectUris") ?? ""),
|
||||
scope: (form.get("scope") ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Shared per-request deps for the OAuth2-clients screen, resolved by `withClients`: the gate + the
|
||||
// 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>, action?: AdminAction): RouteHandler {
|
||||
return async (ctx) => {
|
||||
const user = requirePermission(ctx, "oauth2-clients", action);
|
||||
const hydra = ctx.system?.hydra;
|
||||
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>, 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, 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({ 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({ 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, 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: 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 ?? "" });
|
||||
return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
|
||||
});
|
||||
|
||||
// GET /admin/clients/new — the register form.
|
||||
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)));
|
||||
|
||||
// GET /admin/clients/:id/delete — the deliberate confirm step.
|
||||
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: 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) => {
|
||||
await guardedForm(ctx); // CSRF-verify the POST
|
||||
await hydra.deleteClient(id);
|
||||
ctx.log.info("admin: oauth2 client deleted", { actor: user.id, client: id });
|
||||
return { redirect: ADMIN_CLIENTS_BASE };
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Built-in Groups admin screen (§5): the pure view-model + Keto-tuple builders. A group is a
|
||||
// Built-in Groups admin screen: the pure view-model + Keto-tuple builders. A group is a
|
||||
// Keto subject set (Group:<name>#members); membership tuples carry users (subject_id) or nested
|
||||
// groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over
|
||||
// HTTP in app.test.ts.
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
memberView,
|
||||
parseSubject,
|
||||
} from "./admin-groups.ts";
|
||||
import type { RelationTuple } from "./keto-client.ts";
|
||||
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 =>
|
||||
@@ -59,7 +59,7 @@ test("buildGroupsListModel filters by search, sorts, paginates; the name links t
|
||||
const all = buildGroupsListModel({ groups, url: "http://x/admin/groups" });
|
||||
assert.equal(all.pagination.summary.total, 30);
|
||||
assert.equal(all.table.rows.length, 25); // default page size
|
||||
assert.equal(all.shell.title, "Groups");
|
||||
assert.equal(all.title, "Groups");
|
||||
// The group name is the row header, linking to its detail page.
|
||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||
assert.equal(first.rowHeader.text, "team-00");
|
||||
@@ -78,7 +78,7 @@ test("buildGroupsListModel filters by search, sorts, paginates; the name links t
|
||||
test("buildGroupFormModel: a create form with a required name field + member options, no group of its own", () => {
|
||||
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||
assert.equal(m.shell.title, "New group");
|
||||
assert.equal(m.title, "New group");
|
||||
assert.equal(m.form.action, "/admin/groups");
|
||||
assert.equal(m.form.submitLabel, "Create group");
|
||||
assert.equal(m.form.csrfToken, "tok.sig");
|
||||
@@ -103,7 +103,7 @@ test("buildGroupDetailModel: members → rows, add-options exclude current membe
|
||||
{ label: "ops (group)", value: "group:ops" },
|
||||
];
|
||||
const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members });
|
||||
assert.equal(m.shell.title, "eng");
|
||||
assert.equal(m.title, "eng");
|
||||
assert.equal(m.members.rows.length, 2);
|
||||
assert.equal(m.members.action, "/admin/groups/eng/members/delete");
|
||||
assert.equal(m.add.action, "/admin/groups/eng/members");
|
||||
@@ -0,0 +1,421 @@
|
||||
// Groups admin screen: list / create / delete Keto groups and manage membership.
|
||||
// A group is a Keto subject set `Group:<name>#members`; a member is a user or a nested group (see
|
||||
// parseSubject). Writes go only to Keto (README "stateless"). Keto has no "create object" — a group
|
||||
// exists exactly while it has ≥1 member, so create writes its first-member tuple and delete removes
|
||||
// every member tuple. Pure builders turn tuples + the request URL into view models; below them are thin
|
||||
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
||||
// each returning a RouteResult.
|
||||
|
||||
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";
|
||||
const MEMBERS = "members";
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
// One Keto page of candidate users is fetched for the member pickers (mirrors admin-users).
|
||||
const LIST_FETCH_SIZE = 250;
|
||||
const GROUP_NAME = /^[a-z0-9][a-z0-9_-]*$/; // URL-safe; doubles as the path segment
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // a Kratos identity id
|
||||
|
||||
export interface GroupView {
|
||||
memberCount: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// A member's view model: a user (label = email) or a nested group (label = group name). `subject`
|
||||
// is the form value that round-trips it — `user:<id>` or `group:<name>` (see parseSubject).
|
||||
export interface MemberView {
|
||||
kind: "group" | "user";
|
||||
label: string;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
// One option in a member <select>.
|
||||
export interface MemberOption {
|
||||
label: string;
|
||||
value: string; // `user:<id>` | `group:<name>`
|
||||
}
|
||||
|
||||
export function isValidGroupName(name: string): boolean {
|
||||
return name.length <= 64 && GROUP_NAME.test(name);
|
||||
}
|
||||
|
||||
// Map a member-picker value → the Keto subject (subject_id for a user, subject_set for a nested
|
||||
// group). Returns null for anything unrecognised (a crafted/empty POST).
|
||||
export function parseSubject(value: string): { subject_id: string } | { subject_set: SubjectSet } | null {
|
||||
const sep = value.indexOf(":");
|
||||
if (sep <= 0) return null;
|
||||
const rest = value.slice(sep + 1);
|
||||
if (!rest) return null;
|
||||
// Validate both subject forms so a crafted POST can't write a dangling tuple (the pickers only
|
||||
// ever offer real users/groups): a user id is a Kratos UUID, a nested group a valid group name.
|
||||
if (value.slice(0, sep) === "user") return UUID.test(rest) ? { subject_id: `user:${rest}` } : null;
|
||||
if (value.slice(0, sep) === "group") return isValidGroupName(rest) ? { subject_set: { namespace: GROUP_NS, object: rest, relation: MEMBERS } } : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// The full membership tuple for adding/removing `value` to/from `group` (null if value is invalid).
|
||||
export function memberTuple(group: string, value: string): RelationTuple | null {
|
||||
const subject = parseSubject(value);
|
||||
return subject ? { namespace: GROUP_NS, object: group, relation: MEMBERS, ...subject } : null;
|
||||
}
|
||||
|
||||
// Collapse the namespace's membership tuples → distinct groups + member counts, sorted by name.
|
||||
export function groupsFromTuples(tuples: RelationTuple[]): GroupView[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const t of tuples) counts.set(t.object, (counts.get(t.object) ?? 0) + 1);
|
||||
return [...counts].map(([name, memberCount]) => ({ memberCount, name })).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function memberView(tuple: RelationTuple, emailById: Map<string, string>): MemberView {
|
||||
if (tuple.subject_set) return { kind: "group", label: tuple.subject_set.object, subject: `group:${tuple.subject_set.object}` };
|
||||
const subjectId = tuple.subject_id ?? "";
|
||||
const id = subjectId.startsWith("user:") ? subjectId.slice("user:".length) : subjectId;
|
||||
return { kind: "user", label: emailById.get(id) ?? subjectId, subject: subjectId };
|
||||
}
|
||||
|
||||
// ---- list view model ----
|
||||
|
||||
interface ListState {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
q: string;
|
||||
sort: string | null;
|
||||
}
|
||||
|
||||
const SORT: Record<string, (g: GroupView) => number | string> = {
|
||||
members: (g) => g.memberCount,
|
||||
name: (g) => g.name,
|
||||
};
|
||||
const COLUMNS = [
|
||||
{ key: "name", label: "admin.groups.column.name" },
|
||||
{ key: "members", label: "admin.groups.column.members" },
|
||||
];
|
||||
|
||||
function detailHref(name: string): string {
|
||||
return `${ADMIN_GROUPS_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_GROUPS_BASE}?${qs}` : ADMIN_GROUPS_BASE;
|
||||
}
|
||||
|
||||
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;
|
||||
const needle = query.q.toLowerCase();
|
||||
|
||||
let list = opts.groups.filter((g) => !needle || g.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_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, t: Translate) {
|
||||
return {
|
||||
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: t(c.label), sort: dir, sortable: true };
|
||||
}),
|
||||
rows: rows.map((g) => ({
|
||||
cells: [{ rowHeader: { href: detailHref(g.name), text: g.name } }, String(g.memberCount)],
|
||||
name: g.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function listFilterBar(state: ListState, t: Translate) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
return {
|
||||
applyLabel: t("filter.apply"),
|
||||
clearHref: ADMIN_GROUPS_BASE,
|
||||
label: t("admin.groups.filter"),
|
||||
pills,
|
||||
rows: [[
|
||||
{ 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>, 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: 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: 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 },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- create form + detail view models ----
|
||||
|
||||
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: 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: t("admin.groups.title") }, { label: t("common.new") }],
|
||||
error: opts.error,
|
||||
form: {
|
||||
action: ADMIN_GROUPS_BASE,
|
||||
cancelHref: ADMIN_GROUPS_BASE,
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
memberOptions: opts.memberOptions,
|
||||
nameField,
|
||||
selectedMember: opts.values?.member ?? "",
|
||||
submitLabel: t("admin.groups.create"),
|
||||
},
|
||||
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: 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.
|
||||
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
|
||||
const out: RelationTuple[] = [];
|
||||
let pageToken: string | undefined;
|
||||
do {
|
||||
const page = await keto.listRelations({ ...query, ...(pageToken ? { pageToken } : {}) });
|
||||
out.push(...page.tuples);
|
||||
pageToken = page.nextPageToken ?? undefined;
|
||||
} while (pageToken);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Build the member-picker options (every user by email + every existing group) and the id→email map
|
||||
// detail rows render with. One Kratos page + one Keto scan; ample for an admin tool.
|
||||
export async function memberCandidates(keto: KetoClient, kratosAdmin: KratosAdmin): Promise<{ emailById: Map<string, string>; options: MemberOption[] }> {
|
||||
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
||||
const emailById = new Map<string, string>();
|
||||
const userOptions: MemberOption[] = [];
|
||||
for (const it of identities) {
|
||||
const trait = it.traits?.["email"];
|
||||
const email = typeof trait === "string" ? trait : it.id;
|
||||
emailById.set(it.id, email);
|
||||
userOptions.push({ label: email, value: `user:${it.id}` });
|
||||
}
|
||||
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
||||
return { emailById, options: [...userOptions, ...groups.map((g) => ({ label: `${g.name} (group)`, value: `group:${g.name}` }))] };
|
||||
}
|
||||
|
||||
// A group exists exactly while it has ≥1 member.
|
||||
async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
|
||||
const page = await keto.listRelations({ namespace: GROUP_NS, object: name, relation: MEMBERS, pageSize: 1 });
|
||||
return page.tuples.length > 0;
|
||||
}
|
||||
|
||||
// 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>, action?: AdminAction): RouteHandler {
|
||||
return async (ctx) => {
|
||||
const user = requirePermission(ctx, "groups", action);
|
||||
const keto = ctx.system?.keto;
|
||||
const kratosAdmin = ctx.system?.kratosAdmin;
|
||||
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>, 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, 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({ 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).
|
||||
export const groupsCreate = withGroups(async (deps) => {
|
||||
const { ctx, keto, user } = deps;
|
||||
const form = (await guardedForm(ctx))!;
|
||||
const name = (form.get("name") ?? "").trim();
|
||||
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(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 });
|
||||
return { redirect: detailHref(name) };
|
||||
});
|
||||
|
||||
// GET /admin/groups/new — the create form.
|
||||
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));
|
||||
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).
|
||||
export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
|
||||
const form = (await guardedForm(ctx))!;
|
||||
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
|
||||
if (tuple && tuple.subject_set?.object !== name) await keto.writeTuple(tuple);
|
||||
return { redirect: detailHref(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: 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, revoked: held.join(",") });
|
||||
return { redirect: ADMIN_GROUPS_BASE };
|
||||
});
|
||||
|
||||
// POST /admin/groups/:name/members/delete — remove one member.
|
||||
export const groupsRemoveMember = withGroupName(async ({ ctx, keto }, name) => {
|
||||
const form = (await guardedForm(ctx))!;
|
||||
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
|
||||
if (tuple) await keto.deleteTuple(tuple);
|
||||
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" };
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
|
||||
// (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 @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, 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 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;
|
||||
|
||||
function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
const url = new URL("http://localhost/admin/users");
|
||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||
req.method = opts.method ?? "GET";
|
||||
return {
|
||||
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: an ungated Admin header whose screens each gate on their own read permission", () => {
|
||||
assert.equal(ADMIN_NAV.id, "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/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("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 () => {
|
||||
const post = (over: { body?: string; verifyCsrf?: (s: string | null | undefined) => boolean }) => fakeCtx({ method: "POST", ...over });
|
||||
|
||||
const ok = await guardedForm(post({ body: "_csrf=tok&name=Bo", verifyCsrf: () => true }));
|
||||
assert.equal(ok?.get("name"), "Bo");
|
||||
|
||||
await assert.rejects(guardedForm(post({ body: "_csrf=nope&name=Bo", verifyCsrf: () => false })), // ctx.verifyCsrf rejects
|
||||
(e: unknown) => e instanceof GuardError && e.status === 403);
|
||||
|
||||
assert.equal(await guardedForm(fakeCtx({ method: "GET" })), undefined); // not a mutation → no gate, no body read
|
||||
});
|
||||
|
||||
// ---- confirm-page model ----
|
||||
|
||||
test("buildConfirmModel wires the danger action, message, breadcrumbs and title (shell comes from ctx.chrome)", () => {
|
||||
const model = buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: "Delete" }],
|
||||
cancelHref: ADMIN_USERS_BASE, confirmAction: `${ADMIN_USERS_BASE}/u1/delete`, confirmLabel: "Delete user",
|
||||
message: "Delete ada@x.io?", title: "Delete user",
|
||||
});
|
||||
assert.deepEqual(model.confirm, { action: `${ADMIN_USERS_BASE}/u1/delete`, label: "Delete user" });
|
||||
assert.equal(model.message, "Delete ada@x.io?");
|
||||
assert.equal(model.cancelHref, ADMIN_USERS_BASE);
|
||||
assert.equal(model.title, "Delete user");
|
||||
assert.deepEqual(model.breadcrumbs.at(-1), { label: "Delete" });
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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, 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_USERS_BASE = "/admin/users";
|
||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||
export const ADMIN_PLUGIN_SETTINGS_BASE = "/admin/plugin-settings";
|
||||
|
||||
// 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";
|
||||
|
||||
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: "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.nav.section", // a key in this plugin's catalog; the host translates nav labels
|
||||
};
|
||||
|
||||
// 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)
|
||||
const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET"));
|
||||
if (!can(ctx, permission)) throw new GuardError(403, `${permission} required`);
|
||||
return user;
|
||||
}
|
||||
|
||||
// Read + CSRF-verify a mutation's form body once (double-submit via ctx.verifyCsrf); non-POST ⇒
|
||||
// undefined. A POST without a valid token is refused (GuardError → 403).
|
||||
export async function guardedForm(ctx: RequestContext): Promise<URLSearchParams | undefined> {
|
||||
if ((ctx.req.method ?? "GET").toUpperCase() !== "POST") return undefined;
|
||||
const form = await readFormBody(ctx.req);
|
||||
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token");
|
||||
return form;
|
||||
}
|
||||
|
||||
// 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: 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: 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
|
||||
// (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs.
|
||||
export function buildConfirmModel(opts: {
|
||||
breadcrumbs: { href?: string; label: string }[];
|
||||
cancelHref: string;
|
||||
confirmAction: string;
|
||||
confirmLabel: string;
|
||||
message: string;
|
||||
title: string;
|
||||
}) {
|
||||
return {
|
||||
breadcrumbs: opts.breadcrumbs,
|
||||
cancelHref: opts.cancelHref,
|
||||
confirm: { action: opts.confirmAction, label: opts.confirmLabel },
|
||||
message: opts.message,
|
||||
title: opts.title,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// Built-in Users admin screen (§5): the pure view-model + Kratos-payload builders. The HTTP
|
||||
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in app.test.ts.
|
||||
// Users admin screen (example plugin): the pure view-model + Kratos-payload builders. The HTTP
|
||||
// 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 "@plainpages/plugin-api";
|
||||
import {
|
||||
buildUserFormModel,
|
||||
buildUsersListModel,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
toUserView,
|
||||
updateIdentityPayload,
|
||||
} from "./admin-users.ts";
|
||||
import type { Identity } from "./kratos-admin.ts";
|
||||
|
||||
const id = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
||||
const identity = (n: number, over: Partial<Identity> = {}): Identity => ({
|
||||
@@ -41,7 +41,7 @@ test("buildUsersListModel filters by search + status, sorts, and paginates", ()
|
||||
const all = buildUsersListModel({ identities: people, url: "http://x/admin/users" });
|
||||
assert.equal(all.pagination.summary.total, 30);
|
||||
assert.equal(all.table.rows.length, 25); // default page size
|
||||
assert.equal(all.shell.title, "Users");
|
||||
assert.equal(all.title, "Users");
|
||||
|
||||
// Search narrows to one and shows a pill.
|
||||
const one = buildUsersListModel({ identities: people, url: "http://x/admin/users?q=user7%40example.com" });
|
||||
@@ -64,7 +64,7 @@ test("buildUsersListModel filters by search + status, sorts, and paginates", ()
|
||||
|
||||
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
|
||||
const m = buildUserFormModel({ csrfToken: "tok.sig" });
|
||||
assert.equal(m.shell.title, "New user");
|
||||
assert.equal(m.title, "New user");
|
||||
assert.equal(m.form.action, "/admin/users");
|
||||
assert.equal(m.form.submitLabel, "Create user");
|
||||
assert.equal(m.form.csrfToken, "tok.sig");
|
||||
@@ -76,7 +76,7 @@ test("buildUserFormModel: create mode has an editable email + password, no edit
|
||||
|
||||
test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => {
|
||||
const m = buildUserFormModel({ identity: identity(3) });
|
||||
assert.equal(m.shell.title, "Edit user");
|
||||
assert.equal(m.title, "Edit user");
|
||||
assert.equal(m.form.action, `/admin/users/${id(3)}`);
|
||||
assert.equal(m.form.submitLabel, "Save changes");
|
||||
const email = m.form.fields.find((f) => f.name === "email")!;
|
||||
@@ -0,0 +1,453 @@
|
||||
// Users admin screen: list Kratos identities (filter/sort/paginate) +
|
||||
// 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 { 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;
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
// One Kratos page is fetched and filtered/sorted/paged in memory — the admin API offers no
|
||||
// full-text search or sort. Ample for an admin tool; raise if a deployment outgrows it.
|
||||
const LIST_FETCH_SIZE = 250;
|
||||
const STATE_TONE: Record<string, string> = { active: "pos", inactive: "warn" };
|
||||
|
||||
export interface UserView {
|
||||
email: string;
|
||||
id: string;
|
||||
initials: string;
|
||||
name: string;
|
||||
state: string; // Kratos identity state: "active" | "inactive"
|
||||
}
|
||||
|
||||
export interface UserInput {
|
||||
email: string;
|
||||
first: string;
|
||||
last: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
function nameParts(identity: Identity): { first: string; last: string } {
|
||||
const nm = ((identity.traits?.name ?? {}) as { first?: unknown; last?: unknown });
|
||||
return {
|
||||
first: typeof nm.first === "string" ? nm.first.trim() : "",
|
||||
last: typeof nm.last === "string" ? nm.last.trim() : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function toUserView(identity: Identity): UserView {
|
||||
const email = typeof identity.traits?.email === "string" ? (identity.traits.email as string) : "";
|
||||
const { first, last } = nameParts(identity);
|
||||
const full = `${first} ${last}`.trim();
|
||||
const name = full || email.split("@")[0] || email;
|
||||
const initials = (first && last ? first[0]! + last[0]! : name.slice(0, 2) || "U").toUpperCase();
|
||||
return { email, id: identity.id, initials, name, state: identity.state ?? "active" };
|
||||
}
|
||||
|
||||
// ---- Kratos payloads ----
|
||||
|
||||
export function createIdentityPayload(input: UserInput): Record<string, unknown> {
|
||||
const traits: Record<string, unknown> = { email: input.email };
|
||||
if (input.first || input.last) traits.name = { first: input.first, last: input.last };
|
||||
const payload: Record<string, unknown> = { schema_id: SCHEMA_ID, state: "active", traits };
|
||||
if (input.password) payload.credentials = { password: { config: { password: input.password } } };
|
||||
return payload;
|
||||
}
|
||||
|
||||
// A full-identity PUT must carry schema/state/traits. Keep the existing email (the form's email is
|
||||
// read-only) and other traits; rewrite name from the input (cleared ⇒ drop it).
|
||||
export function updateIdentityPayload(identity: Identity, input: UserInput): Record<string, unknown> {
|
||||
const traits: Record<string, unknown> = { ...(identity.traits ?? {}) };
|
||||
if (input.first || input.last) traits.name = { first: input.first, last: input.last };
|
||||
else delete traits.name;
|
||||
return { schema_id: identity.schema_id ?? SCHEMA_ID, state: identity.state ?? "active", traits };
|
||||
}
|
||||
|
||||
export function setStatePayload(identity: Identity, state: "active" | "inactive"): Record<string, unknown> {
|
||||
return { schema_id: identity.schema_id ?? SCHEMA_ID, state, traits: { ...(identity.traits ?? {}) } };
|
||||
}
|
||||
|
||||
// ---- view models ----
|
||||
|
||||
interface ListState {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
q: string;
|
||||
sort: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const SORT: Record<string, (u: UserView) => string> = {
|
||||
email: (u) => u.email,
|
||||
name: (u) => u.name,
|
||||
status: (u) => u.state,
|
||||
};
|
||||
const COLUMNS = [
|
||||
{ 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.
|
||||
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.status && s.status !== "all") p.set("status", s.status);
|
||||
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_USERS_BASE}?${qs}` : ADMIN_USERS_BASE;
|
||||
}
|
||||
|
||||
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;
|
||||
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
||||
const needle = query.q.toLowerCase();
|
||||
|
||||
const all = opts.identities.map(toUserView);
|
||||
let list = all.filter((u) =>
|
||||
(!needle || u.name.toLowerCase().includes(needle) || u.email.toLowerCase().includes(needle)) &&
|
||||
(status === "all" || u.state === status));
|
||||
if (sort) {
|
||||
const get = SORT[sort.field] as (u: UserView) => string;
|
||||
const dir = sort.dir === "desc" ? -1 : 1;
|
||||
list = [...list].sort((a, b) => get(a).localeCompare(get(b)) * 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, status };
|
||||
|
||||
return {
|
||||
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, t: Translate) {
|
||||
return {
|
||||
actions: true,
|
||||
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: t(c.label), sort: dir, sortable: true };
|
||||
}),
|
||||
rows: rows.map((u) => ({
|
||||
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: t(`admin.users.status.${u.state}`), tone: STATE_TONE[u.state] ?? "info" } },
|
||||
],
|
||||
name: u.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function listFilterBar(state: ListState, total: number, t: Translate) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
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: t("filter.apply"), // an untranslated core key still resolves: the host catalog is the fallback
|
||||
clearHref: ADMIN_USERS_BASE,
|
||||
label: t("admin.users.filter"),
|
||||
pills,
|
||||
rows: [[
|
||||
{ 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>, 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: 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: 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 },
|
||||
};
|
||||
}
|
||||
|
||||
export interface FieldConfig {
|
||||
autocomplete?: string;
|
||||
hint?: string;
|
||||
icon?: string;
|
||||
id: string;
|
||||
label: string;
|
||||
name: string;
|
||||
optional?: boolean;
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
type?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
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 ?? "" };
|
||||
const email = editing ? view!.email : (opts.values?.email ?? "");
|
||||
const idPath = editing ? `${ADMIN_USERS_BASE}/${encodeURIComponent(view!.id)}` : ADMIN_USERS_BASE;
|
||||
|
||||
const fields: FieldConfig[] = [
|
||||
{ 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: 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: 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" ? 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 ? t("admin.users.save") : t("admin.users.create") },
|
||||
permissions: editing ? opts.permissions : undefined,
|
||||
recovery: opts.recovery,
|
||||
title: editing ? t("admin.users.edit") : t("admin.users.new"),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- request handler (imperative shell) ----
|
||||
|
||||
function readUserInput(form: URLSearchParams): UserInput {
|
||||
return {
|
||||
email: (form.get("email") ?? "").trim(),
|
||||
first: (form.get("first") ?? "").trim(),
|
||||
last: (form.get("last") ?? "").trim(),
|
||||
password: form.get("password") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// 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 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 = requirePermission(ctx, "users", action);
|
||||
const kratosAdmin = ctx.system?.kratosAdmin;
|
||||
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>, 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, 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({ 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.
|
||||
export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
|
||||
const input = readUserInput((await guardedForm(ctx))!);
|
||||
try {
|
||||
await kratosAdmin.createIdentity(createIdentityPayload(input));
|
||||
} catch (err) {
|
||||
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 });
|
||||
return { redirect: ADMIN_USERS_BASE };
|
||||
});
|
||||
|
||||
// GET /admin/users/new — the empty create form.
|
||||
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write");
|
||||
|
||||
// GET /admin/users/:id — the edit form, prefilled.
|
||||
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 (deps, identity, id) => {
|
||||
const { ctx, kratosAdmin } = deps;
|
||||
const input = readUserInput((await guardedForm(ctx))!);
|
||||
try {
|
||||
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
|
||||
} catch (err) {
|
||||
// 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)}` };
|
||||
});
|
||||
|
||||
// POST /admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
|
||||
// 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: 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);
|
||||
ctx.log.info("admin: user state changed", { actor: user.id, state: nextState, target: id });
|
||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||
});
|
||||
|
||||
// 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: 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: 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: 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 });
|
||||
return { redirect: ADMIN_USERS_BASE };
|
||||
});
|
||||
|
||||
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
|
||||
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, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) });
|
||||
});
|
||||
|
||||
// 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
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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 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 "@plainpages/plugin-api";
|
||||
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.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";
|
||||
|
||||
// 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: "0.3.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
|
||||
nav: [ADMIN_NAV],
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
<%#
|
||||
OAuth2 client detail page: the client-detail body (info · one-time secret · delete) in the
|
||||
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", { canWrite: model.canWrite, client: model.client, created: model.created, csrfToken: chrome.csrfToken, del: model.delete, secret: model.secret });
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body,
|
||||
brand: chrome.brand,
|
||||
breadcrumbs: model.breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
nav,
|
||||
theme: chrome.theme,
|
||||
title: model.title,
|
||||
user: chrome.user,
|
||||
}) %>
|
||||
@@ -0,0 +1,16 @@
|
||||
<%#
|
||||
OAuth2 client register page: the client-form body captured into the app shell.
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const body = include("partials/client-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,
|
||||
}) %>
|
||||
@@ -0,0 +1,22 @@
|
||||
<%#
|
||||
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
|
||||
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);
|
||||
// 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,
|
||||
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,17 @@
|
||||
<%#
|
||||
Admin destructive-action confirmation page: the confirm body in the app shell. Model
|
||||
from buildConfirmModel: { message, confirm:{action,label}, cancelHref, nav, shell }.
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const body = include("partials/confirm-body", { cancelHref: model.cancelHref, confirm: model.confirm, csrfToken: chrome.csrfToken, message: model.message });
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body,
|
||||
brand: chrome.brand,
|
||||
breadcrumbs: model.breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
nav,
|
||||
theme: chrome.theme,
|
||||
title: model.title,
|
||||
user: chrome.user,
|
||||
}) %>
|
||||
@@ -0,0 +1,16 @@
|
||||
<%#
|
||||
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, 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,
|
||||
brand: chrome.brand,
|
||||
breadcrumbs: model.breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
nav,
|
||||
theme: chrome.theme,
|
||||
title: model.title,
|
||||
user: chrome.user,
|
||||
}) %>
|
||||
@@ -0,0 +1,16 @@
|
||||
<%#
|
||||
Group admin create page: the group-form body captured into the app shell.
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const body = include("partials/group-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,
|
||||
}) %>
|
||||
@@ -0,0 +1,22 @@
|
||||
<%#
|
||||
Groups admin list: the same building blocks as the Users screen, around the shell, but
|
||||
backed by live Keto subject sets (admin-groups.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);
|
||||
// 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,
|
||||
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,17 @@
|
||||
<%#
|
||||
Admin notice page — a single message in the app shell, reused for not-found (404) and
|
||||
capability-unavailable (503). The shell renders `title` as the page <h1>; the body is one line.
|
||||
Data: chrome, title, message.
|
||||
%><%
|
||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const body = include("partials/notice-body", { message });
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body,
|
||||
brand: chrome.brand,
|
||||
csrfToken: chrome.csrfToken,
|
||||
nav: navHtml,
|
||||
theme: chrome.theme,
|
||||
title,
|
||||
user: chrome.user,
|
||||
}) %>
|
||||
@@ -0,0 +1,40 @@
|
||||
<%#
|
||||
Admin OAuth2 client detail body, captured into the shell content slot. Config:
|
||||
client { firstParty, id, name, public, redirectUris[], scopes[] }
|
||||
created bool just registered → success banner
|
||||
secret? string one-time client secret (confidential clients), shown once right after create
|
||||
del { action } delete the client
|
||||
csrfToken
|
||||
%><%
|
||||
const c = locals.client;
|
||||
const del = locals.del;
|
||||
-%>
|
||||
<div class="form-page">
|
||||
<% if (locals.created) { -%>
|
||||
<%- 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"><%= 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><%= 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>
|
||||
<% 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>
|
||||
+9
-9
@@ -1,5 +1,5 @@
|
||||
<%#
|
||||
Admin OAuth2 client register form body (todo §6), captured into the shell content slot. Config:
|
||||
Admin OAuth2 client register form body, captured into the shell content slot. Config:
|
||||
form { action, csrfToken, submitLabel, cancelHref, nameField, scopeField (field.ejs configs),
|
||||
redirectUris: string (newline-separated), public: bool, firstParty: bool }
|
||||
error? string shown when a write was rejected
|
||||
@@ -8,22 +8,22 @@
|
||||
-%>
|
||||
<div class="form-page">
|
||||
<% if (locals.error) { -%>
|
||||
<%- include("alert", { text: locals.error, tone: "neg" }) %>
|
||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
<%- include("field", form.nameField) %>
|
||||
<%- 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("field", form.scopeField) %>
|
||||
<%- include("partials/field", form.scopeField) %>
|
||||
<label class="check"><input type="checkbox" name="public"<% if (form.public) { %> checked<% } %>> Public client (SPA / native app, PKCE — no secret)</label>
|
||||
<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>
|
||||
@@ -0,0 +1,17 @@
|
||||
<%#
|
||||
Destructive-action confirm body, captured into the shell content slot. Zero-JS: the
|
||||
delete is a deliberate second step (a POST form), with a cancel link back. Config:
|
||||
message string
|
||||
confirm { action, label } the danger POST endpoint + button label
|
||||
cancelHref string
|
||||
csrfToken
|
||||
%>
|
||||
<div class="form-page">
|
||||
<section class="form-card admin-actions" aria-label="<%= t("admin.users.confirm") %>">
|
||||
<p><%= locals.message %></p>
|
||||
<div class="form-actions">
|
||||
<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>
|
||||
@@ -0,0 +1,49 @@
|
||||
<%#
|
||||
Admin group membership body, captured into the shell content slot. Config:
|
||||
group { name }
|
||||
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = remove-member endpoint
|
||||
add { action, options: {label,value}[] } action = add-member endpoint
|
||||
del { action } delete the whole group
|
||||
csrfToken, error?
|
||||
%><%
|
||||
const group = locals.group;
|
||||
const members = locals.members;
|
||||
const add = locals.add;
|
||||
const del = locals.del;
|
||||
const csrf = locals.csrfToken;
|
||||
-%>
|
||||
<div class="form-page">
|
||||
<% if (locals.error) { -%>
|
||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<section class="form-card" aria-labelledby="members-h">
|
||||
<h2 class="card-title" id="members-h"><%= t("admin.groups.members") %></h2>
|
||||
<% if (members.rows.length) { -%>
|
||||
<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" ? 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"><%= 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"><%= t("admin.groups.addMember") %></h2>
|
||||
<% if (add.options.length) { -%>
|
||||
<form class="inline-form" method="post" action="<%= localeHref(add.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("common.add") %></button></form>
|
||||
<% } else { -%>
|
||||
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
|
||||
<% } -%>
|
||||
</section>
|
||||
<% } -%>
|
||||
<% 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>
|
||||
@@ -0,0 +1,26 @@
|
||||
<%#
|
||||
Admin group 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="<%= localeHref(form.action) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
<%- include("partials/field", form.nameField) %>
|
||||
<div class="field">
|
||||
<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="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
|
||||
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
<%# One-line notice body (not-found / unavailable). Data: message. %>
|
||||
<section class="notice"><p><%= message %></p></section>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,41 @@
|
||||
<%#
|
||||
Admin user create/edit form body, captured into the shell content slot. Config:
|
||||
form { action, csrfToken, submitLabel, cancelHref, fields: field.ejs config[] }
|
||||
edit? { nextLabel, stateAction, recoveryAction, deleteAction } (edit mode only)
|
||||
recovery? { code? } shown after a recovery code is generated (recovery is code-based)
|
||||
error? string shown when a write was rejected
|
||||
%><%
|
||||
const form = locals.form;
|
||||
const edit = locals.edit;
|
||||
const recovery = locals.recovery;
|
||||
-%>
|
||||
<div class="form-page">
|
||||
<% if (locals.error) { -%>
|
||||
<%- 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><%= 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="<%= 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="<%= 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 && 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>
|
||||
@@ -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,
|
||||
}) %>
|
||||
@@ -0,0 +1,16 @@
|
||||
<%#
|
||||
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", { canWrite: model.canWrite, edit: model.edit, error: model.error, form: model.form, permissions: model.permissions, recovery: model.recovery });
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body,
|
||||
brand: chrome.brand,
|
||||
breadcrumbs: model.breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
nav,
|
||||
theme: chrome.theme,
|
||||
title: model.title,
|
||||
user: chrome.user,
|
||||
}) %>
|
||||
@@ -0,0 +1,22 @@
|
||||
<%#
|
||||
Users admin list: the same building blocks as the dashboard, around the shell, but
|
||||
backed by live Kratos identities (admin-users.ts). Filter/sort/page all 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);
|
||||
// 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,
|
||||
body: filters + table + pager,
|
||||
brand: chrome.brand,
|
||||
breadcrumbs: model.breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
nav,
|
||||
theme: chrome.theme,
|
||||
title: model.title,
|
||||
user: chrome.user,
|
||||
}) %>
|
||||
@@ -1,7 +1,8 @@
|
||||
# Scheduling — the reference plugin
|
||||
|
||||
A worked example of the [plugin contract](../../docs/plugin-contract.md). Copy this folder, rename
|
||||
it (the folder name becomes the plugin id and mount path), and point it at your own backend.
|
||||
A worked example of the [plugin contract](../../../README.md#building-plugins). Copy this folder into
|
||||
`plugins/` (it keeps the id and mount path `scheduling`) and point it at your own backend — the folder
|
||||
name *is* the plugin id and mount path, so rename it only if you want a different one.
|
||||
|
||||
What it demonstrates:
|
||||
|
||||
@@ -9,20 +10,24 @@ What it demonstrates:
|
||||
service and renders the rows with the core building blocks (`shifts.ejs` → app shell, filter-bar,
|
||||
data-table). Search round-trips the URL; zero-JS. (It fetches **all** rows for brevity — for a
|
||||
large list, parse `page`/`pageSize` from `parseListQuery`, forward them upstream as a `?limit`/
|
||||
`?offset`, and render `pagination.ejs` with `paginate()`, exactly as the built-in admin screens do.)
|
||||
`?offset`, and render `pagination.ejs` with `paginate()`, exactly as the admin example plugin does.)
|
||||
- **A form that forwards a write upstream** — `GET /scheduling/shifts/new` renders the form,
|
||||
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
|
||||
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
|
||||
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).
|
||||
|
||||
@@ -45,6 +50,6 @@ cosmetically) — normalise to your backend's format there if it matters.
|
||||
|
||||
## Granting access
|
||||
|
||||
A user sees Scheduling once they hold the `scheduling:read` role in Keto (and `scheduling:write`
|
||||
A user sees Scheduling once they hold the `scheduling:read` permission in Keto (and `scheduling:write`
|
||||
to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
||||
`admin@plainpages.local` can use it immediately.
|
||||
@@ -0,0 +1,40 @@
|
||||
// This plugin's own catalog, and the baseline its other locales are written against. Keys are
|
||||
// looked up here first and fall back to the host's, so a plugin owns its words without prefixing
|
||||
// them, and `shifts.count` shows the plural form (host: README → Translating).
|
||||
|
||||
import type { PluralMessage } from "@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;
|
||||
@@ -0,0 +1,60 @@
|
||||
// Reference plugin: a worked example of the contract — a list page that fetches upstream
|
||||
// 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 "@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). 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: "0.3.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
|
||||
// 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. 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: "scheduling.nav.overview", public: true },
|
||||
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
|
||||
],
|
||||
icon: "i-cal",
|
||||
id: "scheduling",
|
||||
label: "scheduling.nav.section",
|
||||
}],
|
||||
|
||||
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
||||
permissions: [
|
||||
{ description: "View shifts", name: READ },
|
||||
{ description: "Create and edit shifts", name: WRITE },
|
||||
],
|
||||
|
||||
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
|
||||
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
|
||||
routes: [
|
||||
{ handler: overview(), method: "GET", path: "/", public: true },
|
||||
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
||||
{ 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 "../../src/plugin-api.ts";
|
||||
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; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||
return {
|
||||
chrome: CHROME, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||
roles: opts.roles ?? [], url, user: null, 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);
|
||||
});
|
||||
@@ -93,8 +94,8 @@ test("readInput trims; validate requires title + assignee", () => {
|
||||
|
||||
// ---- list handler ----
|
||||
|
||||
test("listShifts renders the upstream rows; q filters; canWrite reflects the role", async () => {
|
||||
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ roles: ["scheduling:write"] })));
|
||||
test("listShifts renders the upstream rows; q filters; canWrite reflects the permission", async () => {
|
||||
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ permissions: ["scheduling:write"] })));
|
||||
assert.equal(r.view, "shifts");
|
||||
const table = r.data["table"] as { rows: { name: string }[] };
|
||||
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
|
||||
@@ -112,15 +113,15 @@ test("listShifts degrades to a recoverable error page when the upstream is down
|
||||
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
|
||||
});
|
||||
|
||||
// ---- public overview handler (§10: a page anyone can reach, gated data stays behind the role) ----
|
||||
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
|
||||
|
||||
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
||||
const anon = asView(await overview()(fakeCtx())); // user null, no roles
|
||||
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
|
||||
assert.equal(anon.view, "overview");
|
||||
assert.equal(anon.data["chrome"], CHROME);
|
||||
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
||||
|
||||
const reader = asView(await overview()(fakeCtx({ roles: ["scheduling:read"] })));
|
||||
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
|
||||
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
||||
});
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
// Reference plugin (todo §7) — Scheduling/Shifts handlers + the upstream client. Shows the blessed
|
||||
// Reference plugin — Scheduling/Shifts handlers + the upstream client. Shows the blessed
|
||||
// shape: a thin handler parses ctx, calls an upstream REST service, and returns a RouteResult the
|
||||
// host renders. The plugin holds no state of its own (README "Stateless") — data lives upstream.
|
||||
//
|
||||
// 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 (docs/plugin-contract.md → dev/test story).
|
||||
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
||||
|
||||
// One import from the host's plugin-api barrel — the stable author surface (see docs/plugin-contract.md).
|
||||
import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, tracedFetch } from "../../src/plugin-api.ts";
|
||||
// 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";
|
||||
|
||||
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page (§10)
|
||||
// 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";
|
||||
export const READ = "scheduling:read"; // permission token gating the list + nav
|
||||
export const WRITE = "scheduling:write"; // permission token gating create
|
||||
export const READ = "scheduling:read"; // the permission gating the list + nav
|
||||
export const WRITE = "scheduling:write"; // the permission gating create
|
||||
|
||||
export interface Shift {
|
||||
id: string;
|
||||
@@ -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 (§9), so each upstream call joins the request's trace (a client
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -172,26 +173,33 @@ export function listShifts(upstream: ShiftsUpstream): RouteHandler {
|
||||
try {
|
||||
shifts = await upstream.list();
|
||||
} catch (err) {
|
||||
ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); // plugin logging via ctx.log (§9)
|
||||
error = "Couldn't reach the scheduling service — try again shortly.";
|
||||
ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); // plugin logging via ctx.log
|
||||
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 (§10): a page anyone may reach — its route + nav node are marked `public`, so the
|
||||
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
|
||||
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
||||
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
||||
// else a prompt to sign in. ctx.user may be null here, so read the role via can() (zero I/O).
|
||||
// else a prompt to sign in. ctx.user may be null here, so read the permission via can() (zero I/O).
|
||||
export function overview(): RouteHandler {
|
||||
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
|
||||
+5
-5
@@ -1,18 +1,18 @@
|
||||
<%#
|
||||
Scheduling · public overview (reference plugin, §10). A page ANYONE may reach — the route and its
|
||||
Scheduling · public overview (reference plugin). A page ANYONE may reach — the route and its
|
||||
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> role.</p>' + cta + '</div>',
|
||||
body: '<div class="scheduling-page"><p>' + t("scheduling.overview.lead") + '</p>' + cta + '</div>',
|
||||
brand: chrome.brand,
|
||||
breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
+3
-3
@@ -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 (plugins/scheduling) — a stand-in for the
|
||||
// customer's real backend so `docker compose up` shows the plugin working out of the box. NOT part
|
||||
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point SCHEDULING_UPSTREAM
|
||||
// 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 PLUGIN_SETTING_SCHEDULING_UPSTREAM
|
||||
// at your real service in production.
|
||||
//
|
||||
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ]
|
||||
@@ -1,733 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>App Shell — Template</title>
|
||||
<link rel="stylesheet" href="../public/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ============ ICON SPRITE — Lucide (https://lucide.dev, ISC license) ============
|
||||
Official Lucide paths, inlined as <symbol> so usage stays zero-JS:
|
||||
<svg class="ico"><use href="#i-name"/></svg>. Stroke + currentColor are
|
||||
applied via the .ico class, matching Lucide's 24-grid / round-cap style. -->
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
|
||||
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24"><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="18" y2="18"/></symbol>
|
||||
<symbol id="i-kebab" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></symbol>
|
||||
<symbol id="i-sort" viewBox="0 0 24 24"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></symbol>
|
||||
<symbol id="i-up" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></symbol>
|
||||
<symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></symbol>
|
||||
<symbol id="i-sliders" viewBox="0 0 24 24"><line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/></symbol>
|
||||
<symbol id="i-cols" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="M15 3v18"/></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></symbol>
|
||||
<symbol id="i-grid" viewBox="0 0 24 24"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></symbol>
|
||||
<symbol id="i-box" viewBox="0 0 24 24"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol>
|
||||
<symbol id="i-layers" viewBox="0 0 24 24"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></symbol>
|
||||
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></symbol>
|
||||
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></symbol>
|
||||
<symbol id="i-gear" viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="i-user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></symbol>
|
||||
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></symbol>
|
||||
<symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
|
||||
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></symbol>
|
||||
<symbol id="i-logout" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/></symbol>
|
||||
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></symbol>
|
||||
</svg>
|
||||
|
||||
<!-- nav-toggle drives the mobile overlay (pure CSS) -->
|
||||
<input type="checkbox" id="nav-toggle" aria-hidden="true" tabindex="-1">
|
||||
|
||||
<div class="app">
|
||||
|
||||
<!-- =================== SIDEBAR =================== -->
|
||||
<aside class="sidebar" aria-label="Primary">
|
||||
<div class="brand">
|
||||
<span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box"/></svg></span>
|
||||
<span class="brand-name">Console</span>
|
||||
<span class="brand-sub">v0.1</span>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
UNIFIED NAV TREE — every item uses the SAME node markup.
|
||||
HEADER → node has a <details class="nav-disc"> toggle + <ul class="nav-children">
|
||||
LEAF → node has a <span class="nav-spacer"> instead of a toggle
|
||||
CLICKABLE → label is <a class="nav-self" href>
|
||||
STATIC → label is <span class="nav-self">
|
||||
Mix freely: a node can be header+clickable, header+static,
|
||||
leaf+clickable, or leaf+static. Workspace / Insights below are
|
||||
simply header + static nodes — nothing special about them.
|
||||
==================================================== -->
|
||||
<nav class="nav" aria-label="Main navigation">
|
||||
<ul class="nav-tree">
|
||||
|
||||
<!-- leaf + clickable -->
|
||||
<li class="nav-node">
|
||||
<div class="nav-row">
|
||||
<span class="nav-spacer" aria-hidden="true"></span>
|
||||
<a class="nav-self" href="#"><svg class="ico"><use href="#i-grid"/></svg><span class="nav-label">Overview</span></a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- header + STATIC (was the "Workspace" group label) -->
|
||||
<li class="nav-node">
|
||||
<div class="nav-row">
|
||||
<details class="nav-disc" open>
|
||||
<summary class="nav-tog" aria-label="Toggle Workspace"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
|
||||
</details>
|
||||
<span class="nav-self"><span class="nav-label">Workspace</span></span>
|
||||
</div>
|
||||
<ul class="nav-children">
|
||||
|
||||
<!-- header + CLICKABLE -->
|
||||
<li class="nav-node">
|
||||
<div class="nav-row">
|
||||
<details class="nav-disc" open>
|
||||
<summary class="nav-tog" aria-label="Toggle Directory"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
|
||||
</details>
|
||||
<a class="nav-self" href="#"><svg class="ico"><use href="#i-users"/></svg><span class="nav-label">Directory</span><span class="nav-count">4</span></a>
|
||||
</div>
|
||||
<ul class="nav-children">
|
||||
|
||||
<!-- leaf + clickable (active) -->
|
||||
<li class="nav-node">
|
||||
<div class="nav-row">
|
||||
<span class="nav-spacer" aria-hidden="true"></span>
|
||||
<a class="nav-self" href="#" aria-current="page"><span class="nav-label">People</span><span class="nav-count">1,284</span></a>
|
||||
</div>
|
||||
</li>
|
||||
<li class="nav-node">
|
||||
<div class="nav-row">
|
||||
<span class="nav-spacer" aria-hidden="true"></span>
|
||||
<a class="nav-self" href="#"><span class="nav-label">Teams</span></a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- header + CLICKABLE -->
|
||||
<li class="nav-node">
|
||||
<div class="nav-row">
|
||||
<details class="nav-disc" open>
|
||||
<summary class="nav-tog" aria-label="Toggle Roles & Access"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
|
||||
</details>
|
||||
<a class="nav-self" href="#"><span class="nav-label">Roles & Access</span></a>
|
||||
</div>
|
||||
<ul class="nav-children">
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Roles</span></a></div></li>
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Permission sets</span></a></div></li>
|
||||
|
||||
<!-- header + CLICKABLE (level 4) -->
|
||||
<li class="nav-node">
|
||||
<div class="nav-row">
|
||||
<details class="nav-disc" open>
|
||||
<summary class="nav-tog" aria-label="Toggle Policies"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
|
||||
</details>
|
||||
<a class="nav-self" href="#"><span class="nav-label">Policies</span></a>
|
||||
</div>
|
||||
<ul class="nav-children">
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Password policy</span></a></div></li>
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Session limits</span></a></div></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- header + STATIC (level 4) -->
|
||||
<li class="nav-node">
|
||||
<div class="nav-row">
|
||||
<details class="nav-disc">
|
||||
<summary class="nav-tog" aria-label="Toggle Scopes"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
|
||||
</details>
|
||||
<span class="nav-self"><span class="nav-label">Scopes</span></span>
|
||||
</div>
|
||||
<ul class="nav-children">
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Read scopes</span></a></div></li>
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Write scopes</span></a></div></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- header + STATIC -->
|
||||
<li class="nav-node">
|
||||
<div class="nav-row">
|
||||
<details class="nav-disc">
|
||||
<summary class="nav-tog" aria-label="Toggle Segments"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
|
||||
</details>
|
||||
<span class="nav-self"><span class="nav-label">Segments</span></span>
|
||||
</div>
|
||||
<ul class="nav-children">
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Active users</span></a></div></li>
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Invited</span></a></div></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- header + STATIC -->
|
||||
<li class="nav-node">
|
||||
<div class="nav-row">
|
||||
<details class="nav-disc" open>
|
||||
<summary class="nav-tog" aria-label="Toggle Resources"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
|
||||
</details>
|
||||
<span class="nav-self"><svg class="ico"><use href="#i-box"/></svg><span class="nav-label">Resources</span></span>
|
||||
</div>
|
||||
<ul class="nav-children">
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Projects</span></a></div></li>
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Environments</span></a></div></li>
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">API keys</span></a></div></li>
|
||||
<!-- leaf + STATIC (no link → not a navigation target) -->
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><span class="nav-self"><span class="nav-label">Webhooks (soon)</span></span></div></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- header + STATIC (was the "Insights" group label) -->
|
||||
<li class="nav-node">
|
||||
<div class="nav-row">
|
||||
<details class="nav-disc" open>
|
||||
<summary class="nav-tog" aria-label="Toggle Insights"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
|
||||
</details>
|
||||
<span class="nav-self"><span class="nav-label">Insights</span></span>
|
||||
</div>
|
||||
<ul class="nav-children">
|
||||
<li class="nav-node">
|
||||
<div class="nav-row">
|
||||
<span class="nav-spacer" aria-hidden="true"></span>
|
||||
<a class="nav-self" href="#"><svg class="ico"><use href="#i-chart"/></svg><span class="nav-label">Reports</span></a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- header + CLICKABLE -->
|
||||
<li class="nav-node">
|
||||
<div class="nav-row">
|
||||
<details class="nav-disc">
|
||||
<summary class="nav-tog" aria-label="Toggle Activity"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
|
||||
</details>
|
||||
<a class="nav-self" href="#"><svg class="ico"><use href="#i-bell"/></svg><span class="nav-label">Activity</span></a>
|
||||
</div>
|
||||
<ul class="nav-children">
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Audit log</span></a></div></li>
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Notifications</span></a></div></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- header + STATIC -->
|
||||
<li class="nav-node">
|
||||
<div class="nav-row">
|
||||
<details class="nav-disc">
|
||||
<summary class="nav-tog" aria-label="Toggle Catalog"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
|
||||
</details>
|
||||
<span class="nav-self"><svg class="ico"><use href="#i-layers"/></svg><span class="nav-label">Catalog</span></span>
|
||||
</div>
|
||||
<ul class="nav-children">
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Items</span></a></div></li>
|
||||
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Categories</span></a></div></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- ---- sidebar footer: theme + profile + settings ---- -->
|
||||
<div class="side-footer">
|
||||
<!-- theme switcher: Light / Auto / Dark (Auto = follow system) -->
|
||||
<div class="theme-switch" role="radiogroup" aria-label="Color theme">
|
||||
<label>
|
||||
<input type="radio" name="theme" id="theme-light">
|
||||
<span>Light</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="theme" id="theme-auto" checked>
|
||||
<span>Auto</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="theme" id="theme-dark">
|
||||
<span>Dark</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="footer-actions">
|
||||
<!-- profile (opens a menu) -->
|
||||
<details class="menu" style="flex:1 1 auto">
|
||||
<summary class="profile">
|
||||
<span class="avatar" aria-hidden="true">AK</span>
|
||||
<span class="profile-meta">
|
||||
<span class="profile-name">Avery Kline</span>
|
||||
<span class="profile-mail"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="1f7e697a6d665f7e7c727a317670">[email protected]</a></span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="menu-pop left" style="bottom:calc(100% + 6px); top:auto; min-width:220px">
|
||||
<div class="menu-head">Signed in as Avery</div>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-user"/></svg>Profile</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-globe"/></svg>Language — English</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-bell"/></svg>Notifications</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger"><svg class="ico"><use href="#i-logout"/></svg>Sign out</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- settings -->
|
||||
<details class="menu">
|
||||
<summary class="btn icon-btn" aria-label="Settings">
|
||||
<svg class="ico"><use href="#i-gear"/></svg>
|
||||
</summary>
|
||||
<div class="menu-pop" style="bottom:calc(100% + 6px); top:auto">
|
||||
<div class="menu-head">Settings</div>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-gear"/></svg>Preferences</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-users"/></svg>Members</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-globe"/></svg>Region & language</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- scrim closes the mobile menu (label toggles the checkbox) -->
|
||||
<label class="scrim" for="nav-toggle" aria-label="Close menu"></label>
|
||||
|
||||
<!-- =================== CONTENT =================== -->
|
||||
<main class="content">
|
||||
|
||||
<!-- topbar -->
|
||||
<header class="topbar">
|
||||
<label class="btn icon-btn hamburger" for="nav-toggle" aria-label="Open menu">
|
||||
<svg class="ico"><use href="#i-menu"/></svg>
|
||||
</label>
|
||||
<h1 class="page-title">People</h1>
|
||||
<nav class="crumbs" aria-label="Breadcrumb">
|
||||
<a href="#">Directory</a><span class="sep">/</span><span>People</span>
|
||||
</nav>
|
||||
<div class="topbar-spacer"></div>
|
||||
<button class="btn"><svg class="ico ico-sm"><use href="#i-download"/></svg>Export</button>
|
||||
<button class="btn btn-primary"><svg class="ico ico-sm"><use href="#i-plus"/></svg>Add person</button>
|
||||
</header>
|
||||
|
||||
<!-- ============ FILTER BAR ============
|
||||
A real GET form: selections submit as query params (?q=…&status=…)
|
||||
so filtering is server-side and works with zero JavaScript.
|
||||
Every control has a name + associated label; related controls are
|
||||
grouped in <fieldset>/<legend>; Apply submits, Reset clears. -->
|
||||
<form class="filters" method="get" aria-label="Filter people">
|
||||
<!-- row 1: search + status + team + column/extra menus -->
|
||||
<div class="filter-row">
|
||||
<label class="search">
|
||||
<span class="sr-only">Search people</span>
|
||||
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"/></svg>
|
||||
<input type="search" name="q" placeholder="Search people…">
|
||||
</label>
|
||||
|
||||
<fieldset class="filter-field">
|
||||
<legend class="sr-only">Status</legend>
|
||||
<div class="segmented">
|
||||
<label><input type="radio" name="status" value="all" checked><span>All</span><span class="seg-count">1,284</span></label>
|
||||
<label><input type="radio" name="status" value="active"><span>Active</span></label>
|
||||
<label><input type="radio" name="status" value="archived"><span>Archived</span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<span class="filter">
|
||||
<label class="sr-only" for="f-team">Team</label>
|
||||
<span class="select">
|
||||
<select id="f-team" name="team">
|
||||
<option value="">All teams</option>
|
||||
<option value="engineering">Engineering</option>
|
||||
<option value="design">Design</option>
|
||||
<option value="operations">Operations</option>
|
||||
<option value="sales">Sales</option>
|
||||
</select>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<!-- column visibility (display preference, also persisted via the form) -->
|
||||
<details class="menu">
|
||||
<summary class="btn"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cols"/></svg>Columns</summary>
|
||||
<div class="menu-pop">
|
||||
<fieldset class="menu-field">
|
||||
<legend class="menu-head">Visible columns</legend>
|
||||
<label class="menu-check"><input type="checkbox" name="col" value="name" checked>Name</label>
|
||||
<label class="menu-check"><input type="checkbox" name="col" value="email" checked>Email</label>
|
||||
<label class="menu-check"><input type="checkbox" name="col" value="role" checked>Role</label>
|
||||
<label class="menu-check"><input type="checkbox" name="col" value="team" checked>Team</label>
|
||||
<label class="menu-check"><input type="checkbox" name="col" value="status" checked>Status</label>
|
||||
<label class="menu-check"><input type="checkbox" name="col" value="last_active" checked>Last active</label>
|
||||
<label class="menu-check"><input type="checkbox" name="col" value="created">Created</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="menu">
|
||||
<summary class="btn"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-sliders"/></svg>More filters</summary>
|
||||
<div class="menu-pop" style="min-width:240px">
|
||||
<fieldset class="menu-field">
|
||||
<legend class="menu-head">Role</legend>
|
||||
<label class="menu-check"><input type="radio" name="role" value="" checked>Any role</label>
|
||||
<label class="menu-check"><input type="radio" name="role" value="admin">Admin</label>
|
||||
<label class="menu-check"><input type="radio" name="role" value="member">Member</label>
|
||||
<label class="menu-check"><input type="radio" name="role" value="viewer">Viewer</label>
|
||||
</fieldset>
|
||||
<div class="menu-sep"></div>
|
||||
<fieldset class="menu-field">
|
||||
<legend class="menu-head">Flags</legend>
|
||||
<label class="menu-check"><input type="checkbox" name="flag" value="2fa">2FA enabled</label>
|
||||
<label class="menu-check"><input type="checkbox" name="flag" value="pending">Pending invite</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- row 2: tags + joined date range -->
|
||||
<div class="filter-row">
|
||||
<fieldset class="filter-field">
|
||||
<legend class="sr-only">Tags</legend>
|
||||
<span class="filter-legend" aria-hidden="true">Tags</span>
|
||||
<div class="chips">
|
||||
<label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="tag" value="engineering" checked>Engineering</label>
|
||||
<label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="tag" value="design">Design</label>
|
||||
<label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="tag" value="oncall" checked>On-call</label>
|
||||
<label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="tag" value="contractor">Contractor</label>
|
||||
<label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="tag" value="remote">Remote</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<fieldset class="filter-field">
|
||||
<legend class="sr-only">Joined</legend>
|
||||
<span class="filter-legend" aria-hidden="true">Joined</span>
|
||||
<div class="daterange">
|
||||
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"/></svg>
|
||||
<label class="sr-only" for="f-from">Joined from</label>
|
||||
<input type="date" id="f-from" name="joined_from" value="2026-01-01">
|
||||
<span class="to" aria-hidden="true">to</span>
|
||||
<label class="sr-only" for="f-to">Joined to</label>
|
||||
<input type="date" id="f-to" name="joined_to" value="2026-06-14">
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<!-- row 3: applied filters (server-rendered) + form actions -->
|
||||
<div class="filter-row filter-foot">
|
||||
<div class="active-pills" aria-label="Applied filters">
|
||||
<span class="filter-legend">Applied</span>
|
||||
<span class="pill"><b>Team:</b> Engineering <a class="pill-x" href="?tag=oncall&joined_from=2026-01-01" aria-label="Remove Team filter"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span>
|
||||
<span class="pill"><b>Tag:</b> On-call <a class="pill-x" href="?team=engineering&joined_from=2026-01-01" aria-label="Remove On-call filter"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span>
|
||||
<span class="pill"><b>Joined:</b> 2026 <a class="pill-x" href="?team=engineering&tag=oncall" aria-label="Remove Joined filter"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span>
|
||||
<a class="pill-clear" href="?">Clear all</a>
|
||||
</div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<div class="filter-actions">
|
||||
<button type="reset" class="btn">Reset</button>
|
||||
<button type="submit" class="btn btn-primary"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"/></svg>Apply filters</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- ============ TABLE ============ -->
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<caption class="sr-only">People in the directory</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check" scope="col">
|
||||
<input type="checkbox" aria-label="Select all rows">
|
||||
</th>
|
||||
<th scope="col" aria-sort="ascending">
|
||||
<button class="th-sort">Name <svg class="ico ico-sm sort-ico"><use href="#i-up"/></svg></button>
|
||||
</th>
|
||||
<th scope="col">
|
||||
<button class="th-sort">Email <svg class="ico ico-sm sort-ico"><use href="#i-sort"/></svg></button>
|
||||
</th>
|
||||
<th scope="col">
|
||||
<button class="th-sort">Role <svg class="ico ico-sm sort-ico"><use href="#i-sort"/></svg></button>
|
||||
</th>
|
||||
<th scope="col">Team</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">
|
||||
<button class="th-sort">Last active <svg class="ico ico-sm sort-ico"><use href="#i-sort"/></svg></button>
|
||||
</th>
|
||||
<th class="col-actions" scope="col"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- row template, repeated -->
|
||||
<tr>
|
||||
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Mara Delgado"></td>
|
||||
<td><span class="cell-user"><span class="avatar" aria-hidden="true">MD</span><span class="cell-strong">Mara Delgado</span></span></td>
|
||||
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="80ede1f2e1aee4e5ece7e1e4efc0e1e3ede5aee9ef">[email protected]</a></td>
|
||||
<td>Admin</td>
|
||||
<td class="cell-muted">Engineering</td>
|
||||
<td><span class="badge pos"><span class="dot"></span>Active</span></td>
|
||||
<td class="cell-muted">2 min ago</td>
|
||||
<td class="col-actions">
|
||||
<details class="menu kebab">
|
||||
<summary aria-label="Row actions for Mara Delgado"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
|
||||
<div class="menu-pop">
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Soren Vance"></td>
|
||||
<td><span class="cell-user"><span class="avatar" aria-hidden="true">SV</span><span class="cell-strong">Soren Vance</span></span></td>
|
||||
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="4b3824392e25653d2a25282e0b2a28262e652224">[email protected]</a></td>
|
||||
<td>Member</td>
|
||||
<td class="cell-muted">Design</td>
|
||||
<td><span class="badge warn"><span class="dot"></span>Idle</span></td>
|
||||
<td class="cell-muted">3 hours ago</td>
|
||||
<td class="col-actions">
|
||||
<details class="menu kebab">
|
||||
<summary aria-label="Row actions for Soren Vance"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
|
||||
<div class="menu-pop">
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Priya Nair"></td>
|
||||
<td><span class="cell-user"><span class="avatar" aria-hidden="true">PN</span><span class="cell-strong">Priya Nair</span></span></td>
|
||||
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="5f2f2d36263e71313e362d1f3e3c323a713630">[email protected]</a></td>
|
||||
<td>Admin</td>
|
||||
<td class="cell-muted">Operations</td>
|
||||
<td><span class="badge pos"><span class="dot"></span>Active</span></td>
|
||||
<td class="cell-muted">just now</td>
|
||||
<td class="col-actions">
|
||||
<details class="menu kebab">
|
||||
<summary aria-label="Row actions for Priya Nair"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
|
||||
<div class="menu-pop">
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Eli Brandt"></td>
|
||||
<td><span class="cell-user"><span class="avatar" aria-hidden="true">EB</span><span class="cell-strong">Eli Brandt</span></span></td>
|
||||
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="b0d5dcd99ed2c2d1ded4c4f0d1d3ddd59ed9df">[email protected]</a></td>
|
||||
<td>Viewer</td>
|
||||
<td class="cell-muted">Sales</td>
|
||||
<td><span class="badge neg"><span class="dot"></span>Suspended</span></td>
|
||||
<td class="cell-muted">6 days ago</td>
|
||||
<td class="col-actions">
|
||||
<details class="menu kebab">
|
||||
<summary aria-label="Row actions for Eli Brandt"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
|
||||
<div class="menu-pop">
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Tomas Lindqvist"></td>
|
||||
<td><span class="cell-user"><span class="avatar" aria-hidden="true">TL</span><span class="cell-strong">Tomas Lindqvist</span></span></td>
|
||||
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="d9adb6b4b8aaf7b599b8bab4bcf7b0b6">[email protected]</a></td>
|
||||
<td>Member</td>
|
||||
<td class="cell-muted">Engineering</td>
|
||||
<td><span class="badge info"><span class="dot"></span>Invited</span></td>
|
||||
<td class="cell-muted">—</td>
|
||||
<td class="col-actions">
|
||||
<details class="menu kebab">
|
||||
<summary aria-label="Row actions for Tomas Lindqvist"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
|
||||
<div class="menu-pop">
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Hana Osei"></td>
|
||||
<td><span class="cell-user"><span class="avatar" aria-hidden="true">HO</span><span class="cell-strong">Hana Osei</span></span></td>
|
||||
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="e28a838c83cc8d91878ba283818f87cc8b8d">[email protected]</a></td>
|
||||
<td>Member</td>
|
||||
<td class="cell-muted">Design</td>
|
||||
<td><span class="badge pos"><span class="dot"></span>Active</span></td>
|
||||
<td class="cell-muted">21 min ago</td>
|
||||
<td class="col-actions">
|
||||
<details class="menu kebab">
|
||||
<summary aria-label="Row actions for Hana Osei"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
|
||||
<div class="menu-pop">
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Rafael Costa"></td>
|
||||
<td><span class="cell-user"><span class="avatar" aria-hidden="true">RC</span><span class="cell-strong">Rafael Costa</span></span></td>
|
||||
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="fd8f9c9b9c9891d39e928e899cbd9c9e9098d39492">[email protected]</a></td>
|
||||
<td>Admin</td>
|
||||
<td class="cell-muted">Operations</td>
|
||||
<td><span class="badge warn"><span class="dot"></span>Idle</span></td>
|
||||
<td class="cell-muted">1 hour ago</td>
|
||||
<td class="col-actions">
|
||||
<details class="menu kebab">
|
||||
<summary aria-label="Row actions for Rafael Costa"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
|
||||
<div class="menu-pop">
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Wen Li"></td>
|
||||
<td><span class="cell-user"><span class="avatar" aria-hidden="true">WL</span><span class="cell-strong">Wen Li</span></span></td>
|
||||
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="dfa8bab1f1b3b69fbebcb2baf1b6b0">[email protected]</a></td>
|
||||
<td>Viewer</td>
|
||||
<td class="cell-muted">Sales</td>
|
||||
<td><span class="badge pos"><span class="dot"></span>Active</span></td>
|
||||
<td class="cell-muted">44 min ago</td>
|
||||
<td class="col-actions">
|
||||
<details class="menu kebab">
|
||||
<summary aria-label="Row actions for Wen Li"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
|
||||
<div class="menu-pop">
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Nadia Farouk"></td>
|
||||
<td><span class="cell-user"><span class="avatar" aria-hidden="true">NF</span><span class="cell-strong">Nadia Farouk</span></span></td>
|
||||
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="4826292c2129662e293a273d2308292b252d662127">[email protected]</a></td>
|
||||
<td>Member</td>
|
||||
<td class="cell-muted">Engineering</td>
|
||||
<td><span class="badge neg"><span class="dot"></span>Suspended</span></td>
|
||||
<td class="cell-muted">12 days ago</td>
|
||||
<td class="col-actions">
|
||||
<details class="menu kebab">
|
||||
<summary aria-label="Row actions for Nadia Farouk"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
|
||||
<div class="menu-pop">
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Otto Berg"></td>
|
||||
<td><span class="cell-user"><span class="avatar" aria-hidden="true">OB</span><span class="cell-strong">Otto Berg</span></span></td>
|
||||
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="4a253e3e2564282f382d0a2b29272f642325">[email protected]</a></td>
|
||||
<td>Member</td>
|
||||
<td class="cell-muted">Design</td>
|
||||
<td><span class="badge info"><span class="dot"></span>Invited</span></td>
|
||||
<td class="cell-muted">—</td>
|
||||
<td class="col-actions">
|
||||
<details class="menu kebab">
|
||||
<summary aria-label="Row actions for Otto Berg"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
|
||||
<div class="menu-pop">
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Greta Holm"></td>
|
||||
<td><span class="cell-user"><span class="avatar" aria-hidden="true">GH</span><span class="cell-strong">Greta Holm</span></span></td>
|
||||
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="2047524554410e484f4c4d6041434d450e494f">[email protected]</a></td>
|
||||
<td>Admin</td>
|
||||
<td class="cell-muted">Operations</td>
|
||||
<td><span class="badge pos"><span class="dot"></span>Active</span></td>
|
||||
<td class="cell-muted">8 min ago</td>
|
||||
<td class="col-actions">
|
||||
<details class="menu kebab">
|
||||
<summary aria-label="Row actions for Greta Holm"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
|
||||
<div class="menu-pop">
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Yusuf Demir"></td>
|
||||
<td><span class="cell-user"><span class="avatar" aria-hidden="true">YD</span><span class="cell-strong">Yusuf Demir</span></span></td>
|
||||
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="2059555355460e44454d49526041434d450e494f">[email protected]</a></td>
|
||||
<td>Viewer</td>
|
||||
<td class="cell-muted">Sales</td>
|
||||
<td><span class="badge warn"><span class="dot"></span>Idle</span></td>
|
||||
<td class="cell-muted">5 hours ago</td>
|
||||
<td class="col-actions">
|
||||
<details class="menu kebab">
|
||||
<summary aria-label="Row actions for Yusuf Demir"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
|
||||
<div class="menu-pop">
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
|
||||
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ============ PAGINATION ============ -->
|
||||
<footer class="pager">
|
||||
<span>1–12 of <b>1,284</b></span>
|
||||
<div class="pager-rows">
|
||||
<label for="rows">Rows</label>
|
||||
<div class="select">
|
||||
<select id="rows" aria-label="Rows per page">
|
||||
<option>12</option><option>25</option><option>50</option><option>100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<nav class="page-nums" aria-label="Pagination">
|
||||
<button class="page-btn" disabled aria-label="Previous page"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></button>
|
||||
<button class="page-btn" aria-current="page">1</button>
|
||||
<button class="page-btn">2</button>
|
||||
<button class="page-btn">3</button>
|
||||
<button class="page-btn">…</button>
|
||||
<button class="page-btn">107</button>
|
||||
<button class="page-btn" aria-label="Next page"><svg class="ico ico-sm"><use href="#i-chev"/></svg></button>
|
||||
</nav>
|
||||
</footer>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false" src="/cdn-cgi/scripts/5c5dd728/cloudflare-static/email-decode.min.js"></script></body>
|
||||
</html>
|
||||
@@ -1,216 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign in — Console</title>
|
||||
<link rel="stylesheet" href="../public/css/styles.css">
|
||||
<link rel="stylesheet" href="../public/css/auth.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ============ ICON SPRITE — Lucide (ISC) ============ -->
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
|
||||
<symbol id="i-box" viewBox="0 0 24 24"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol>
|
||||
<symbol id="i-mail" viewBox="0 0 24 24"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
|
||||
<symbol id="i-user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
|
||||
<symbol id="i-arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></symbol>
|
||||
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></symbol>
|
||||
</svg>
|
||||
|
||||
<main class="auth-stage">
|
||||
<div class="auth">
|
||||
|
||||
<div class="auth-brand">
|
||||
<span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box"/></svg></span>
|
||||
<span class="brand-name">Console</span>
|
||||
</div>
|
||||
|
||||
<!-- =================== LOGIN =================== -->
|
||||
<section id="login" class="auth-view" aria-labelledby="login-title">
|
||||
<form class="auth-card" method="post" action="#">
|
||||
<div class="auth-head">
|
||||
<h1 id="login-title">Sign in</h1>
|
||||
<p class="auth-sub">Welcome back. Enter your details to continue.</p>
|
||||
</div>
|
||||
|
||||
<!-- SSO section (toggle on/off) -->
|
||||
<div class="sso" aria-label="Single sign-on options">
|
||||
<ul class="sso-list">
|
||||
<!-- add a provider: copy one <li> and change the logo + label -->
|
||||
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">G</span><span class="sso-label">Continue with Google</span></button></li>
|
||||
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">M</span><span class="sso-label">Continue with Microsoft</span></button></li>
|
||||
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true"><svg class="ico ico-sm"><use href="#i-shield"/></svg></span><span class="sso-label">Continue with SAML SSO</span></button></li>
|
||||
</ul>
|
||||
<div class="auth-divider">or</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-form">
|
||||
<div class="field">
|
||||
<label for="login-email">Email</label>
|
||||
<div class="input-wrap">
|
||||
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-mail"/></svg>
|
||||
<input class="input has-ico" id="login-email" name="email" type="email" autocomplete="email" placeholder="you@company.com" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-top">
|
||||
<label for="login-password">Password</label>
|
||||
<a class="field-link" href="#forgot">Forgot password?</a>
|
||||
</div>
|
||||
<div class="input-wrap">
|
||||
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-lock"/></svg>
|
||||
<input class="input has-ico" id="login-password" name="password" type="password" autocomplete="current-password" placeholder="••••••••" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="check remember"><input type="checkbox" name="remember" value="1"> Keep me signed in</label>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">Sign in</button>
|
||||
</div>
|
||||
|
||||
<p class="auth-alt">Don't have an account? <a href="#register">Create one</a></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- =================== REGISTER =================== -->
|
||||
<section id="register" class="auth-view" aria-labelledby="register-title">
|
||||
<form class="auth-card" method="post" action="#">
|
||||
<div class="auth-head">
|
||||
<h1 id="register-title">Create account</h1>
|
||||
<p class="auth-sub">Get started — it only takes a minute.</p>
|
||||
</div>
|
||||
|
||||
<div class="sso" aria-label="Single sign-on options">
|
||||
<ul class="sso-list">
|
||||
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">G</span><span class="sso-label">Sign up with Google</span></button></li>
|
||||
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">M</span><span class="sso-label">Sign up with Microsoft</span></button></li>
|
||||
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true"><svg class="ico ico-sm"><use href="#i-shield"/></svg></span><span class="sso-label">Sign up with SAML SSO</span></button></li>
|
||||
</ul>
|
||||
<div class="auth-divider">or</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-form">
|
||||
<div class="register-alert alert alert-neg" role="alert">
|
||||
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"/></svg>
|
||||
<div class="alert-body"><strong>Please fix the highlighted fields</strong><span>A couple of details need your attention before we can create your account.</span></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-top">
|
||||
<label for="reg-name">Name</label>
|
||||
<span class="optional">Optional</span>
|
||||
</div>
|
||||
<div class="input-wrap">
|
||||
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-user"/></svg>
|
||||
<input class="input has-ico" id="reg-name" name="name" type="text" autocomplete="name" placeholder="Avery Kline">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="reg-email">Email</label>
|
||||
<div class="input-wrap">
|
||||
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-mail"/></svg>
|
||||
<input class="input has-ico" id="reg-email" name="email" type="email" autocomplete="email" placeholder="you@company.com" aria-describedby="reg-email-err" required>
|
||||
</div>
|
||||
<p class="field-error err-email" id="reg-email-err" role="alert">
|
||||
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"/></svg>
|
||||
<span>This email is already used by another account. <a href="#login">Sign in instead</a>.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="reg-password">Password</label>
|
||||
<div class="input-wrap">
|
||||
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-lock"/></svg>
|
||||
<input class="input has-ico" id="reg-password" name="password" type="password" autocomplete="new-password" placeholder="At least 8 characters" minlength="8" aria-describedby="reg-password-err" required>
|
||||
</div>
|
||||
<span class="field-hint">Use 8 or more characters.</span>
|
||||
<p class="field-error err-password" id="reg-password-err" role="alert">
|
||||
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"/></svg>
|
||||
<span>Password must be at least 8 characters.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">Create account</button>
|
||||
</div>
|
||||
|
||||
<p class="auth-alt">Already have an account? <a href="#login">Sign in</a></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- =================== FORGOT PASSWORD =================== -->
|
||||
<section id="forgot" class="auth-view" aria-labelledby="forgot-title">
|
||||
<form class="auth-card" method="post">
|
||||
<div class="auth-head">
|
||||
<a class="auth-back" href="#login"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"/></svg>Back to sign in</a>
|
||||
<h1 id="forgot-title">Reset password</h1>
|
||||
<p class="auth-sub">Enter your email and we'll send you a reset link.</p>
|
||||
</div>
|
||||
|
||||
<!-- feedback (shown by server via .state-sent / .state-error on #forgot) -->
|
||||
<div class="forgot-alert is-sent alert alert-pos" role="status">
|
||||
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg>
|
||||
<div class="alert-body"><strong>Check your email</strong><span>If an account exists for that address, a reset link is on its way.</span></div>
|
||||
</div>
|
||||
<div class="forgot-alert is-error alert alert-neg" role="alert">
|
||||
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"/></svg>
|
||||
<div class="alert-body"><strong>Couldn't send the link</strong><span>Something went wrong on our end. Please try again.</span></div>
|
||||
</div>
|
||||
|
||||
<div class="auth-form">
|
||||
<div class="field">
|
||||
<label for="forgot-email">Email</label>
|
||||
<div class="input-wrap">
|
||||
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-mail"/></svg>
|
||||
<input class="input has-ico" id="forgot-email" name="email" type="email" autocomplete="email" placeholder="you@company.com" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">Send reset link</button>
|
||||
</div>
|
||||
|
||||
<p class="auth-alt">Remembered it? <a href="#login">Sign in</a></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ============ TEMPLATE PREVIEW CONTROLS (remove for production) ============ -->
|
||||
<div class="tpl-controls" role="group" aria-label="Template preview controls">
|
||||
<span class="tpl-label">Preview</span>
|
||||
<div class="theme-switch" role="radiogroup" aria-label="Color theme">
|
||||
<label><input type="radio" name="theme" id="theme-light"><span>Light</span></label>
|
||||
<label><input type="radio" name="theme" id="theme-auto" checked><span>Auto</span></label>
|
||||
<label><input type="radio" name="theme" id="theme-dark"><span>Dark</span></label>
|
||||
</div>
|
||||
<span class="tpl-sep" aria-hidden="true"></span>
|
||||
<label class="tpl-toggle">
|
||||
<input type="checkbox" id="sso-toggle" checked>
|
||||
<span class="tpl-track" aria-hidden="true"></span>
|
||||
SSO
|
||||
</label>
|
||||
<span class="tpl-sep tpl-forgot" aria-hidden="true"></span>
|
||||
<div class="tpl-forgot">
|
||||
<div class="segmented" role="radiogroup" aria-label="Forgot-password state (preview)">
|
||||
<label><input type="radio" name="fstate" id="fstate-default" checked><span>Default</span></label>
|
||||
<label><input type="radio" name="fstate" id="fstate-sent"><span>Sent</span></label>
|
||||
<label><input type="radio" name="fstate" id="fstate-error"><span>Error</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<span class="tpl-sep tpl-register" aria-hidden="true"></span>
|
||||
<div class="tpl-register">
|
||||
<div class="segmented" role="radiogroup" aria-label="Register state (preview)">
|
||||
<label><input type="radio" name="rstate" id="rstate-default" checked><span>Default</span></label>
|
||||
<label><input type="radio" name="rstate" id="rstate-taken"><span>Email taken</span></label>
|
||||
<label><input type="radio" name="rstate" id="rstate-combined"><span>Multiple</span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
# plainpages (README: "OAuth2 provider"). The web app implements Hydra's login &
|
||||
# consent steps at the URLs below, authenticating the user via their Kratos session;
|
||||
# Hydra mints the tokens. DSN comes from the env (the per-service hydra DB). Only
|
||||
# relevant when external apps log in through us — nothing first-party needs it (§6).
|
||||
# relevant when external apps log in through us — nothing first-party needs it.
|
||||
serve:
|
||||
public:
|
||||
port: 4444
|
||||
@@ -10,7 +10,7 @@ serve:
|
||||
port: 4445
|
||||
|
||||
# issuer = the public OAuth2 URL clients use; login/consent/logout hand the browser to
|
||||
# our themed handlers (§6). Dev defaults (http) — prod overrides issuer via env (https).
|
||||
# our themed handlers. Dev defaults (http) — prod overrides issuer via env (https).
|
||||
urls:
|
||||
self:
|
||||
issuer: http://127.0.0.1:4444/
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Ory Keto — authorization (ReBAC), the source of truth for roles/groups and the rare
|
||||
# Ory Keto — authorization (ReBAC), the source of truth for permissions/groups and the rare
|
||||
# fine-grained check (README: three tiers of "may I?"). The permission model lives in
|
||||
# namespaces.keto.ts (OPL); DSN comes from the env (the per-service keto DB). The web
|
||||
# app never connects directly — it calls the read (4466) / write (4467) APIs, the ports
|
||||
|
||||
@@ -4,28 +4,30 @@
|
||||
// identity ids (== the JWT `sub`).
|
||||
import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
|
||||
|
||||
// A human identity. Subjects are written as `user:<kratos-identity-id>`.
|
||||
// A person. Ory calls this an "identity" (Kratos owns the record); Plainpages says "user"
|
||||
// throughout. Subjects are written as `user:<kratos-identity-id>`.
|
||||
class User implements Namespace {}
|
||||
|
||||
// A subject set: a named collection of users (and nested groups), resolved transitively.
|
||||
// The admin "Groups" screen (§5) manages membership; checks expand it automatically.
|
||||
// A named set of users (and nested groups), resolved transitively. The admin "Groups"
|
||||
// screen manages membership; checks expand it automatically.
|
||||
class Group implements Namespace {
|
||||
related: {
|
||||
members: (User | SubjectSet<Group, "members">)[]
|
||||
}
|
||||
}
|
||||
|
||||
// A coarse role — the source of truth for the JWT `roles` claim. At login the app reads
|
||||
// `role:<name>#members@user:<id>` from Keto and projects the result into the token
|
||||
// (README: Login → session JWT). A group can hold a role, so members can be users or groups.
|
||||
class Role implements Namespace {
|
||||
// A coarse permission — an operation a route or menu item gates on, and the source of truth
|
||||
// for the JWT `permissions` claim. At login the app reads `Permission:<name>#granted@user:<id>`
|
||||
// from Keto and projects the result into the token (README: Login → session JWT). A group can
|
||||
// hold a permission, so grants go to a user or to a whole group.
|
||||
class Permission implements Namespace {
|
||||
related: {
|
||||
members: (User | SubjectSet<Group, "members">)[]
|
||||
granted: (User | SubjectSet<Group, "members">)[]
|
||||
}
|
||||
}
|
||||
|
||||
// A fine-grained, relationship-checked resource — README's third "may I?" tier, the rare
|
||||
// live Keto check (e.g. sharing/delegation). Permissions nest: owner ⊇ editor ⊇ viewer.
|
||||
// live Keto check (e.g. sharing/delegation). Permits nest: owner ⊇ editor ⊇ viewer.
|
||||
// Grants accept a user directly or any member of a group.
|
||||
class Resource implements Namespace {
|
||||
related: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Browser-E2E overlay (compose.e2e-full.yml) — merged after kratos.yml via a second `-c`. The
|
||||
# Browser-E2E overlay (e2e-tests/compose.full.yml) — merged after kratos.yml via a second `-c`. The
|
||||
# full-flow suite drives the real browser, so web + Kratos must share one origin (the `proxy`
|
||||
# gateway, e2e/proxy.mjs). Point Kratos' public base_url and every self-service URL at that host so
|
||||
# gateway, e2e-tests/proxy.ts). Point Kratos' public base_url and every self-service URL at that host so
|
||||
# the flow action, the session cookie, and the after-login redirect all stay same-origin as the
|
||||
# browser sees them. The normal (10m) tokenizer TTL from kratos.yml is kept — no re-mint mid-test.
|
||||
serve:
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# E2E overlay (compose.e2e-auth.yml) — merged after kratos.yml via a second `-c`. Two changes
|
||||
# E2E overlay (e2e-tests/compose.auth.yml) — merged after kratos.yml via a second `-c`. Two changes
|
||||
# that let the auth-refresh suite exercise token timeout + re-mint in seconds:
|
||||
# 1. A very short session→JWT tokenizer TTL, so the JWT lapses while the Kratos session lives.
|
||||
# 2. A public base_url on the compose-network hostname, so the Playwright runner can drive the
|
||||
|
||||
+22
-19
@@ -1,27 +1,30 @@
|
||||
# Ory Kratos — identity & self-service auth. Identity schema (email, name) +
|
||||
# password login; recovery & verification run on email codes. Every self-service
|
||||
# flow returns to our own themed routes (§4 renders the fields). DSN + prod
|
||||
# flow returns to our own themed routes (renders the fields). DSN + prod
|
||||
# courier/secrets come from the env. Session→JWT tokenizer wired below (signing
|
||||
# key in tokenizer/jwks.json).
|
||||
serve:
|
||||
public:
|
||||
base_url: http://127.0.0.1:4433/
|
||||
base_url: http://localhost:4433/
|
||||
cors:
|
||||
enabled: false
|
||||
admin:
|
||||
base_url: http://kratos:4434/
|
||||
|
||||
selfservice:
|
||||
default_browser_return_url: http://127.0.0.1:3000/
|
||||
# Browser-facing URLs default to localhost (clean clone = APP_URL's default, so the host the web app
|
||||
# canonicalises to matches the host the login form POSTs to — cookies share one host). Driven by
|
||||
# APP_URL: compose overrides these from ${APP_URL} (compose.override.yml), so there's one knob.
|
||||
default_browser_return_url: http://localhost:3000/
|
||||
allowed_return_urls:
|
||||
- http://127.0.0.1:3000
|
||||
- http://localhost:3000
|
||||
methods:
|
||||
password:
|
||||
enabled: true
|
||||
code: # email one-time code — powers recovery + verification (not login)
|
||||
enabled: true
|
||||
# Social sign-in, OFF by default → clean clone is password-only. Activate via env only
|
||||
# (no code; the whole-array form is the only env-settable one Kratos offers); §4 derives
|
||||
# (no code; the whole-array form is the only env-settable one Kratos offers); derives
|
||||
# the buttons from this list. SAML isn't in OSS Kratos — bridge it as OIDC (README).
|
||||
# SELFSERVICE_METHODS_OIDC_ENABLED=true
|
||||
# SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS=[{"id":"google","provider":"google",
|
||||
@@ -33,37 +36,37 @@ selfservice:
|
||||
providers: []
|
||||
flows:
|
||||
error:
|
||||
ui_url: http://127.0.0.1:3000/error
|
||||
ui_url: http://localhost:3000/error
|
||||
login:
|
||||
ui_url: http://127.0.0.1:3000/login
|
||||
ui_url: http://localhost:3000/login
|
||||
after:
|
||||
# After authenticating, land on our completion route — it mints the session JWT
|
||||
# (roles from Keto → metadata_public projection → tokenize) and sets our cookie (§4).
|
||||
default_browser_return_url: http://127.0.0.1:3000/auth/complete
|
||||
# (permissions from Keto → metadata_public projection → tokenize) and sets our cookie.
|
||||
default_browser_return_url: http://localhost:3000/auth/complete
|
||||
registration:
|
||||
ui_url: http://127.0.0.1:3000/registration
|
||||
ui_url: http://localhost:3000/registration
|
||||
after:
|
||||
password:
|
||||
hooks:
|
||||
- hook: session # log in immediately after sign-up
|
||||
- hook: show_verification_ui
|
||||
settings:
|
||||
ui_url: http://127.0.0.1:3000/settings
|
||||
ui_url: http://localhost:3000/settings
|
||||
privileged_session_max_age: 15m
|
||||
required_aal: highest_available
|
||||
recovery:
|
||||
enabled: true
|
||||
use: code
|
||||
ui_url: http://127.0.0.1:3000/recovery
|
||||
ui_url: http://localhost:3000/recovery
|
||||
verification:
|
||||
enabled: true
|
||||
use: code
|
||||
ui_url: http://127.0.0.1:3000/verification
|
||||
ui_url: http://localhost:3000/verification
|
||||
after:
|
||||
default_browser_return_url: http://127.0.0.1:3000/
|
||||
default_browser_return_url: http://localhost:3000/
|
||||
logout:
|
||||
after:
|
||||
default_browser_return_url: http://127.0.0.1:3000/login
|
||||
default_browser_return_url: http://localhost:3000/login
|
||||
|
||||
# Dev mail catcher (compose.override.yml). Prod overrides via COURIER_SMTP_CONNECTION_URI.
|
||||
courier:
|
||||
@@ -79,7 +82,7 @@ identity:
|
||||
url: file:///etc/config/kratos/identity.schema.json
|
||||
|
||||
# "Stay signed in" backbone: a long-lived Kratos session that the app re-mints the
|
||||
# short-lived (~10m) JWT off (§4). Sliding refresh — an active session is extended
|
||||
# short-lived (~10m) JWT off. Sliding refresh — an active session is extended
|
||||
# back to full lifespan only once it's within earliest_possible_extend of expiry,
|
||||
# so frequent users never lapse without a DB write per request.
|
||||
session:
|
||||
@@ -89,9 +92,9 @@ session:
|
||||
name: plainpages_session
|
||||
persistent: true # survive browser restarts
|
||||
same_site: Lax
|
||||
# Session→JWT tokenizer (§4): whoami(tokenize_as: plainpages) mints a short-lived,
|
||||
# Session→JWT tokenizer: whoami(tokenize_as: plainpages) mints a short-lived,
|
||||
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the
|
||||
# committed Jsonnet mapper (sub = identity id, email from traits, roles from the
|
||||
# committed Jsonnet mapper (sub = identity id, email from traits, permissions from the
|
||||
# metadata_public projection); signed with tokenizer/jwks.json.
|
||||
whoami:
|
||||
tokenizer:
|
||||
@@ -102,7 +105,7 @@ session:
|
||||
claims_mapper_url: file:///etc/config/kratos/tokenizer/plainpages.jsonnet
|
||||
jwks_url: file:///etc/config/kratos/tokenizer/jwks.json
|
||||
|
||||
# Dev throwaways — production supplies real secrets via env (§3). cipher = 32 chars.
|
||||
# Dev throwaways — production supplies real secrets via env. cipher = 32 chars.
|
||||
secrets:
|
||||
cookie:
|
||||
- PLEASE-CHANGE-ME-dev-kratos-cookie-secret
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Session→JWT claims mapper for the `plainpages` tokenizer (§4). Kratos exposes the
|
||||
// Session→JWT claims mapper for the `plainpages` tokenizer. Kratos exposes the
|
||||
// session as `session`; `sub` is set from the identity id (subject_source: id) and
|
||||
// can't be overridden here. roles come from metadata_public — the per-login projection
|
||||
// of Keto roles the app refreshes at login (metadata_admin is NOT carried in the session
|
||||
// can't be overridden here. permissions come from metadata_public — the per-login projection
|
||||
// of Keto permissions the app refreshes at login (metadata_admin is NOT carried in the session
|
||||
// the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list.
|
||||
local session = std.extVar('session');
|
||||
local meta =
|
||||
@@ -12,6 +12,6 @@ local meta =
|
||||
{
|
||||
claims: {
|
||||
email: session.identity.traits.email,
|
||||
roles: if std.objectHas(meta, 'roles') then meta.roles else [],
|
||||
permissions: if std.objectHas(meta, 'permissions') then meta.permissions else [],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user