Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 702e42de09 | |||
| 98bdd2c1b5 | |||
| 12913402a6 | |||
| 7dee80a976 | |||
| c0fe8b0a82 | |||
| fb7d20a7db | |||
| 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 |
+4
-2
@@ -3,6 +3,8 @@ node_modules
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
html-css-foundation
|
|
||||||
|
|
||||||
e2e/artifacts
|
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@v4.2.2
|
||||||
|
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,18 @@
|
|||||||
|
name: Mirror
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['**']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
github-mirror:
|
||||||
|
runs-on: docker-host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- run: |
|
||||||
|
git push --force \
|
||||||
|
"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@v4.2.2
|
||||||
|
- 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.18.1-alpine3.24 node registry-cleanup/cleanup.ts
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: Release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v[0-9]+.[0-9]+.[0-9]+']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
retag-image:
|
||||||
|
runs-on: docker-host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4.2.2
|
||||||
|
- 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: |
|
||||||
|
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||||
|
VERSION=${GIT_TAG#v}
|
||||||
|
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
|
||||||
|
docker pull "$REPO:$COMMIT" \
|
||||||
|
|| { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; }
|
||||||
|
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
|
||||||
|
docker tag "$REPO:$COMMIT" "$REPO:$TAG"
|
||||||
|
docker push "$REPO:$TAG"
|
||||||
|
done
|
||||||
|
- name: Sync the release tags to Docker Hub
|
||||||
|
env:
|
||||||
|
DOCKERHUB_REPO: docker.io/${{ github.repository }}
|
||||||
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
|
||||||
|
GIT_TAG: ${{ github.ref_name }}
|
||||||
|
REPO: gitea.larvit.se/${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||||
|
VERSION=${GIT_TAG#v}
|
||||||
|
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|
||||||
|
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; }
|
||||||
|
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
|
||||||
|
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
|
||||||
|
docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG"
|
||||||
|
docker push "$DOCKERHUB_REPO:$TAG"
|
||||||
|
done
|
||||||
|
- name: Log out of the registries
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker logout gitea.larvit.se
|
||||||
|
docker logout docker.io
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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.6.0
|
||||||
|
|
||||||
|
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
||||||
|
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
|
||||||
|
# trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit
|
||||||
|
# (a human owns that release) or nothing new merged. ff-only merges keep the renovate commit's
|
||||||
|
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
|
||||||
|
# pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the
|
||||||
|
# already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
|
||||||
|
auto-release:
|
||||||
|
runs-on: docker-host
|
||||||
|
needs: renovate
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4.2.2
|
||||||
|
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; })
|
||||||
|
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.18.1-alpine3.24 \
|
||||||
|
node auto-release/next-version.ts "$LATEST" $BUMPS)
|
||||||
|
echo "Releasing $LATEST -> $NEXT"
|
||||||
|
git tag "$NEXT" origin/main
|
||||||
|
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
|
||||||
+9
-1
@@ -4,4 +4,12 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
# Playwright E2E outputs (screenshots, html report, traces)
|
# Playwright E2E outputs (screenshots, html report, traces)
|
||||||
e2e/artifacts/
|
e2e-tests/artifacts/
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -3,9 +3,15 @@
|
|||||||
Guidance for AI agents and contributors working in this repo. Read `README.md` for
|
Guidance for AI agents and contributors working in this repo. Read `README.md` for
|
||||||
commands and layout.
|
commands and layout.
|
||||||
|
|
||||||
|
## 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)
|
## Project priorities (do not erode)
|
||||||
|
|
||||||
1. **Simplicity** — prefer the smallest, most readable solution.
|
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`,
|
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`,
|
||||||
`@larvit/log` — the last itself zero-dependency, for structured/OTLP logging).
|
`@larvit/log` — the last itself zero-dependency, for structured/OTLP logging).
|
||||||
Prefer the Node standard library; justify any new dependency; do not add
|
Prefer the Node standard library; justify any new dependency; do not add
|
||||||
@@ -16,7 +22,8 @@ commands and layout.
|
|||||||
folders** under `plugins/` that fetch their data from upstream services, not as
|
folders** under `plugins/` that fetch their data from upstream services, not as
|
||||||
core code. See `README.md` for the architecture.
|
core code. See `README.md` for the architecture.
|
||||||
3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
|
3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
|
||||||
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way.
|
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer
|
||||||
|
exact types and limit nullable and multi option types when possible. KISS.
|
||||||
4. **Environment-agnostic** — the app never asks *which environment* it runs in; there is
|
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
|
no `NODE_ENV` (or equivalent) branching. Every behaviour is an **explicit config
|
||||||
toggle** (e.g. `CACHE_TEMPLATES`, `REQUIRE_SECURE_SECRETS`, a future "disable email"),
|
toggle** (e.g. `CACHE_TEMPLATES`, `REQUIRE_SECURE_SECRETS`, a future "disable email"),
|
||||||
@@ -31,6 +38,61 @@ commands and layout.
|
|||||||
Tests stay independent and side-effect-free so the suite runs `fullyParallel` — keep it
|
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
|
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`.
|
fast. E2E runs in Docker against the live stack — see `README.md`.
|
||||||
|
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the
|
||||||
|
only way to add domain features. It optimises for being **powerful, predictable, and
|
||||||
|
overloadable** (a plugin can take over as much of a page as it wants), and the host
|
||||||
|
**fails loud at boot/discovery** (bad manifest, version mismatch, or conflict stops
|
||||||
|
startup with a clear message) rather than sandboxing at runtime. Runtime crash-isolation
|
||||||
|
is a deliberate **non-goal** — diagnose at deploy time, not in production. Keep this
|
||||||
|
contract stable; see `README.md` → Building plugins.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/`
|
||||||
|
(session-JWT hot path, guards, and the Ory REST clients), `plugin-host/`
|
||||||
|
(discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel + `system.ts`, the
|
||||||
|
`ctx.system` capability surface), and `ui/` (design-system view-models + menu/chrome);
|
||||||
|
`server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests
|
||||||
|
are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its
|
||||||
|
concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain
|
||||||
|
screens** — even the admin GUI (users/groups/roles) is a drop-in plugin (`examples/plugins/admin/`),
|
||||||
|
not `src/` code.
|
||||||
|
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the
|
||||||
|
base request context. It protects the I/O-free hot path on the public, bot-hit landing
|
||||||
|
(`/`). (Declined twice.)
|
||||||
|
- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web`
|
||||||
|
never touches SMTP. Customization is Kratos' built-in `courier.template_override_path`,
|
||||||
|
not app code — keeping `web` stateless and dependency-light (see [Email](README.md#email)).
|
||||||
|
- **Plugins and config import the host only via package.json `imports`** — `#plugin-api`
|
||||||
|
→ `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts` — never a
|
||||||
|
relative `../../src/*` path. These two barrels are the whole author/operator contract
|
||||||
|
surface; the `src/*` behind them may be refactored freely. Depth-independent and
|
||||||
|
refactor-stable by design — don't "fix" a `#`-import back to a relative path.
|
||||||
|
**One caveat:** `#plugin-api` re-exports the Ory client types for the `ctx.system` surface
|
||||||
|
(`KratosAdmin`/`KetoClient`/`HydraAdmin` + their DTOs and error classes). Those shapes are
|
||||||
|
therefore **contract-visible** — changing them is a plugin-API break needing a major
|
||||||
|
`apiVersion` bump, not a free refactor. Keep the Ory clients stable, or bump the version.
|
||||||
|
- **A plugin/config folder must stay a plain folder — no `package.json` of its own.** Node
|
||||||
|
resolves `#`-specifiers against the nearest parent `package.json`; a `package.json` inside
|
||||||
|
the folder becomes its own scope and `#plugin-api`/`#menu-config` stop resolving. Accepted
|
||||||
|
cost of the `#`-import contract (fits the stateless, no-per-plugin-deps ethos). A plugin
|
||||||
|
kept in its own repo typechecks against the barrel only when mounted under the host tree
|
||||||
|
(or by adding a local `imports` map / vendored stub).
|
||||||
|
- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to
|
||||||
|
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirror folders are in
|
||||||
|
`tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks
|
||||||
|
in place *and* copies across unchanged. Never commit real plugins/config into the root
|
||||||
|
mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise).
|
||||||
|
- **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
|
## Docker only — no host tooling
|
||||||
|
|
||||||
@@ -44,19 +106,72 @@ docker compose run --rm --no-deps web npm test # tests
|
|||||||
docker compose -f compose.yml up --build -d # production
|
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 (`docker compose up`, sign in) and a *minimal* plugin live. Nothing comes
|
||||||
|
before Quick start — no philosophy, no rationale. Keep its commands copy-pasteable and the
|
||||||
|
example plugin as small as possible; deeper detail lives in its own section, linked.
|
||||||
|
2. **Returning developer (rest).** A **Contents** ToC immediately after Quick start, then
|
||||||
|
sections ordered by **what a developer adopting Plainpages reaches for, in priority
|
||||||
|
order** — not by architectural layering. The value that sets the order: getting up and
|
||||||
|
running **building plugins** comes first, then **configuring and securing** the system
|
||||||
|
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
|
||||||
|
deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
|
||||||
|
Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
||||||
|
Architecture → Testing → Production → Observability → the JWT-rotation runbook → the
|
||||||
|
Project-layout file map → Extending. When adding a section, place it by this value (how
|
||||||
|
early an adopter needs it), not by where it sits in the stack.
|
||||||
|
|
||||||
|
When editing: put content in the section it belongs to (don't prepend rationale above Quick
|
||||||
|
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
|
||||||
|
one home, linking to it rather than restating (credentials, env vars, rotation steps).
|
||||||
|
|
||||||
|
**Don't document internals here.** How a script reaches a decision, why one run behaved
|
||||||
|
differently from another, what a function guards — a developer doesn't need it day to day and
|
||||||
|
can read it off the code or a run's log in seconds. Prose like that only makes the README
|
||||||
|
longer and harder to consume, for humans and machines alike. It belongs in the code it
|
||||||
|
describes, or nowhere. The README earns its length on what you cannot dig out: how to use and
|
||||||
|
operate Plainpages, the external contracts, and one-time setup (secrets, accounts, tokens).
|
||||||
|
Same test before adding a row to a table or the file map — a clause, not a paragraph.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
|
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
|
||||||
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or
|
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or
|
||||||
decorators. Import local modules with their `.ts` extension.
|
decorators. Import local modules with their `.ts` extension.
|
||||||
|
- **No `.mjs`.** Write modules as `.ts` (Prio 1) — even standalone scripts run in bare
|
||||||
|
`node:24` containers (the e2e mock servers, `examples/shifts-upstream/server.ts`): Node
|
||||||
|
strips types and detects ESM from syntax, no package.json needed. If a file genuinely
|
||||||
|
must be plain JavaScript, use `.js` (Prio 2); `"type": "module"` is already set in both
|
||||||
|
`package.json`s, so `.js` is ESM.
|
||||||
- **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit.
|
- **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.
|
- 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.
|
- Tests use the built-in `node --test` runner — no test framework dependency.
|
||||||
- English everywhere. Keep code comments short and information-dense.
|
- English everywhere. Keep code comments short and information-dense. Self explained code
|
||||||
|
without any comment at all is the preferred solution.
|
||||||
|
- Do not comment about history in the code or README. Like "This function included X before,
|
||||||
|
but it moved to Y".
|
||||||
|
- Do not comment about the absence of things, if it is not very unexpected. Banned is things
|
||||||
|
like "This function does not calculate pi, that is done in function Z".
|
||||||
- Pin all dependencies and Docker images to exact, human-readable **semantic
|
- Pin all dependencies and Docker images to exact, human-readable **semantic
|
||||||
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
|
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
|
||||||
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
|
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
|
||||||
`node:24.16.0-alpine3.24`).
|
`node:24.16.0-alpine3.24`).
|
||||||
- Run the stability reviewer agent after every implementation of something that can be like
|
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version the
|
||||||
a PR. That includes an implementation from the todo file that is pushed directly to master.
|
plugin was built against — bumped by hand on rebuild, **never** the host's
|
||||||
Skip this if the changes are purely documentation and/or comments.
|
`HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the
|
||||||
|
host, so `checkApiVersion` can never fire and a breaking change slips through silently.
|
||||||
|
- **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler
|
||||||
|
per `{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed
|
||||||
|
`%`-encoding — no manual path-slicing/decoding). Don't funnel many routes into one dispatcher
|
||||||
|
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
|
||||||
|
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
|
||||||
|
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
|
||||||
|
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
|
||||||
|
agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
|
||||||
|
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
|
||||||
|
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
|
||||||
|
and not "ids=x,y".
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
||||||
FROM node:24.16.0-alpine3.24
|
FROM node:24.18.1-alpine3.24
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -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,193 @@
|
|||||||
|
# Plainpages
|
||||||
|
|
||||||
|
A self-hostable foundation for server-rendered web apps — public or gated pages from a
|
||||||
|
zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in.
|
||||||
|
Every domain feature is a drop-in plugin folder; the app is stateless, no build step.
|
||||||
|
|
||||||
|
**Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>**
|
||||||
|
([GitHub mirror](https://github.com/larvit/plainpages))
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
`X.Y.Z` · `X.Y` · `X` · `latest` — each is a release promoted from a CI-gated build.
|
||||||
|
Pin the exact `X.Y.Z` you deploy.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
This image is the Plainpages web app plus its one-shot bootstrap seeder. It runs
|
||||||
|
alongside its Ory sidecars (Kratos, Keto) and Postgres — and it **ships their config**,
|
||||||
|
so there is nothing to clone. In an empty directory, save this as `compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: larvit/plainpages:0.0.2
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
APP_URL: http://localhost:3000
|
||||||
|
depends_on:
|
||||||
|
bootstrap:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
kratos:
|
||||||
|
condition: service_healthy
|
||||||
|
keto:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
|
||||||
|
- ./plugins:/app/plugins
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
|
||||||
|
bootstrap:
|
||||||
|
image: larvit/plainpages:0.0.2
|
||||||
|
command: node src/auth/bootstrap.ts
|
||||||
|
depends_on:
|
||||||
|
kratos:
|
||||||
|
condition: service_healthy
|
||||||
|
keto:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
|
||||||
|
- ./plugins:/app/plugins:ro
|
||||||
|
restart: "on-failure:5"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:18.4-alpine3.23
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ory
|
||||||
|
POSTGRES_PASSWORD: ory
|
||||||
|
POSTGRES_USER: ory
|
||||||
|
volumes:
|
||||||
|
- ./ory/postgres/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
- pgdata:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ory -d ory"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
kratos-migrate:
|
||||||
|
image: oryd/kratos:v26.2.0
|
||||||
|
command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DSN: postgres://ory:ory@postgres:5432/kratos?sslmode=disable
|
||||||
|
volumes:
|
||||||
|
- ./ory/kratos:/etc/config/kratos:ro
|
||||||
|
restart: on-failure
|
||||||
|
|
||||||
|
kratos:
|
||||||
|
image: oryd/kratos:v26.2.0
|
||||||
|
command: serve -c /etc/config/kratos/kratos.yml --watch-courier
|
||||||
|
ports:
|
||||||
|
- "4433:4433" # the login form POSTs straight to Kratos from the browser
|
||||||
|
depends_on:
|
||||||
|
kratos-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
DSN: postgres://ory:ory@postgres:5432/kratos?sslmode=disable
|
||||||
|
volumes:
|
||||||
|
- ./ory/kratos:/etc/config/kratos:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4433/health/ready"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
keto-migrate:
|
||||||
|
image: oryd/keto:v26.2.0
|
||||||
|
command: -c /etc/config/keto/keto.yml migrate up -y
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DSN: postgres://ory:ory@postgres:5432/keto?sslmode=disable
|
||||||
|
volumes:
|
||||||
|
- ./ory/keto:/etc/config/keto:ro
|
||||||
|
restart: on-failure
|
||||||
|
|
||||||
|
keto:
|
||||||
|
image: oryd/keto:v26.2.0
|
||||||
|
command: serve -c /etc/config/keto/keto.yml
|
||||||
|
depends_on:
|
||||||
|
keto-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
DSN: postgres://ory:ory@postgres:5432/keto?sslmode=disable
|
||||||
|
volumes:
|
||||||
|
- ./ory/keto:/etc/config/keto:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4466/health/ready"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:v1.30.1
|
||||||
|
ports:
|
||||||
|
- "8025:8025"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
```
|
||||||
|
|
||||||
|
Extract the Ory config the image ships, then start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm larvit/plainpages:0.0.2 tar -cf - ory | tar -xf -
|
||||||
|
mkdir -p plugins
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://localhost:3000> and sign in as `admin@plainpages.local` / `admin`.
|
||||||
|
|
||||||
|
This quick start runs http-on-localhost with dev-throwaway secrets, and omits Hydra (the
|
||||||
|
OAuth2 provider — only needed when other apps log in *through* Plainpages). For
|
||||||
|
production — https, real secrets (`CSRF_SECRET`, Postgres credentials, a fresh JWT
|
||||||
|
signing key), Hydra — see the repo README → Production & deployment.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Every behaviour is an explicit env toggle read at boot — no `NODE_ENV`. The common ones:
|
||||||
|
|
||||||
|
| Var | Default | What |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | `admin@plainpages.local` / `admin` | the seeded first admin (bootstrap service) |
|
||||||
|
| `APP_URL` | unset | canonical public URL; off-host visitors are redirected to it |
|
||||||
|
| `CACHE_TEMPLATES` | `false` | cache compiled templates (`true` in prod) |
|
||||||
|
| `CSRF_SECRET` | dev throwaway | signs the CSRF token — set a real one in prod |
|
||||||
|
| `KRATOS_*` / `KETO_*` / `HYDRA_*` URLs | compose defaults | the Ory sidecar endpoints |
|
||||||
|
| `LOG_FORMAT` / `LOG_LEVEL` | `text` / `info` | `json` for structured prod logs |
|
||||||
|
| `OTLP_ENDPOINT` | unset | export logs + traces to an OpenTelemetry Collector |
|
||||||
|
| `REQUIRE_SECURE_SECRETS` | `false` | `true` ⇒ refuse to boot on a missing/throwaway `CSRF_SECRET` |
|
||||||
|
| `SECURE_COOKIES` | `false` | mark cookies `Secure` (`true` behind https) |
|
||||||
|
|
||||||
|
Full list (JWT/JWKS, timeouts, instant revoke): repo README → Configuration.
|
||||||
|
|
||||||
|
## Your first plugin
|
||||||
|
|
||||||
|
Everything domain-specific is a plugin folder — the compose above mounts `./plugins`
|
||||||
|
into the app. Create `plugins/hello/plugin.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { definePlugin } from "#plugin-api";
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||||
|
routes: [
|
||||||
|
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart (`docker compose restart web`) and visit <http://localhost:3000/hello>. Views,
|
||||||
|
forms, permissions, and the runnable reference plugin: repo README → Building plugins.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { bumpFromUpdateType, maxLevel, nextVersion } from "./next-version.ts";
|
||||||
|
|
||||||
|
test("bumpFromUpdateType: only major/minor keep their level; everything else is patch", () => {
|
||||||
|
assert.equal(bumpFromUpdateType("major"), "major");
|
||||||
|
assert.equal(bumpFromUpdateType("minor"), "minor");
|
||||||
|
assert.equal(bumpFromUpdateType("patch"), "patch");
|
||||||
|
assert.equal(bumpFromUpdateType("digest"), "patch");
|
||||||
|
assert.equal(bumpFromUpdateType("pin"), "patch");
|
||||||
|
assert.equal(bumpFromUpdateType("lockFileMaintenance"), "patch");
|
||||||
|
assert.equal(bumpFromUpdateType(""), "patch");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maxLevel: defaults to patch, escalates on the highest level present", () => {
|
||||||
|
assert.equal(maxLevel([]), "patch");
|
||||||
|
assert.equal(maxLevel(["patch"]), "patch");
|
||||||
|
assert.equal(maxLevel(["patch", "minor"]), "minor");
|
||||||
|
assert.equal(maxLevel(["minor", "major", "patch"]), "major");
|
||||||
|
assert.equal(maxLevel(["digest", "pin"]), "patch");
|
||||||
|
assert.equal(maxLevel(["", "bogus"]), "patch"); // unknown → patch, never throws
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nextVersion pre-1.0 (major===0): shift down so we never auto-cross into 1.0.0", () => {
|
||||||
|
// dep major → 0.x minor (the 0.x "breaking" slot); dep minor/patch → 0.x patch
|
||||||
|
assert.equal(nextVersion("v0.0.2", "major"), "v0.1.0");
|
||||||
|
assert.equal(nextVersion("v0.0.2", "minor"), "v0.0.3");
|
||||||
|
assert.equal(nextVersion("v0.0.2", "patch"), "v0.0.3");
|
||||||
|
assert.equal(nextVersion("v0.3.4", "major"), "v0.4.0");
|
||||||
|
assert.equal(nextVersion("v0.3.4", "minor"), "v0.3.5");
|
||||||
|
assert.equal(nextVersion("v0.3.4", "patch"), "v0.3.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nextVersion at/after 1.0.0: literal semver", () => {
|
||||||
|
assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0");
|
||||||
|
assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0");
|
||||||
|
assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nextVersion rejects a tag that is not vX.Y.Z", () => {
|
||||||
|
assert.throws(() => nextVersion("1.2.3", "patch"), /vX\.Y\.Z/);
|
||||||
|
assert.throws(() => nextVersion("vx.y.z", "patch"), /vX\.Y\.Z/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Pure release-version math for the Renovate auto-release (see renovate.yml → auto-release job,
|
||||||
|
// README → CI/CD). Renovate stamps each commit with a `Release-Bump: <updateType>` trailer; the
|
||||||
|
// workflow feeds those values here to pick the next `vX.Y.Z` tag. Kept side-effect-free and unit
|
||||||
|
// tested (next-version.test.ts) — the git/tag/push side lives in the workflow shell.
|
||||||
|
|
||||||
|
export type Bump = "major" | "minor" | "patch";
|
||||||
|
|
||||||
|
// A dependency change is always at least a patch; only a real major/minor escalates.
|
||||||
|
export function bumpFromUpdateType(updateType: string): Bump {
|
||||||
|
if (updateType === "major") return "major";
|
||||||
|
if (updateType === "minor") return "minor";
|
||||||
|
return "patch";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maxLevel(updateTypes: string[]): Bump {
|
||||||
|
let level: Bump = "patch";
|
||||||
|
for (const updateType of updateTypes) {
|
||||||
|
const bump = bumpFromUpdateType(updateType);
|
||||||
|
if (bump === "major") return "major";
|
||||||
|
if (bump === "minor") level = "minor";
|
||||||
|
}
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-1.0 (major===0) shifts every level down one notch, so a dependency major only bumps the 0.x
|
||||||
|
// minor and we never auto-cross into 1.0.0 — that stays a deliberate human milestone.
|
||||||
|
export function nextVersion(latestTag: string, level: Bump): string {
|
||||||
|
const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(latestTag);
|
||||||
|
if (!match) throw new Error(`latest tag must be vX.Y.Z, got ${JSON.stringify(latestTag)}`);
|
||||||
|
const major = Number(match[1]);
|
||||||
|
const minor = Number(match[2]);
|
||||||
|
const patch = Number(match[3]);
|
||||||
|
const effective: Bump = major === 0 ? (level === "major" ? "minor" : "patch") : level;
|
||||||
|
if (effective === "major") return `v${major + 1}.0.0`;
|
||||||
|
if (effective === "minor") return `v${major}.${minor + 1}.0`;
|
||||||
|
return `v${major}.${minor}.${patch + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI: node auto-release/next-version.ts <latestTag> [updateType...] → prints the next tag.
|
||||||
|
if (process.argv[1]?.endsWith("/next-version.ts")) {
|
||||||
|
const [, , latestTag, ...updateTypes] = process.argv;
|
||||||
|
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/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.
|
||||||
|
docs_only() {
|
||||||
|
local base changed
|
||||||
|
git rev-parse --git-dir >/dev/null 2>&1 || return 1
|
||||||
|
git fetch --no-tags --quiet origin +refs/heads/main:refs/remotes/origin/main 2>/dev/null || true
|
||||||
|
base=$(git merge-base refs/remotes/origin/main HEAD 2>/dev/null) || return 1
|
||||||
|
changed=$(
|
||||||
|
{ git diff --name-only "$base" HEAD && git status --porcelain --untracked-files=all | cut -c4-; } 2>/dev/null
|
||||||
|
) || 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; }
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
e2e() {
|
||||||
|
step "E2E: $1"
|
||||||
|
local proj="plainpages-e2e-$(basename "$1" .yml | tr '.' '-')" # dots aren't valid in a compose project name
|
||||||
|
local rc=0
|
||||||
|
docker compose -p "$proj" -f compose.yml -f "$1" run --build --rm e2e || rc=$?
|
||||||
|
docker compose -p "$proj" -f compose.yml -f "$1" 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 --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
|
|
||||||
+36
-9
@@ -5,21 +5,30 @@ services:
|
|||||||
command: node --watch src/server.ts
|
command: node --watch src/server.ts
|
||||||
# Dev overrides the base toggles: live template edits, dev-throwaway secrets allowed.
|
# Dev overrides the base toggles: live template edits, dev-throwaway secrets allowed.
|
||||||
environment:
|
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"
|
CACHE_TEMPLATES: "false"
|
||||||
LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines)
|
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)
|
||||||
REQUIRE_SECURE_SECRETS: "false"
|
REQUIRE_SECURE_SECRETS: "false"
|
||||||
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
|
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
|
||||||
SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # reference plugin → the dev mock backend
|
SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
- /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
|
# Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so
|
||||||
# real scheduling service — stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at
|
# the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this
|
||||||
# the real backend instead. Uses the pinned app image so there's nothing extra to build/pull.
|
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
||||||
|
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.16.0-alpine3.24
|
image: node:24.18.1-alpine3.24
|
||||||
command: node /srv/server.mjs
|
command: node /srv/server.ts
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./examples/shifts-upstream:/srv:ro
|
- ./examples/shifts-upstream:/srv:ro
|
||||||
@@ -27,17 +36,35 @@ services:
|
|||||||
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
# 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.
|
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:v1.30.1
|
image: axllent/mailpit:v1.30.6
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Ory Kratos dev: expose the public API so the browser can POST self-service flows to
|
# 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,
|
# flow.ui.action. Prod fronts Ory same-origin, so the base file publishes no Ory ports.
|
||||||
# so the base file publishes no Ory ports.
|
|
||||||
kratos:
|
kratos:
|
||||||
ports:
|
ports:
|
||||||
- "4433:4433"
|
- "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
|
# 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.
|
# so OAuth2 flows reach the host. Prod (base file) drops --dev for an https issuer.
|
||||||
|
|||||||
+10
-5
@@ -9,12 +9,17 @@ services:
|
|||||||
# Supply CSRF_SECRET via env; the dev-throwaway fallback boots a clean clone but
|
# 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.
|
# REQUIRE_SECURE_SECRETS refuses it in prod (config.ts), so a forgotten secret fails loud.
|
||||||
environment:
|
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"
|
CACHE_TEMPLATES: "true"
|
||||||
CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}
|
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
|
LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector
|
||||||
REQUIRE_SECURE_SECRETS: "true"
|
REQUIRE_SECURE_SECRETS: "true"
|
||||||
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
|
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/
|
# Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
|
||||||
# consent handler) + the one-shot bootstrap (admin + JWKS seed).
|
# consent handler) + the one-shot bootstrap (admin + JWKS seed).
|
||||||
depends_on:
|
depends_on:
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@@ -25,7 +30,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
hydra:
|
hydra:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
# §4 verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
|
# verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
|
||||||
# Read-only — bootstrap is the only writer.
|
# Read-only — bootstrap is the only writer.
|
||||||
volumes:
|
volumes:
|
||||||
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
|
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
|
||||||
@@ -112,7 +117,7 @@ services:
|
|||||||
retries: 20
|
retries: 20
|
||||||
restart: unless-stopped
|
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
|
# 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.
|
# read-write (the only writer) so the absent-JWKS safety net can land the key.
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@@ -134,14 +139,14 @@ services:
|
|||||||
KRATOS_ADMIN_URL: http://kratos:4434
|
KRATOS_ADMIN_URL: http://kratos:4434
|
||||||
volumes:
|
volumes:
|
||||||
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
|
- ./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
|
# 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).
|
# error must give up, not loop forever and hang `web` (gates on completion).
|
||||||
restart: "on-failure:5"
|
restart: "on-failure:5"
|
||||||
|
|
||||||
# Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README).
|
# 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
|
# 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).
|
# (compose.override.yml); prod sets an https issuer via env (URLS_SELF_ISSUER).
|
||||||
hydra-migrate:
|
hydra-migrate:
|
||||||
image: oryd/hydra:v26.2.0
|
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,12 @@
|
|||||||
|
# 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/ ./
|
||||||
|
|
||||||
|
CMD ["npx", "playwright", "test"]
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
// Full-stack auth E2E: token timeout + silent re-mint ("stay signed in", §4). Runs against the
|
// Full-stack auth E2E: token timeout + silent re-mint ("stay signed in"). Runs against the
|
||||||
// real Ory stack via compose.e2e-auth.yml, where the session→JWT TTL is shortened to 8s and the
|
// real Ory stack via e2e-tests/compose.auth.yml, where the session→JWT TTL is shortened to 8s and the
|
||||||
// web clock skew is 0 — so the ~10m token lapses in seconds and the hot path re-mints it from 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
|
// 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.
|
// 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 WEB = process.env.BASE_URL ?? "http://web:3000";
|
||||||
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
||||||
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
|
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
|
||||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3); admin role granted in Keto
|
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin role granted in Keto
|
||||||
const ADMIN_PASSWORD = "admin";
|
const ADMIN_PASSWORD = "admin";
|
||||||
|
|
||||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Full-stack auth E2E — token timeout + silent re-mint ("stay signed in", §4). The Ory-free
|
# Full-stack auth E2E — token timeout + silent re-mint ("stay signed in"). The Ory-free
|
||||||
# visual suite (compose.e2e.yml) covers the design system; this is its full-stack counterpart:
|
# 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)
|
# 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.
|
# 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 e2e-tests/compose.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 down -v # tear down after
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
# This suite exercises only the Kratos session → JWT re-mint; it needs Kratos + Keto + bootstrap,
|
# 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
|
# 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.
|
# expired the instant its TTL lapses (no 60s leeway) so the re-mint fires promptly.
|
||||||
environment:
|
environment:
|
||||||
|
APP_URL: http://web:3000 # the runner calls web on this host → canonical-host redirect stays inert
|
||||||
CACHE_TEMPLATES: "true"
|
CACHE_TEMPLATES: "true"
|
||||||
JWT_CLOCK_SKEW_SEC: "0"
|
JWT_CLOCK_SKEW_SEC: "0"
|
||||||
REQUIRE_SECURE_SECRETS: "false"
|
REQUIRE_SECURE_SECRETS: "false"
|
||||||
@@ -37,7 +38,7 @@ services:
|
|||||||
e2e:
|
e2e:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.e2e
|
dockerfile: e2e-tests/Dockerfile
|
||||||
depends_on:
|
depends_on:
|
||||||
web:
|
web:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -47,4 +48,4 @@ services:
|
|||||||
KRATOS_PUBLIC_URL: http://kratos:4433
|
KRATOS_PUBLIC_URL: http://kratos:4433
|
||||||
command: ["npx", "playwright", "test", "auth-refresh.spec.ts"]
|
command: ["npx", "playwright", "test", "auth-refresh.spec.ts"]
|
||||||
volumes:
|
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 --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 +
|
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
||||||
# mocked-SSO login, menu filtering by role, users/groups/roles CRUD, a plugin page, logout. A tiny
|
# login, menu filtering by role, users/groups/roles/OAuth2-clients CRUD, a plugin page, logout. A
|
||||||
# same-origin gateway (proxy, e2e/proxy.mjs) fronts web + Kratos on one host so the browser's cookies
|
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
||||||
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
|
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
|
||||||
# docker compose -f compose.yml -f compose.e2e-full.yml run --build --rm e2e
|
# docker compose -f compose.yml -f e2e-tests/compose.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 down -v # tear down after
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
# First-party + SSO flows need Kratos + Keto + bootstrap, not Hydra — drop it so the stack is
|
# The base's full depends_on applies (Hydra included — the admin plugin's OAuth2-clients
|
||||||
# leaner. SSO is enabled here only (clean clone stays password-only): the mock provider's whole
|
# screen needs it); only the reference plugin's upstream is added. SSO is enabled here only
|
||||||
# array is the env-settable form Kratos offers, mapped through the committed claims jsonnet.
|
# (clean clone stays password-only): the mock provider's whole array is the env-settable form
|
||||||
depends_on: !override
|
# Kratos offers, mapped through the committed claims jsonnet.
|
||||||
bootstrap:
|
depends_on:
|
||||||
condition: service_completed_successfully
|
|
||||||
kratos:
|
|
||||||
condition: service_healthy
|
|
||||||
keto:
|
|
||||||
condition: service_healthy
|
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
|
APP_URL: http://proxy # the browser reaches web through the same-origin gateway → canonical-host redirect stays inert
|
||||||
CACHE_TEMPLATES: "true"
|
CACHE_TEMPLATES: "true"
|
||||||
REQUIRE_SECURE_SECRETS: "false"
|
REQUIRE_SECURE_SECRETS: "false"
|
||||||
SECURE_COOKIES: "false" # the browser hits the gateway over http — Secure cookies wouldn't be stored
|
SECURE_COOKIES: "false" # the browser hits the gateway over http — Secure cookies wouldn't be stored
|
||||||
@@ -27,6 +23,19 @@ services:
|
|||||||
interval: 2s
|
interval: 2s
|
||||||
timeout: 4s
|
timeout: 4s
|
||||||
retries: 30
|
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 tokens, 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.
|
# 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
|
# `--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: >-
|
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"}]
|
[{"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.
|
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.16.0-alpine3.24
|
image: node:24.18.1-alpine3.24
|
||||||
command: ["node", "/server.mjs"]
|
command: ["node", "/server.ts"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./examples/shifts-upstream/server.mjs:/server.mjs:ro
|
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:4000/shifts"]
|
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:4000/shifts"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
@@ -53,23 +66,23 @@ services:
|
|||||||
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
||||||
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
||||||
mock-oidc:
|
mock-oidc:
|
||||||
image: node:24.16.0-alpine3.24
|
image: node:24.18.1-alpine3.24
|
||||||
command: ["node", "/mock-oidc.mjs"]
|
command: ["node", "/mock-oidc.ts"]
|
||||||
environment:
|
environment:
|
||||||
ISSUER: http://mock-oidc:9000
|
ISSUER: http://mock-oidc:9000
|
||||||
SSO_EMAIL: sso-user@plainpages.local
|
SSO_EMAIL: sso-user@plainpages.local
|
||||||
volumes:
|
volumes:
|
||||||
- ./e2e/mock-oidc.mjs:/mock-oidc.mjs:ro
|
- ./e2e-tests/mock-oidc.ts:/mock-oidc.ts:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9000/.well-known/openid-configuration"]
|
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9000/.well-known/openid-configuration"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
timeout: 4s
|
timeout: 4s
|
||||||
retries: 15
|
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:
|
proxy:
|
||||||
image: node:24.16.0-alpine3.24
|
image: node:24.18.1-alpine3.24
|
||||||
command: ["node", "/proxy.mjs"]
|
command: ["node", "/proxy.ts"]
|
||||||
depends_on:
|
depends_on:
|
||||||
web:
|
web:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -77,7 +90,7 @@ services:
|
|||||||
KRATOS_URL: http://kratos:4433
|
KRATOS_URL: http://kratos:4433
|
||||||
WEB_URL: http://web:3000
|
WEB_URL: http://web:3000
|
||||||
volumes:
|
volumes:
|
||||||
- ./e2e/proxy.mjs:/proxy.mjs:ro
|
- ./e2e-tests/proxy.ts:/proxy.ts:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost/public/css/styles.css"]
|
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost/public/css/styles.css"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
@@ -87,7 +100,7 @@ services:
|
|||||||
e2e:
|
e2e:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.e2e
|
dockerfile: e2e-tests/Dockerfile
|
||||||
command: ["npx", "playwright", "test", "full-flow.spec.ts"]
|
command: ["npx", "playwright", "test", "full-flow.spec.ts"]
|
||||||
depends_on:
|
depends_on:
|
||||||
mock-oidc:
|
mock-oidc:
|
||||||
@@ -98,4 +111,4 @@ services:
|
|||||||
BASE_URL: http://proxy
|
BASE_URL: http://proxy
|
||||||
KRATOS_ADMIN_URL: http://kratos:4434
|
KRATOS_ADMIN_URL: http://kratos:4434
|
||||||
volumes:
|
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
|
# 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 +
|
# 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
|
# Hydra + bootstrap + web). The runner drives the flow over HTTP (fetch, manual cookies), so it
|
||||||
# reaches the Ory services by their compose-network names.
|
# 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 e2e-tests/compose.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 down -v # tear down after
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
# Dev throwaways are fine for the test stack; the runner hits web over http.
|
# Dev throwaways are fine for the test stack; the runner hits web over http.
|
||||||
environment:
|
environment:
|
||||||
|
APP_URL: http://web:3000 # the runner/browser reach web on this host → canonical-host redirect stays inert
|
||||||
CACHE_TEMPLATES: "true"
|
CACHE_TEMPLATES: "true"
|
||||||
REQUIRE_SECURE_SECRETS: "false"
|
REQUIRE_SECURE_SECRETS: "false"
|
||||||
SECURE_COOKIES: "false"
|
SECURE_COOKIES: "false"
|
||||||
@@ -31,7 +32,7 @@ services:
|
|||||||
e2e:
|
e2e:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.e2e
|
dockerfile: e2e-tests/Dockerfile
|
||||||
depends_on:
|
depends_on:
|
||||||
web:
|
web:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -42,4 +43,4 @@ services:
|
|||||||
KRATOS_PUBLIC_URL: http://kratos:4433
|
KRATOS_PUBLIC_URL: http://kratos:4433
|
||||||
command: ["npx", "playwright", "test", "oauth-login.spec.ts"]
|
command: ["npx", "playwright", "test", "oauth-login.spec.ts"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./e2e/artifacts:/e2e/artifacts
|
- ./e2e-tests/artifacts:/e2e-tests/artifacts
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Playwright E2E. Brings up the app + a Playwright runner and exercises the live pages (design
|
||||||
|
# system, theme switch, mobile layout, CSRF, landing, 404, plugin gating) — Ory-free, so it's fast.
|
||||||
|
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --build --rm e2e
|
||||||
|
# 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
|
||||||
|
# Just the Ory-free visual suite; the full-stack auth spec runs via e2e-tests/compose.auth.yml.
|
||||||
|
command: ["npx", "playwright", "test", "visual.spec.ts"]
|
||||||
|
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,51 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
// Regression: the from-scratch dev experience the README/banner advertises must work. `docker compose
|
||||||
|
// up`, open the printed login URL (http://localhost:3000), sign in as the seeded admin → you land on
|
||||||
|
// the dashboard, signed in. Originally this dumped the user on http://127.0.0.1:3000/error?id=…
|
||||||
|
// ("Page not found"): the banner printed `localhost` but kratos.yml hard-coded `127.0.0.1`, and a
|
||||||
|
// host-scoped Kratos CSRF cookie can't cross `localhost`↔`127.0.0.1`, so the cross-host login POST
|
||||||
|
// lost it and Kratos redirected to its error sink.
|
||||||
|
//
|
||||||
|
// The fix makes APP_URL the single source for the public host: the web app canonicalises every
|
||||||
|
// off-host visitor onto it (so localhost / 127.0.0.1 / any alias funnel to one cookie host), Kratos'
|
||||||
|
// browser URLs derive from it, and a real /error page replaces the 404.
|
||||||
|
//
|
||||||
|
// This is faithful to the user's environment: the runner uses the host network
|
||||||
|
// (e2e-tests/compose.devstack.yml) against the plain `docker compose up` topology, so it sees
|
||||||
|
// http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host browser
|
||||||
|
// does. The proxied full-flow suite can't catch this regression — it fronts web + Kratos on one origin.
|
||||||
|
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 its "Log in" call to action.
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("link", { name: "Log 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);
|
||||||
|
});
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { type Browser, type Page, expect, test } from "@playwright/test";
|
import { type Browser, type Page, expect, test } from "@playwright/test";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
// Full browser E2E (todo §8): the real Playwright UI against the live stack via the same-origin
|
// Full browser E2E: the real Playwright UI against the live stack via the same-origin
|
||||||
// gateway (compose.e2e-full.yml) — the browser-UI login the earlier full-stack suites deferred here.
|
// 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.
|
// 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
|
// 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
|
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
|
||||||
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
|
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
|
||||||
// (no cross-group shared backend writes) or serialise the file if that ever changes.
|
// (no cross-group shared backend writes) or serialise the file if that ever changes.
|
||||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3), holds the admin role in Keto
|
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin role in Keto
|
||||||
const ADMIN_PASSWORD = "admin";
|
const ADMIN_PASSWORD = "admin";
|
||||||
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
|
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
|
||||||
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
|
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
|
||||||
@@ -85,6 +85,35 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page.locator("main")).toContainText(role);
|
await expect(page.locator("main")).toContainText(role);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
test("plugin page: the reference plugin renders its upstream shifts inside the native shell", async () => {
|
||||||
await page.goto("/scheduling/shifts");
|
await page.goto("/scheduling/shifts");
|
||||||
await expect(page.locator("h1")).toHaveText("Shifts");
|
await expect(page.locator("h1")).toHaveText("Shifts");
|
||||||
@@ -103,7 +132,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("return_to: a deep link while logged out returns to that page after login (§9)", async ({ page }) => {
|
test("return_to: a deep link while logged out returns to that page after login", async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
|
// 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).
|
// flow server-side, so it's consumed, not shown in the settled URL).
|
||||||
@@ -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
|
// 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
|
// 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).
|
// 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 "@playwright/test";
|
||||||
|
|
||||||
// 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
|
// 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
|
// 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
|
// 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
|
// 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 WEB = process.env.BASE_URL ?? "http://web:3000";
|
||||||
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
||||||
const HYDRA_PUBLIC = process.env.HYDRA_PUBLIC_URL ?? "http://hydra:4444";
|
const HYDRA_PUBLIC = process.env.HYDRA_PUBLIC_URL ?? "http://hydra:4444";
|
||||||
const HYDRA_ADMIN = process.env.HYDRA_ADMIN_URL ?? "http://hydra:4445";
|
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";
|
const ADMIN_PASSWORD = "admin";
|
||||||
|
|
||||||
function setCookieLine(res: Response, name: string): string | undefined {
|
function setCookieLine(res: Response, name: string): string | undefined {
|
||||||
+15
-15
@@ -8,23 +8,23 @@
|
|||||||
"name": "plainpages-e2e",
|
"name": "plainpages-e2e",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.49.1"
|
"@playwright/test": "1.62.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.49.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
|
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.49.1"
|
"playwright": "1.62.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
@@ -43,35 +43,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.49.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.49.1"
|
"playwright-core": "1.62.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"fsevents": "2.3.2"
|
"fsevents": "2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.49.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,6 @@
|
|||||||
"test": "playwright test"
|
"test": "playwright test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.49.1"
|
"@playwright/test": "1.62.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
// Visual + functional checks against the live app (the `web` compose service, BASE_URL) and the
|
// Visual + functional checks against the live app (the `web` compose service, BASE_URL). Run via
|
||||||
// static html-css-foundation mockups (bind-mounted at /repo). Run via compose.e2e.yml. Parallel
|
// e2e-tests/compose.visual.yml. Parallel per the project's E2E principle; deterministic colorScheme/viewport
|
||||||
// per the project's E2E principle (todo §1.1); deterministic colorScheme/viewport so the
|
// so the rendered design is stable across runs.
|
||||||
// computed-style parity vs the reference design is stable.
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: ".",
|
testDir: ".",
|
||||||
outputDir: "artifacts/test-output",
|
outputDir: "artifacts/test-output",
|
||||||
@@ -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
|
// 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
|
// 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
|
// reverse proxy fronts both on a single host (the browser's only origin), exactly as a production
|
||||||
@@ -3,19 +3,15 @@ import { readFileSync } from "node:fs";
|
|||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
// 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 SHOTS = "artifacts/screenshots";
|
||||||
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
|
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
|
||||||
const SESSION_COOKIE = "plainpages_jwt"; // src/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
|
||||||
|
|
||||||
const shot = (page: Page, name: string): Promise<Buffer> =>
|
const shot = (page: Page, name: string): Promise<Buffer> =>
|
||||||
page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` });
|
page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` });
|
||||||
|
|
||||||
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
|
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
|
||||||
// gated dashboard (§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.
|
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
|
||||||
function devSession(roles: string[] = []): string {
|
function devSession(roles: string[] = []): string {
|
||||||
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
|
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
|
||||||
@@ -28,21 +24,19 @@ function devSession(roles: string[] = []): string {
|
|||||||
|
|
||||||
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
|
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
|
// The dashboard is gated: 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.
|
// member (no roles) so the gated scheduling nav stays filtered out.
|
||||||
test.beforeEach(async ({ context }) => {
|
test.beforeEach(async ({ context }) => {
|
||||||
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
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 page.goto("/dashboard");
|
||||||
await expect(page.locator(".sidebar")).toBeVisible();
|
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 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.goto("/dashboard");
|
||||||
await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio
|
await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio
|
||||||
await shot(page, "live-03-dark");
|
await shot(page, "live-03-dark");
|
||||||
@@ -51,31 +45,6 @@ test("captures live pages + reference mockups for side-by-side review", async ({
|
|||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await shot(page, "live-04-mobile");
|
await shot(page, "live-04-mobile");
|
||||||
await page.setViewportSize({ width: 1280, height: 800 });
|
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 }) => {
|
test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => {
|
||||||
@@ -89,20 +58,9 @@ test("every icon <use> resolves to a defined <symbol> (no broken graphics)", asy
|
|||||||
expect(missing).toEqual([]);
|
expect(missing).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sorting and search drive the list through the URL (zero-JS)", async ({ page }) => {
|
// (The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component
|
||||||
await page.goto("/dashboard");
|
// (list-query/data-table/filter-bar) and exercised live with real data by the full-flow E2E's admin
|
||||||
const total = await page.locator("tbody tr").count();
|
// Users list. The mock-data dashboard that used to host it in this Ory-free suite is gone.)
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("theme switch flips the palette with no JavaScript", async ({ page }) => {
|
test("theme switch flips the palette with no JavaScript", async ({ page }) => {
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
@@ -137,11 +95,12 @@ test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a t
|
|||||||
expect(res.status()).toBe(403);
|
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 context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.locator(".landing")).toBeVisible(); // the standalone landing, not the app shell
|
await expect(page.locator(".landing")).toBeVisible();
|
||||||
await expect(page.locator(".sidebar")).toHaveCount(0);
|
// the same app shell every page renders — the menu shows even signed out (role-filtered).
|
||||||
|
await expect(page.locator(".sidebar")).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
|
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.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
|
||||||
await shot(page, "live-05-public-landing");
|
await shot(page, "live-05-public-landing");
|
||||||
@@ -156,9 +115,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
|
// 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,
|
// 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.
|
// 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
|
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
|
||||||
// probes are genuinely anonymous.
|
// probes are genuinely anonymous.
|
||||||
// The public overview is reachable with no session (200), not bounced to sign in.
|
// The public overview is reachable with no session (200), not bounced to sign in.
|
||||||
@@ -166,13 +125,13 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
|
|||||||
expect(pub.status()).toBe(200);
|
expect(pub.status()).toBe(200);
|
||||||
const body = await pub.text();
|
const body = await pub.text();
|
||||||
expect(body).toContain("Scheduling");
|
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.
|
// /login), and the shell's Sign-in link carries the current page as return_to.
|
||||||
expect(body).not.toContain('href="/dashboard"');
|
expect(body).not.toContain('href="/dashboard"');
|
||||||
expect(body).toContain('href="/login?return_to=%2Fscheduling"');
|
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);
|
// 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 });
|
const res = await request.get("/scheduling/shifts", { maxRedirects: 0 });
|
||||||
expect(res.status()).toBe(303);
|
expect(res.status()).toBe(303);
|
||||||
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
||||||
@@ -180,7 +139,7 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
|
|||||||
// The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav,
|
// The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav,
|
||||||
// but the gated Shifts leaf is filtered out.
|
// but the gated Shifts leaf is filtered out.
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await expect(page.locator(".sidebar")).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"]')).toHaveCount(1); // public Overview shown
|
||||||
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
|
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
|
||||||
});
|
});
|
||||||
@@ -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 `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
|
||||||
|
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
|
||||||
|
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
|
||||||
|
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
// Central menu override + branding (todo §2). Brand the app and reorder/rename/group/hide nav
|
// Reference config/menu.ts — copy into the (empty) config/ mount at the repo root:
|
||||||
// nodes (by their `id`) across all plugins — the override always wins, applied before the
|
// cp examples/config/menu.ts config/menu.ts
|
||||||
// per-user permission filter. Every field is optional; delete one to fall back to the default.
|
// config/ ships empty; mount your own or copy this in. Absent config = built-in defaults.
|
||||||
// See src/menu-config.ts (types), src/nav.ts (NavOverride), docs/plugin-contract.md.
|
//
|
||||||
|
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins —
|
||||||
|
// the override always wins, applied before the per-user permission filter. Every field is
|
||||||
|
// optional; delete one to fall back to the default.
|
||||||
|
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system).
|
||||||
|
|
||||||
import { defineMenu } from "../src/menu-config.ts";
|
import { defineMenu } from "#menu-config";
|
||||||
|
|
||||||
export default defineMenu({
|
export default defineMenu({
|
||||||
branding: {
|
branding: {
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Admin — the system-administration plugin
|
||||||
|
|
||||||
|
The Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. These used to be
|
||||||
|
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
|
||||||
|
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
|
||||||
|
screens live at `/admin/*`) and restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r examples/plugins/admin plugins/admin
|
||||||
|
docker compose restart web
|
||||||
|
```
|
||||||
|
|
||||||
|
The seeded `admin@plainpages.local` already holds the `admin` role, so the section appears in the
|
||||||
|
menu and the screens work immediately.
|
||||||
|
|
||||||
|
## 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 (Groups, Roles).
|
||||||
|
- **`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 role change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
||||||
|
|
||||||
|
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
|
||||||
|
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
|
||||||
|
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
|
||||||
|
gated per route by `permission: "admin"`, rendering the core building blocks in `views/`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission token, and the
|
||||||
|
route table — one thin handler per method+path, all gated by `permission: "admin"`.
|
||||||
|
- `admin-users.ts` · `admin-groups.ts` · `admin-roles.ts` · `admin-clients.ts` — each a set of pure
|
||||||
|
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
|
||||||
|
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
|
||||||
|
admin gate + the needed `ctx.system` clients once.
|
||||||
|
- `admin-shared.ts` — the shared gate (`requireAdmin`), CSRF form reader (`guardedForm`), confirm
|
||||||
|
model, nav fragment, and the not-found / unavailable helpers.
|
||||||
|
- `views/` — the screens' EJS, plus the admin-specific body partials under `views/partials/`. They
|
||||||
|
`include()` the core building-block partials (shell, data-table, filter-bar, field, …).
|
||||||
|
|
||||||
|
The four screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
|
||||||
|
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
|
// 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.
|
// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in app.test.ts.
|
||||||
import assert from "node:assert/strict";
|
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" });
|
const all = buildClientsListModel({ clients, url: "http://x/admin/clients" });
|
||||||
assert.equal(all.pagination.summary.total, 30);
|
assert.equal(all.pagination.summary.total, 30);
|
||||||
assert.equal(all.table.rows.length, 25); // default page size
|
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 } };
|
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.text, "app-00");
|
||||||
assert.equal(first.rowHeader.href, "/admin/clients/id-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", () => {
|
test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => {
|
||||||
const m = buildClientFormModel({ csrfToken: "tok.sig" });
|
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.action, "/admin/clients");
|
||||||
assert.equal(m.form.submitLabel, "Register client");
|
assert.equal(m.form.submitLabel, "Register client");
|
||||||
assert.equal(m.form.csrfToken, "tok.sig");
|
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 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 });
|
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.delete.action, "/admin/clients/c1/delete");
|
||||||
assert.equal(plain.created, false);
|
assert.equal(plain.created, false);
|
||||||
assert.equal(plain.secret, undefined);
|
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" });
|
const fresh = buildClientDetailModel({ client, created: true, secret: "s3cr3t" });
|
||||||
assert.equal(fresh.created, true);
|
assert.equal(fresh.created, true);
|
||||||
assert.equal(fresh.secret, "s3cr3t");
|
assert.equal(fresh.secret, "s3cr3t");
|
||||||
assert.equal(fresh.shell.title, "Client registered");
|
assert.equal(fresh.title, "Client registered");
|
||||||
});
|
});
|
||||||
@@ -1,20 +1,13 @@
|
|||||||
// Built-in OAuth2 clients admin screen (todo §6): register / list / delete the OAuth2 clients other
|
// OAuth2 clients admin screen: register / list / delete the OAuth2 clients other
|
||||||
// apps log in *through* us with (Ory Hydra, the §6 login+consent handlers). A client is an Ory Hydra
|
// 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
|
// 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
|
// 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). `handleAdminClients` is the
|
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
|
||||||
// imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded.
|
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
|
||||||
|
|
||||||
import { ADMIN_CLIENTS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
|
||||||
import { safeDecode } from "./admin-groups.ts";
|
import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
import type { RequestContext, User } from "./context.ts";
|
|
||||||
import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts";
|
|
||||||
import { parseListQuery } from "./list-query.ts";
|
|
||||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
|
||||||
import { paginate } from "./paginate.ts";
|
|
||||||
import type { RouteResult } from "./plugin.ts";
|
|
||||||
import { buildShellContext } from "./shell-context.ts";
|
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
const PAGE_SIZES = [25, 50, 100];
|
const PAGE_SIZES = [25, 50, 100];
|
||||||
@@ -109,11 +102,8 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
|||||||
export function buildClientsListModel(opts: {
|
export function buildClientsListModel(opts: {
|
||||||
clients: OAuth2Client[];
|
clients: OAuth2Client[];
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
menu?: MenuConfig;
|
|
||||||
url: URL | URLSearchParams | string;
|
url: URL | URLSearchParams | string;
|
||||||
user?: User | null;
|
|
||||||
}) {
|
}) {
|
||||||
const menu = opts.menu ?? DEFAULT_MENU;
|
|
||||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||||
const needle = query.q.toLowerCase();
|
const needle = query.q.toLowerCase();
|
||||||
|
|
||||||
@@ -126,17 +116,11 @@ export function buildClientsListModel(opts: {
|
|||||||
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q };
|
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filterBar: listFilterBar(state),
|
|
||||||
nav: adminNav(opts.user?.roles ?? [], menu, "clients"),
|
|
||||||
pagination: listPagination(state, page),
|
|
||||||
shell: buildShellContext({
|
|
||||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }],
|
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }],
|
||||||
csrfToken: opts.csrfToken ?? "",
|
filterBar: listFilterBar(state),
|
||||||
menu,
|
pagination: listPagination(state, page),
|
||||||
title: "OAuth2 clients",
|
|
||||||
user: opts.user ?? null,
|
|
||||||
}),
|
|
||||||
table: listTable(rows),
|
table: listTable(rows),
|
||||||
|
title: "OAuth2 clients",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,11 +175,8 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
|
|||||||
export function buildClientFormModel(opts: {
|
export function buildClientFormModel(opts: {
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
menu?: MenuConfig;
|
|
||||||
user?: User | null;
|
|
||||||
values?: Partial<ClientInput>;
|
values?: Partial<ClientInput>;
|
||||||
}) {
|
}) {
|
||||||
const menu = opts.menu ?? DEFAULT_MENU;
|
|
||||||
const v = opts.values;
|
const v = opts.values;
|
||||||
const nameField: FieldConfig = {
|
const nameField: FieldConfig = {
|
||||||
autocomplete: "off", icon: "i-box", id: "name", label: "Name", name: "name", required: true, value: v?.name ?? "",
|
autocomplete: "off", icon: "i-box", id: "name", label: "Name", name: "name", required: true, value: v?.name ?? "",
|
||||||
@@ -205,6 +186,7 @@ export function buildClientFormModel(opts: {
|
|||||||
value: v?.scope ?? DEFAULT_SCOPE,
|
value: v?.scope ?? DEFAULT_SCOPE,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }],
|
||||||
error: opts.error,
|
error: opts.error,
|
||||||
form: {
|
form: {
|
||||||
action: ADMIN_CLIENTS_BASE,
|
action: ADMIN_CLIENTS_BASE,
|
||||||
@@ -217,14 +199,7 @@ export function buildClientFormModel(opts: {
|
|||||||
scopeField,
|
scopeField,
|
||||||
submitLabel: "Register client",
|
submitLabel: "Register client",
|
||||||
},
|
},
|
||||||
nav: adminNav(opts.user?.roles ?? [], menu, "clients"),
|
|
||||||
shell: buildShellContext({
|
|
||||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }],
|
|
||||||
csrfToken: opts.csrfToken ?? "",
|
|
||||||
menu,
|
|
||||||
title: "Register client",
|
title: "Register client",
|
||||||
user: opts.user ?? null,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,38 +207,22 @@ export function buildClientDetailModel(opts: {
|
|||||||
client: ClientView;
|
client: ClientView;
|
||||||
created?: boolean; // just registered → success banner + the one-time secret (if any)
|
created?: boolean; // just registered → success banner + the one-time secret (if any)
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
menu?: MenuConfig;
|
|
||||||
secret?: string; // one-time client_secret (confidential clients), shown once right after create
|
secret?: string; // one-time client_secret (confidential clients), shown once right after create
|
||||||
user?: User | null;
|
|
||||||
}) {
|
}) {
|
||||||
const menu = opts.menu ?? DEFAULT_MENU;
|
|
||||||
const base = detailHref(opts.client.id);
|
const base = detailHref(opts.client.id);
|
||||||
return {
|
return {
|
||||||
|
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }],
|
||||||
client: opts.client,
|
client: opts.client,
|
||||||
created: opts.created ?? false,
|
created: opts.created ?? false,
|
||||||
csrfToken: opts.csrfToken ?? "",
|
csrfToken: opts.csrfToken ?? "",
|
||||||
delete: { action: `${base}/delete` },
|
delete: { action: `${base}/delete` },
|
||||||
nav: adminNav(opts.user?.roles ?? [], menu, "clients"),
|
|
||||||
secret: opts.secret,
|
secret: opts.secret,
|
||||||
shell: buildShellContext({
|
|
||||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }],
|
|
||||||
csrfToken: opts.csrfToken ?? "",
|
|
||||||
menu,
|
|
||||||
title: opts.created ? "Client registered" : opts.client.name,
|
title: opts.created ? "Client registered" : opts.client.name,
|
||||||
user: opts.user ?? null,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- request handler (imperative shell) ----
|
// ---- request handler (imperative shell) ----
|
||||||
|
|
||||||
export interface AdminClientsDeps {
|
|
||||||
csrfSecret: string;
|
|
||||||
hydra: HydraAdmin;
|
|
||||||
menu: MenuConfig;
|
|
||||||
render: (view: string, data: Record<string, unknown>) => Promise<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readClientInput(form: URLSearchParams): ClientInput {
|
function readClientInput(form: URLSearchParams): ClientInput {
|
||||||
return {
|
return {
|
||||||
firstParty: form.get("firstParty") === "on",
|
firstParty: form.get("firstParty") === "on",
|
||||||
@@ -274,74 +233,78 @@ function readClientInput(form: URLSearchParams): ClientInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleAdminClients(ctx: RequestContext, csrfToken: string, deps: AdminClientsDeps): Promise<RouteResult | null> {
|
// Shared per-request deps for the OAuth2-clients screen, resolved by `withClients`: the gate + the
|
||||||
const path = ctx.url.pathname;
|
// Hydra capability (else a themed 503). Each route below is a thin handler over these.
|
||||||
if (path !== ADMIN_CLIENTS_BASE && !path.startsWith(`${ADMIN_CLIENTS_BASE}/`)) return null;
|
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; }
|
||||||
|
|
||||||
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
|
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteHandler {
|
||||||
const { hydra, menu, render } = deps;
|
return async (ctx) => {
|
||||||
const method = (ctx.req.method ?? "GET").toUpperCase();
|
const user = requireAdmin(ctx);
|
||||||
const seg = path.slice(ADMIN_CLIENTS_BASE.length).split("/").filter(Boolean);
|
const hydra = ctx.system?.hydra;
|
||||||
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
|
if (!hydra) return unavailable(ctx, "Hydra OAuth2 admin");
|
||||||
|
return inner({ ctx, hydra, user });
|
||||||
const renderForm = async (extra: { error?: string; values?: Partial<ClientInput> }): Promise<RouteResult> =>
|
};
|
||||||
({ html: await render("admin/client-form", { model: buildClientFormModel({ csrfToken, menu, user, ...extra }) }) });
|
|
||||||
const renderDetail = async (client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): Promise<RouteResult> =>
|
|
||||||
({ html: await render("admin/client-detail", { model: buildClientDetailModel({ client: toClientView(client), csrfToken, menu, user, ...extra }) }) });
|
|
||||||
const notFound = async (): Promise<RouteResult> => ({ html: await render("404", { title: "Not found" }), status: 404 });
|
|
||||||
|
|
||||||
// /admin/clients — list (GET) · register (POST)
|
|
||||||
if (seg.length === 0) {
|
|
||||||
if (method === "GET") {
|
|
||||||
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
|
|
||||||
return { html: await render("admin/clients", { model: buildClientsListModel({ clients, csrfToken, menu, url: ctx.url, user }) }) };
|
|
||||||
}
|
}
|
||||||
if (method === "POST") {
|
|
||||||
const input = readClientInput(form!);
|
// Same, plus the target client from ctx.params.id (unknown → themed 404).
|
||||||
|
function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise<RouteResult>): 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientFormResult = (ctx: RequestContext, extra: { error?: string; values?: Partial<ClientInput> }): RouteResult =>
|
||||||
|
({ data: { chrome: ctx.chrome, model: buildClientFormModel({ csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "client-form" });
|
||||||
|
const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult =>
|
||||||
|
({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ client: toClientView(client), csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "client-detail" });
|
||||||
|
|
||||||
|
// GET /admin/clients — the list.
|
||||||
|
export const clientsList = withClients(async ({ ctx, hydra }) => {
|
||||||
|
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
|
||||||
|
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ clients, csrfToken: ctx.chrome.csrfToken, url: ctx.url }) }, view: "clients" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
|
||||||
|
// returns it again). A Hydra 4xx (bad redirect/scope) re-renders the form (400); a 5xx rethrows → 500.
|
||||||
|
export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
|
||||||
|
const input = readClientInput((await guardedForm(ctx))!);
|
||||||
const error = validateClientInput(input);
|
const error = validateClientInput(input);
|
||||||
if (error) return { ...(await renderForm({ error, values: input })), status: 400 };
|
if (error) return { ...clientFormResult(ctx, { error, values: input }), status: 400 };
|
||||||
let created: OAuth2Client;
|
let created: OAuth2Client;
|
||||||
try {
|
try {
|
||||||
created = await hydra.createClient(clientPayload(input));
|
created = await hydra.createClient(clientPayload(input));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A Hydra 4xx (bad redirect/scope it rejects) is the operator's input — re-render the form;
|
if (err instanceof HydraError && err.status < 500) return { ...clientFormResult(ctx, { error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input }), status: 400 };
|
||||||
// a 5xx (Hydra down) rethrows → 500. Mirrors the §6 challenge-handler degrade.
|
|
||||||
if (err instanceof HydraError && err.status < 500) {
|
|
||||||
return { ...(await renderForm({ error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input })), status: 400 };
|
|
||||||
}
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
// Show the one-time secret now (Hydra never returns it again) — render the detail directly.
|
|
||||||
ctx.log.info("admin: oauth2 client registered", { actor: user.id, client: created.client_id ?? "" });
|
ctx.log.info("admin: oauth2 client registered", { actor: user.id, client: created.client_id ?? "" });
|
||||||
return renderDetail(created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
|
return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
|
||||||
}
|
});
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// /admin/clients/new — register form
|
// GET /admin/clients/new — the register form.
|
||||||
if (seg.length === 1 && seg[0] === "new" && method === "GET") return renderForm({});
|
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})));
|
||||||
|
|
||||||
// /admin/clients/:id …
|
// GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
|
||||||
const id = safeDecode(seg[0]!);
|
export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client)));
|
||||||
if (id === null) return notFound();
|
|
||||||
const client = await hydra.getClient(id);
|
// GET /admin/clients/:id/delete — the deliberate confirm step.
|
||||||
if (!client) return notFound();
|
export const clientsDeleteConfirm = withClient((deps, client, id) => {
|
||||||
const base = detailHref(id);
|
const base = detailHref(id);
|
||||||
|
|
||||||
if (seg.length === 1 && method === "GET") return renderDetail(client);
|
|
||||||
|
|
||||||
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
|
|
||||||
const name = toClientView(client).name;
|
const name = toClientView(client).name;
|
||||||
return { html: await render("admin/confirm", { model: buildConfirmModel({
|
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }],
|
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }],
|
||||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client", csrfToken,
|
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client",
|
||||||
current: "clients", menu, message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, title: "Delete client", user,
|
message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, title: "Delete client",
|
||||||
}) }) };
|
}) }, view: "confirm" });
|
||||||
}
|
});
|
||||||
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
|
|
||||||
|
// 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);
|
await hydra.deleteClient(id);
|
||||||
ctx.log.info("admin: oauth2 client deleted", { actor: user.id, client: id });
|
ctx.log.info("admin: oauth2 client deleted", { actor: user.id, client: id });
|
||||||
return { redirect: ADMIN_CLIENTS_BASE };
|
return { redirect: ADMIN_CLIENTS_BASE };
|
||||||
}
|
});
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -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
|
// 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
|
// groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over
|
||||||
// HTTP in app.test.ts.
|
// HTTP in app.test.ts.
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
memberView,
|
memberView,
|
||||||
parseSubject,
|
parseSubject,
|
||||||
} from "./admin-groups.ts";
|
} from "./admin-groups.ts";
|
||||||
import type { RelationTuple } from "./keto-client.ts";
|
import type { RelationTuple } from "#plugin-api";
|
||||||
|
|
||||||
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
||||||
const userTuple = (group: string, n: number): RelationTuple =>
|
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" });
|
const all = buildGroupsListModel({ groups, url: "http://x/admin/groups" });
|
||||||
assert.equal(all.pagination.summary.total, 30);
|
assert.equal(all.pagination.summary.total, 30);
|
||||||
assert.equal(all.table.rows.length, 25); // default page size
|
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.
|
// 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 } };
|
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||||
assert.equal(first.rowHeader.text, "team-00");
|
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", () => {
|
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 options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||||
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
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.action, "/admin/groups");
|
||||||
assert.equal(m.form.submitLabel, "Create group");
|
assert.equal(m.form.submitLabel, "Create group");
|
||||||
assert.equal(m.form.csrfToken, "tok.sig");
|
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" },
|
{ label: "ops (group)", value: "group:ops" },
|
||||||
];
|
];
|
||||||
const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members });
|
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.rows.length, 2);
|
||||||
assert.equal(m.members.action, "/admin/groups/eng/members/delete");
|
assert.equal(m.members.action, "/admin/groups/eng/members/delete");
|
||||||
assert.equal(m.add.action, "/admin/groups/eng/members");
|
assert.equal(m.add.action, "/admin/groups/eng/members");
|
||||||
@@ -1,21 +1,14 @@
|
|||||||
// Built-in Groups admin screen (todo §5): list / create / delete Keto groups and manage membership.
|
// 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
|
// 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
|
// 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
|
// 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; `handleAdminGroups`
|
// every member tuple. Pure builders turn tuples + the request URL into view models; below them are thin
|
||||||
// is the imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded, mapping each action
|
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
||||||
// to a RouteResult.
|
// each returning a RouteResult.
|
||||||
|
|
||||||
import { ADMIN_GROUPS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type User } from "#plugin-api";
|
||||||
|
import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
import type { RequestContext, User } from "./context.ts";
|
|
||||||
import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "./keto-client.ts";
|
|
||||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
|
||||||
import { parseListQuery } from "./list-query.ts";
|
|
||||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
|
||||||
import { paginate } from "./paginate.ts";
|
|
||||||
import type { RouteResult } from "./plugin.ts";
|
|
||||||
import { buildShellContext } from "./shell-context.ts";
|
|
||||||
|
|
||||||
const GROUP_NS = "Group";
|
const GROUP_NS = "Group";
|
||||||
const MEMBERS = "members";
|
const MEMBERS = "members";
|
||||||
@@ -119,11 +112,8 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
|||||||
export function buildGroupsListModel(opts: {
|
export function buildGroupsListModel(opts: {
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
groups: GroupView[];
|
groups: GroupView[];
|
||||||
menu?: MenuConfig;
|
|
||||||
url: URL | URLSearchParams | string;
|
url: URL | URLSearchParams | string;
|
||||||
user?: User | null;
|
|
||||||
}) {
|
}) {
|
||||||
const menu = opts.menu ?? DEFAULT_MENU;
|
|
||||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||||
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
||||||
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
||||||
@@ -146,17 +136,11 @@ export function buildGroupsListModel(opts: {
|
|||||||
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filterBar: listFilterBar(state),
|
|
||||||
nav: adminNav(opts.user?.roles ?? [], menu, "groups"),
|
|
||||||
pagination: listPagination(state, page),
|
|
||||||
shell: buildShellContext({
|
|
||||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Admin" }, { label: "Groups" }],
|
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Admin" }, { label: "Groups" }],
|
||||||
csrfToken: opts.csrfToken ?? "",
|
filterBar: listFilterBar(state),
|
||||||
menu,
|
pagination: listPagination(state, page),
|
||||||
title: "Groups",
|
|
||||||
user: opts.user ?? null,
|
|
||||||
}),
|
|
||||||
table: listTable(rows, state, sort),
|
table: listTable(rows, state, sort),
|
||||||
|
title: "Groups",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,16 +197,14 @@ export function buildGroupFormModel(opts: {
|
|||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
memberOptions: MemberOption[];
|
memberOptions: MemberOption[];
|
||||||
menu?: MenuConfig;
|
|
||||||
user?: User | null;
|
|
||||||
values?: { member?: string; name?: string };
|
values?: { member?: string; name?: string };
|
||||||
}) {
|
}) {
|
||||||
const menu = opts.menu ?? DEFAULT_MENU;
|
|
||||||
const nameField: FieldConfig = {
|
const nameField: FieldConfig = {
|
||||||
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-layers",
|
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-layers",
|
||||||
id: "name", label: "Group name", name: "name", required: true, value: opts.values?.name ?? "",
|
id: "name", label: "Group name", name: "name", required: true, value: opts.values?.name ?? "",
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: "New" }],
|
||||||
error: opts.error,
|
error: opts.error,
|
||||||
form: {
|
form: {
|
||||||
action: ADMIN_GROUPS_BASE,
|
action: ADMIN_GROUPS_BASE,
|
||||||
@@ -233,14 +215,7 @@ export function buildGroupFormModel(opts: {
|
|||||||
selectedMember: opts.values?.member ?? "",
|
selectedMember: opts.values?.member ?? "",
|
||||||
submitLabel: "Create group",
|
submitLabel: "Create group",
|
||||||
},
|
},
|
||||||
nav: adminNav(opts.user?.roles ?? [], menu, "groups"),
|
|
||||||
shell: buildShellContext({
|
|
||||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: "New" }],
|
|
||||||
csrfToken: opts.csrfToken ?? "",
|
|
||||||
menu,
|
|
||||||
title: "New group",
|
title: "New group",
|
||||||
user: opts.user ?? null,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,10 +225,7 @@ export function buildGroupDetailModel(opts: {
|
|||||||
error?: string;
|
error?: string;
|
||||||
group: { name: string };
|
group: { name: string };
|
||||||
members: MemberView[];
|
members: MemberView[];
|
||||||
menu?: MenuConfig;
|
|
||||||
user?: User | null;
|
|
||||||
}) {
|
}) {
|
||||||
const menu = opts.menu ?? DEFAULT_MENU;
|
|
||||||
const name = opts.group.name;
|
const name = opts.group.name;
|
||||||
const base = detailHref(name);
|
const base = detailHref(name);
|
||||||
const taken = new Set(opts.members.map((m) => m.subject));
|
const taken = new Set(opts.members.map((m) => m.subject));
|
||||||
@@ -261,32 +233,18 @@ export function buildGroupDetailModel(opts: {
|
|||||||
const options = opts.candidates.filter((c) => c.value !== self && !taken.has(c.value));
|
const options = opts.candidates.filter((c) => c.value !== self && !taken.has(c.value));
|
||||||
return {
|
return {
|
||||||
add: { action: `${base}/members`, options },
|
add: { action: `${base}/members`, options },
|
||||||
|
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: name }],
|
||||||
csrfToken: opts.csrfToken ?? "",
|
csrfToken: opts.csrfToken ?? "",
|
||||||
delete: { action: `${base}/delete` },
|
delete: { action: `${base}/delete` },
|
||||||
error: opts.error,
|
error: opts.error,
|
||||||
group: { name },
|
group: { name },
|
||||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
members: { action: `${base}/members/delete`, rows: opts.members },
|
||||||
nav: adminNav(opts.user?.roles ?? [], menu, "groups"),
|
|
||||||
shell: buildShellContext({
|
|
||||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: name }],
|
|
||||||
csrfToken: opts.csrfToken ?? "",
|
|
||||||
menu,
|
|
||||||
title: name,
|
title: name,
|
||||||
user: opts.user ?? null,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- request handler (imperative shell) ----
|
// ---- request handler (imperative shell) ----
|
||||||
|
|
||||||
export interface AdminGroupsDeps {
|
|
||||||
csrfSecret: string;
|
|
||||||
keto: KetoClient;
|
|
||||||
kratosAdmin: KratosAdmin;
|
|
||||||
menu: MenuConfig;
|
|
||||||
render: (view: string, data: Record<string, unknown>) => Promise<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain every page of a relation-tuple query. (Reused by the Roles screen — same membership model.)
|
// Drain every page of a relation-tuple query. (Reused by the Roles screen — same membership model.)
|
||||||
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
|
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
|
||||||
const out: RelationTuple[] = [];
|
const out: RelationTuple[] = [];
|
||||||
@@ -321,85 +279,96 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
|
|||||||
return page.tuples.length > 0;
|
return page.tuples.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode a path segment without letting malformed %-encoding throw (→ caller treats it as not found).
|
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate + the Keto and
|
||||||
export function safeDecode(seg: string): string | null {
|
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
|
||||||
try { return decodeURIComponent(seg); } catch { return null; }
|
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; }
|
||||||
|
|
||||||
|
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHandler {
|
||||||
|
return async (ctx) => {
|
||||||
|
const user = requireAdmin(ctx);
|
||||||
|
const keto = ctx.system?.keto;
|
||||||
|
const kratosAdmin = ctx.system?.kratosAdmin;
|
||||||
|
if (!keto || !kratosAdmin) return unavailable(ctx, "Keto and Kratos identity admin");
|
||||||
|
return inner({ ctx, keto, kratosAdmin, user });
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleAdminGroups(ctx: RequestContext, csrfToken: string, deps: AdminGroupsDeps): Promise<RouteResult | null> {
|
// Same, plus the validated :name from ctx.params (an invalid group name → themed 404).
|
||||||
const path = ctx.url.pathname;
|
function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise<RouteResult>): RouteHandler {
|
||||||
if (path !== ADMIN_GROUPS_BASE && !path.startsWith(`${ADMIN_GROUPS_BASE}/`)) return null;
|
return withGroups((deps) => {
|
||||||
|
const name = deps.ctx.params["name"] ?? "";
|
||||||
|
if (!isValidGroupName(name)) return Promise.resolve(notFound(deps.ctx));
|
||||||
|
return inner(deps, name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
|
const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
|
||||||
const { keto, kratosAdmin, menu, render } = deps;
|
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
||||||
const method = (ctx.req.method ?? "GET").toUpperCase();
|
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "group-form" };
|
||||||
const seg = path.slice(ADMIN_GROUPS_BASE.length).split("/").filter(Boolean);
|
};
|
||||||
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
|
|
||||||
|
|
||||||
const renderList = async (): Promise<RouteResult> => {
|
// GET /admin/groups — the list.
|
||||||
|
export const groupsList = withGroups(async ({ ctx, keto }) => {
|
||||||
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
||||||
return { html: await render("admin/groups", { model: buildGroupsListModel({ csrfToken, groups, menu, url: ctx.url, user }) }) };
|
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ csrfToken: ctx.chrome.csrfToken, groups, url: ctx.url }) }, view: "groups" };
|
||||||
};
|
});
|
||||||
const renderForm = async (extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
|
|
||||||
const { options } = await memberCandidates(keto, kratosAdmin);
|
|
||||||
return { html: await render("admin/group-form", { model: buildGroupFormModel({ csrfToken, memberOptions: options, menu, user, ...extra }) }) };
|
|
||||||
};
|
|
||||||
const renderDetail = async (name: string): Promise<RouteResult> => {
|
|
||||||
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
|
||||||
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
|
|
||||||
return { html: await render("admin/group-detail", { model: buildGroupDetailModel({ candidates: options, csrfToken, group: { name }, members, menu, user }) }) };
|
|
||||||
};
|
|
||||||
|
|
||||||
// /admin/groups — list (GET) · create (POST)
|
// POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
|
||||||
if (seg.length === 0) {
|
export const groupsCreate = withGroups(async (deps) => {
|
||||||
if (method === "GET") return renderList();
|
const { ctx, keto, user } = deps;
|
||||||
if (method === "POST") {
|
const form = (await guardedForm(ctx))!;
|
||||||
const name = (form!.get("name") ?? "").trim();
|
const name = (form.get("name") ?? "").trim();
|
||||||
const tuple = memberTuple(name, (form!.get("member") ?? "").trim());
|
const member = (form.get("member") ?? "").trim();
|
||||||
const reject = (error: string): Promise<RouteResult> =>
|
const tuple = memberTuple(name, member);
|
||||||
renderForm({ error, values: { member: form!.get("member") ?? "", name } }).then((r) => ({ ...r, status: 400 }));
|
const reject = async (error: string): Promise<RouteResult> => ({ ...(await groupFormResult(deps, { error, values: { member, name } })), status: 400 });
|
||||||
if (!isValidGroupName(name)) return reject("Group names use lowercase letters, digits, dashes and underscores.");
|
if (!isValidGroupName(name)) return reject("Group names use lowercase letters, digits, dashes and underscores.");
|
||||||
if (!tuple) return reject("Pick a member to add as the group's first member.");
|
if (!tuple) return reject("Pick a member to add as the group's first member.");
|
||||||
if (await groupExists(keto, name)) return reject("A group with that name already exists.");
|
if (await groupExists(keto, name)) return reject("A group with that name already exists.");
|
||||||
await keto.writeTuple(tuple);
|
await keto.writeTuple(tuple);
|
||||||
ctx.log.info("admin: group created", { actor: user.id, group: name });
|
ctx.log.info("admin: group created", { actor: user.id, group: name });
|
||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
}
|
});
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// /admin/groups/new — create form
|
// GET /admin/groups/new — the create form.
|
||||||
if (seg.length === 1 && seg[0] === "new" && method === "GET") return renderForm({});
|
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}));
|
||||||
|
|
||||||
// /admin/groups/:name …
|
// GET /admin/groups/:name — the detail + membership page.
|
||||||
const name = safeDecode(seg[0]!);
|
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
|
||||||
if (name === null || !isValidGroupName(name)) return { html: await render("404", { title: "Not found" }), status: 404 };
|
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
||||||
const base = detailHref(name);
|
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
|
||||||
|
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members }) }, view: "group-detail" };
|
||||||
|
});
|
||||||
|
|
||||||
if (seg.length === 1 && method === "GET") return renderDetail(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) => {
|
||||||
if (seg.length === 2 && seg[1] === "members" && method === "POST") {
|
const form = (await guardedForm(ctx))!;
|
||||||
const tuple = memberTuple(name, (form!.get("member") ?? "").trim());
|
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
|
||||||
// Skip an invalid member or a self-nest (the picker already excludes both).
|
|
||||||
if (tuple && tuple.subject_set?.object !== name) await keto.writeTuple(tuple);
|
if (tuple && tuple.subject_set?.object !== name) await keto.writeTuple(tuple);
|
||||||
return { redirect: base };
|
return { redirect: detailHref(name) };
|
||||||
}
|
});
|
||||||
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
|
|
||||||
return { html: await render("admin/confirm", { model: buildConfirmModel({
|
// GET /admin/groups/:name/delete — the deliberate confirm step.
|
||||||
|
export const groupsDeleteConfirm = withGroupName((deps, name) => {
|
||||||
|
const base = detailHref(name);
|
||||||
|
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }],
|
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }],
|
||||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete group", csrfToken,
|
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete group",
|
||||||
current: "groups", menu, message: `Delete group ${name}? This removes the group and all its memberships.`, title: "Delete group", user,
|
message: `Delete group ${name}? This removes the group and all its memberships.`, title: "Delete group",
|
||||||
}) }) };
|
}) }, view: "confirm" });
|
||||||
}
|
});
|
||||||
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
|
|
||||||
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS }); // removes every member tuple
|
// 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
|
||||||
|
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
|
||||||
ctx.log.info("admin: group deleted", { actor: user.id, group: name });
|
ctx.log.info("admin: group deleted", { actor: user.id, group: name });
|
||||||
return { redirect: ADMIN_GROUPS_BASE };
|
return { redirect: ADMIN_GROUPS_BASE };
|
||||||
}
|
});
|
||||||
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
|
|
||||||
const tuple = memberTuple(name, (form!.get("member") ?? "").trim());
|
// 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);
|
if (tuple) await keto.deleteTuple(tuple);
|
||||||
return { redirect: base };
|
return { redirect: detailHref(name) };
|
||||||
}
|
});
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Built-in Roles & permissions admin screen (§5): the pure view-model + Keto builders. A role is a
|
// Built-in Roles & permissions admin screen: the pure view-model + Keto builders. A role is a
|
||||||
// Keto subject set (Role:<name>#members); members are users (subject_id) or groups (subject_set) —
|
// Keto subject set (Role:<name>#members); members are users (subject_id) or groups (subject_set) —
|
||||||
// "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the
|
// "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the
|
||||||
// distinct set of users who hold the role directly or transitively via a group. The HTTP
|
// distinct set of users who hold the role directly or transitively via a group. The HTTP
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
isValidRoleName,
|
isValidRoleName,
|
||||||
roleMemberTuple,
|
roleMemberTuple,
|
||||||
} from "./admin-roles.ts";
|
} from "./admin-roles.ts";
|
||||||
import type { ExpandTree, RelationTuple } from "./keto-client.ts";
|
import type { ExpandTree, RelationTuple } from "#plugin-api";
|
||||||
|
|
||||||
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
||||||
const userTuple = (role: string, n: number): RelationTuple =>
|
const userTuple = (role: string, n: number): RelationTuple =>
|
||||||
@@ -57,7 +57,7 @@ test("buildRolesListModel filters by search, sorts, paginates; the name links to
|
|||||||
const all = buildRolesListModel({ roles, url: "http://x/admin/roles" });
|
const all = buildRolesListModel({ roles, url: "http://x/admin/roles" });
|
||||||
assert.equal(all.pagination.summary.total, 30);
|
assert.equal(all.pagination.summary.total, 30);
|
||||||
assert.equal(all.table.rows.length, 25); // default page size
|
assert.equal(all.table.rows.length, 25); // default page size
|
||||||
assert.equal(all.shell.title, "Roles");
|
assert.equal(all.title, "Roles");
|
||||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||||
assert.equal(first.rowHeader.text, "role-00");
|
assert.equal(first.rowHeader.text, "role-00");
|
||||||
assert.equal(first.rowHeader.href, "/admin/roles/role-00");
|
assert.equal(first.rowHeader.href, "/admin/roles/role-00");
|
||||||
@@ -73,7 +73,7 @@ test("buildRolesListModel filters by search, sorts, paginates; the name links to
|
|||||||
test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => {
|
test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => {
|
||||||
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||||
const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||||
assert.equal(m.shell.title, "New role");
|
assert.equal(m.title, "New role");
|
||||||
assert.equal(m.form.action, "/admin/roles");
|
assert.equal(m.form.action, "/admin/roles");
|
||||||
assert.equal(m.form.submitLabel, "Create role");
|
assert.equal(m.form.submitLabel, "Create role");
|
||||||
assert.equal(m.form.csrfToken, "tok.sig");
|
assert.equal(m.form.csrfToken, "tok.sig");
|
||||||
@@ -96,7 +96,7 @@ test("buildRoleDetailModel: members → rows, add-options exclude current member
|
|||||||
];
|
];
|
||||||
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
|
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
|
||||||
const m = buildRoleDetailModel({ candidates, effective, members, role: { name: "admin" } });
|
const m = buildRoleDetailModel({ candidates, effective, members, role: { name: "admin" } });
|
||||||
assert.equal(m.shell.title, "admin");
|
assert.equal(m.title, "admin");
|
||||||
assert.equal(m.members.rows.length, 2);
|
assert.equal(m.members.rows.length, 2);
|
||||||
assert.equal(m.members.action, "/admin/roles/admin/members/delete");
|
assert.equal(m.members.action, "/admin/roles/admin/members/delete");
|
||||||
assert.equal(m.add.action, "/admin/roles/admin/members");
|
assert.equal(m.add.action, "/admin/roles/admin/members");
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
// Built-in Roles & permissions admin screen (todo §5): list / create / delete Keto roles and assign
|
// Roles & permissions admin screen: list / create / delete Keto roles and assign
|
||||||
// them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users
|
// them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users
|
||||||
// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the
|
// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the
|
||||||
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
|
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
|
||||||
// are reused from admin-groups. The role-specific piece is the **effective access** view:
|
// are reused from admin-groups. The role-specific piece is the **effective access** view:
|
||||||
// `keto.expand(Role:<name>#members)` flattened to the distinct users who hold the role directly or via
|
// `keto.expand(Role:<name>#members)` flattened to the distinct users who hold the role directly or via
|
||||||
// a group — matching what login projects into the JWT (login.ts readRoles). Writes go only to Keto;
|
// a group — matching what login projects into the JWT (login.ts readRoles). Writes go only to Keto;
|
||||||
// Kratos is read only to label members. `handleAdminRoles` is the imperative shell app.ts dispatches
|
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
|
||||||
// to — gated admin-only, CSRF-guarded.
|
// ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded.
|
||||||
|
|
||||||
import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
|
||||||
|
import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||||
import {
|
import {
|
||||||
type GroupView,
|
type GroupView,
|
||||||
groupsFromTuples,
|
groupsFromTuples,
|
||||||
@@ -19,17 +20,8 @@ import {
|
|||||||
memberView,
|
memberView,
|
||||||
pagedTuples,
|
pagedTuples,
|
||||||
parseSubject,
|
parseSubject,
|
||||||
safeDecode,
|
|
||||||
} from "./admin-groups.ts";
|
} from "./admin-groups.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
import type { RequestContext, User } from "./context.ts";
|
|
||||||
import type { ExpandTree, KetoClient, RelationTuple } from "./keto-client.ts";
|
|
||||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
|
||||||
import { parseListQuery } from "./list-query.ts";
|
|
||||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
|
||||||
import { paginate } from "./paginate.ts";
|
|
||||||
import type { RouteResult } from "./plugin.ts";
|
|
||||||
import { buildShellContext } from "./shell-context.ts";
|
|
||||||
|
|
||||||
const ROLE_NS = "Role";
|
const ROLE_NS = "Role";
|
||||||
const MEMBERS = "members";
|
const MEMBERS = "members";
|
||||||
@@ -104,12 +96,9 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
|||||||
|
|
||||||
export function buildRolesListModel(opts: {
|
export function buildRolesListModel(opts: {
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
menu?: MenuConfig;
|
|
||||||
roles: RoleView[];
|
roles: RoleView[];
|
||||||
url: URL | URLSearchParams | string;
|
url: URL | URLSearchParams | string;
|
||||||
user?: User | null;
|
|
||||||
}) {
|
}) {
|
||||||
const menu = opts.menu ?? DEFAULT_MENU;
|
|
||||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||||
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
||||||
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
||||||
@@ -132,17 +121,11 @@ export function buildRolesListModel(opts: {
|
|||||||
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filterBar: listFilterBar(state),
|
|
||||||
nav: adminNav(opts.user?.roles ?? [], menu, "roles"),
|
|
||||||
pagination: listPagination(state, page),
|
|
||||||
shell: buildShellContext({
|
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }],
|
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }],
|
||||||
csrfToken: opts.csrfToken ?? "",
|
filterBar: listFilterBar(state),
|
||||||
menu,
|
pagination: listPagination(state, page),
|
||||||
title: "Roles",
|
|
||||||
user: opts.user ?? null,
|
|
||||||
}),
|
|
||||||
table: listTable(rows, state, sort),
|
table: listTable(rows, state, sort),
|
||||||
|
title: "Roles",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,16 +182,14 @@ export function buildRoleFormModel(opts: {
|
|||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
memberOptions: MemberOption[];
|
memberOptions: MemberOption[];
|
||||||
menu?: MenuConfig;
|
|
||||||
user?: User | null;
|
|
||||||
values?: { member?: string; name?: string };
|
values?: { member?: string; name?: string };
|
||||||
}) {
|
}) {
|
||||||
const menu = opts.menu ?? DEFAULT_MENU;
|
|
||||||
const nameField: FieldConfig = {
|
const nameField: FieldConfig = {
|
||||||
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield",
|
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield",
|
||||||
id: "name", label: "Role name", name: "name", required: true, value: opts.values?.name ?? "",
|
id: "name", label: "Role name", name: "name", required: true, value: opts.values?.name ?? "",
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }],
|
||||||
error: opts.error,
|
error: opts.error,
|
||||||
form: {
|
form: {
|
||||||
action: ADMIN_ROLES_BASE,
|
action: ADMIN_ROLES_BASE,
|
||||||
@@ -219,14 +200,7 @@ export function buildRoleFormModel(opts: {
|
|||||||
selectedMember: opts.values?.member ?? "",
|
selectedMember: opts.values?.member ?? "",
|
||||||
submitLabel: "Create role",
|
submitLabel: "Create role",
|
||||||
},
|
},
|
||||||
nav: adminNav(opts.user?.roles ?? [], menu, "roles"),
|
|
||||||
shell: buildShellContext({
|
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }],
|
|
||||||
csrfToken: opts.csrfToken ?? "",
|
|
||||||
menu,
|
|
||||||
title: "New role",
|
title: "New role",
|
||||||
user: opts.user ?? null,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,50 +210,32 @@ export function buildRoleDetailModel(opts: {
|
|||||||
effective: EffectiveUser[];
|
effective: EffectiveUser[];
|
||||||
error?: string;
|
error?: string;
|
||||||
members: MemberView[];
|
members: MemberView[];
|
||||||
menu?: MenuConfig;
|
|
||||||
role: { name: string };
|
role: { name: string };
|
||||||
user?: User | null;
|
|
||||||
}) {
|
}) {
|
||||||
const menu = opts.menu ?? DEFAULT_MENU;
|
|
||||||
const name = opts.role.name;
|
const name = opts.role.name;
|
||||||
const base = detailHref(name);
|
const base = detailHref(name);
|
||||||
const taken = new Set(opts.members.map((m) => m.subject));
|
const taken = new Set(opts.members.map((m) => m.subject));
|
||||||
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the role itself
|
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the role itself
|
||||||
return {
|
return {
|
||||||
add: { action: `${base}/members`, options },
|
add: { action: `${base}/members`, options },
|
||||||
|
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }],
|
||||||
csrfToken: opts.csrfToken ?? "",
|
csrfToken: opts.csrfToken ?? "",
|
||||||
delete: { action: `${base}/delete` },
|
delete: { action: `${base}/delete` },
|
||||||
effective: opts.effective,
|
effective: opts.effective,
|
||||||
error: opts.error,
|
error: opts.error,
|
||||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
members: { action: `${base}/members/delete`, rows: opts.members },
|
||||||
nav: adminNav(opts.user?.roles ?? [], menu, "roles"),
|
|
||||||
role: { name },
|
role: { name },
|
||||||
shell: buildShellContext({
|
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }],
|
|
||||||
csrfToken: opts.csrfToken ?? "",
|
|
||||||
menu,
|
|
||||||
title: name,
|
title: name,
|
||||||
user: opts.user ?? null,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- request handler (imperative shell) ----
|
// ---- request handler (imperative shell) ----
|
||||||
|
|
||||||
export interface AdminRolesDeps {
|
// instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
|
||||||
csrfSecret: string;
|
|
||||||
keto: KetoClient;
|
|
||||||
kratosAdmin: KratosAdmin;
|
|
||||||
menu: MenuConfig;
|
|
||||||
render: (view: string, data: Record<string, unknown>) => Promise<string>;
|
|
||||||
revoke?: (sub: string) => void; // optional instant-revoke (§9): assigning/unassigning a *user* kills their live tokens
|
|
||||||
}
|
|
||||||
|
|
||||||
// §9 instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
|
|
||||||
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
|
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
|
||||||
// transitive across many users — left to lag (documented), so only direct user members revoke.
|
// transitive across many users — left to lag (documented), so only direct user members revoke.
|
||||||
function revokeUserMember(deps: AdminRolesDeps, member: string): void {
|
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
|
||||||
if (deps.revoke && member.startsWith("user:")) deps.revoke(member.slice("user:".length));
|
if (revoke && member.startsWith("user:")) revoke(member.slice("user:".length));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A role exists exactly while it has ≥1 member (Keto has no create-object).
|
// A role exists exactly while it has ≥1 member (Keto has no create-object).
|
||||||
@@ -298,94 +254,114 @@ async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolea
|
|||||||
.sort((a, b) => a.label.localeCompare(b.label));
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, deps: AdminRolesDeps): Promise<RouteResult | null> {
|
// Shared per-request deps for the Roles screen, resolved by `withRoles`: the gate + the Keto and
|
||||||
const path = ctx.url.pathname;
|
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
|
||||||
if (path !== ADMIN_ROLES_BASE && !path.startsWith(`${ADMIN_ROLES_BASE}/`)) return null;
|
interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
|
||||||
|
|
||||||
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
|
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
|
||||||
const { keto, kratosAdmin, menu, render } = deps;
|
return async (ctx) => {
|
||||||
const method = (ctx.req.method ?? "GET").toUpperCase();
|
const user = requireAdmin(ctx);
|
||||||
const seg = path.slice(ADMIN_ROLES_BASE.length).split("/").filter(Boolean);
|
const keto = ctx.system?.keto;
|
||||||
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
|
const kratosAdmin = ctx.system?.kratosAdmin;
|
||||||
|
if (!keto || !kratosAdmin) return unavailable(ctx, "Keto and Kratos identity admin");
|
||||||
|
return inner({ ctx, keto, kratosAdmin, revoke: ctx.system?.revoke, user });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const renderList = async (): Promise<RouteResult> => {
|
// Same, plus the validated :name from ctx.params (an invalid role name → themed 404).
|
||||||
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
|
function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
|
||||||
return { html: await render("admin/roles", { model: buildRolesListModel({ csrfToken, menu, roles, url: ctx.url, user }) }) };
|
return withRoles((deps) => {
|
||||||
|
const name = deps.ctx.params["name"] ?? "";
|
||||||
|
if (!isValidRoleName(name)) return Promise.resolve(notFound(deps.ctx));
|
||||||
|
return inner(deps, name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
|
||||||
|
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
||||||
|
return { data: { chrome: deps.ctx.chrome, model: buildRoleFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "role-form" };
|
||||||
};
|
};
|
||||||
const renderForm = async (extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
|
|
||||||
const { options } = await memberCandidates(keto, kratosAdmin);
|
// The role detail (members + effective access). With `error` set it's a 400 (a rejected action).
|
||||||
return { html: await render("admin/role-form", { model: buildRoleFormModel({ csrfToken, memberOptions: options, menu, user, ...extra }) }) };
|
const roleDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => {
|
||||||
};
|
const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
||||||
const renderDetail = async (name: string, error?: string): Promise<RouteResult> => {
|
const tuples = await pagedTuples(deps.keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
|
||||||
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
|
||||||
const tuples = await pagedTuples(keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
|
|
||||||
const members = tuples.map((t) => memberView(t, emailById));
|
const members = tuples.map((t) => memberView(t, emailById));
|
||||||
const effective = await effectiveUsers(keto, name, tuples.length > 0, emailById);
|
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
|
||||||
const html = await render("admin/role-detail", { model: buildRoleDetailModel({ candidates: options, csrfToken, effective, members, menu, role: { name }, user, ...(error ? { error } : {}) }) });
|
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildRoleDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, role: { name }, ...(error ? { error } : {}) }) }, view: "role-detail" };
|
||||||
return error ? { html, status: 400 } : { html };
|
return error ? { ...result, status: 400 } : result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// /admin/roles — list (GET) · create (POST)
|
// GET /admin/roles — the list.
|
||||||
if (seg.length === 0) {
|
export const rolesList = withRoles(async ({ ctx, keto }) => {
|
||||||
if (method === "GET") return renderList();
|
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
|
||||||
if (method === "POST") {
|
return { data: { chrome: ctx.chrome, model: buildRolesListModel({ csrfToken: ctx.chrome.csrfToken, roles, url: ctx.url }) }, view: "roles" };
|
||||||
const name = (form!.get("name") ?? "").trim();
|
});
|
||||||
const member = (form!.get("member") ?? "").trim();
|
|
||||||
|
// POST /admin/roles — create + assign the first member (a *user* grant revokes their live tokens).
|
||||||
|
export const rolesCreate = withRoles(async (deps) => {
|
||||||
|
const { ctx, keto, revoke, user } = deps;
|
||||||
|
const form = (await guardedForm(ctx))!;
|
||||||
|
const name = (form.get("name") ?? "").trim();
|
||||||
|
const member = (form.get("member") ?? "").trim();
|
||||||
const tuple = roleMemberTuple(name, member);
|
const tuple = roleMemberTuple(name, member);
|
||||||
const reject = (error: string): Promise<RouteResult> =>
|
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
|
||||||
renderForm({ error, values: { member, name } }).then((r) => ({ ...r, status: 400 }));
|
|
||||||
if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores.");
|
if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores.");
|
||||||
if (!tuple) return reject("Pick a user or group to assign the role to.");
|
if (!tuple) return reject("Pick a user or group to assign the role to.");
|
||||||
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
|
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
|
||||||
await keto.writeTuple(tuple);
|
await keto.writeTuple(tuple);
|
||||||
revokeUserMember(deps, member);
|
revokeUserMember(revoke, member);
|
||||||
ctx.log.info("admin: role created + first member assigned", { actor: user.id, member, role: name });
|
ctx.log.info("admin: role created + first member assigned", { actor: user.id, member, role: name });
|
||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
}
|
});
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// /admin/roles/new — create form
|
// GET /admin/roles/new — the create form.
|
||||||
if (seg.length === 1 && seg[0] === "new" && method === "GET") return renderForm({});
|
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
|
||||||
|
|
||||||
// /admin/roles/:name …
|
// GET /admin/roles/:name — the detail (members + effective access via Keto expand).
|
||||||
const name = safeDecode(seg[0]!);
|
export const rolesDetail = withRoleName((deps, name) => roleDetailResult(deps, name));
|
||||||
if (name === null || !isValidRoleName(name)) return { html: await render("404", { title: "Not found" }), status: 404 };
|
|
||||||
|
// POST /admin/roles/:name/members — assign a user/group; a *user* grant revokes their live tokens.
|
||||||
|
export const rolesAddMember = withRoleName(async (deps, name) => {
|
||||||
|
const { ctx, keto, revoke, user } = deps;
|
||||||
|
const form = (await guardedForm(ctx))!;
|
||||||
|
const member = (form.get("member") ?? "").trim();
|
||||||
|
const tuple = roleMemberTuple(name, member); // the picker only offers real users/groups
|
||||||
|
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role assigned", { actor: user.id, member, role: name }); }
|
||||||
|
return { redirect: detailHref(name) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /admin/roles/:name/delete — confirm, except the admin role can't be deleted.
|
||||||
|
export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
||||||
|
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
|
||||||
const base = detailHref(name);
|
const base = detailHref(name);
|
||||||
|
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||||
if (seg.length === 1 && method === "GET") return renderDetail(name);
|
|
||||||
|
|
||||||
if (seg.length === 2 && seg[1] === "members" && method === "POST") {
|
|
||||||
const member = (form!.get("member") ?? "").trim();
|
|
||||||
const tuple = roleMemberTuple(name, member);
|
|
||||||
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(deps, member); ctx.log.info("admin: role assigned", { actor: user.id, member, role: name }); } // the picker only offers real users/groups
|
|
||||||
return { redirect: base };
|
|
||||||
}
|
|
||||||
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
|
|
||||||
// Self-protection: deleting the admin role removes everyone's admin — refuse it outright.
|
|
||||||
if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access.");
|
|
||||||
return { html: await render("admin/confirm", { model: buildConfirmModel({
|
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
|
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
|
||||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role", csrfToken,
|
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role",
|
||||||
current: "roles", menu, message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role", user,
|
message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role",
|
||||||
}) }) };
|
}) }, view: "confirm" });
|
||||||
}
|
});
|
||||||
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
|
|
||||||
if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access.");
|
// POST /admin/roles/:name/delete — remove every member tuple (a whole-role delete lags per the
|
||||||
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); // removes every member tuple
|
// documented instant-revoke tradeoff; the admin role is protected).
|
||||||
// §9: a whole-role delete drops many members at once — left to lag like a group change; the
|
export const rolesDelete = withRoleName(async (deps, name) => {
|
||||||
// per-member unassign above is the instant-revoke path.
|
const { ctx, keto, user } = deps;
|
||||||
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
|
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
|
||||||
|
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS });
|
||||||
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
|
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
|
||||||
return { redirect: ADMIN_ROLES_BASE };
|
return { redirect: ADMIN_ROLES_BASE };
|
||||||
}
|
});
|
||||||
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
|
|
||||||
const member = (form!.get("member") ?? "").trim();
|
// POST /admin/roles/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
|
||||||
// Self-protection: don't let an admin revoke their own *direct* admin grant (would lock them out).
|
// Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
|
||||||
// Admin held only via a group isn't covered here — the robust "last effective admin" check is §9.
|
// covered — the robust "last effective admin" check is deferred).
|
||||||
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return renderDetail(name, "You can't revoke your own admin access.");
|
export const rolesRemoveMember = withRoleName(async (deps, name) => {
|
||||||
|
const { ctx, keto, revoke, user } = deps;
|
||||||
|
const form = (await guardedForm(ctx))!;
|
||||||
|
const member = (form.get("member") ?? "").trim();
|
||||||
|
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
|
||||||
const tuple = roleMemberTuple(name, member);
|
const tuple = roleMemberTuple(name, member);
|
||||||
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(deps, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
|
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
|
||||||
return { redirect: base };
|
return { redirect: detailHref(name) };
|
||||||
}
|
});
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
|
||||||
|
// (requireAdmin/guardedForm gate every admin write) and reused across all four screens, so pin the
|
||||||
|
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
|
||||||
|
// Import only from the #plugin-api barrel — the same contract boundary the plugin code uses.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
|
||||||
|
import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
|
||||||
|
|
||||||
|
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
|
||||||
|
const member: User = { email: "bo@x.io", id: "u2", roles: ["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, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||||
|
roles: opts.user?.roles ?? [], url, user: opts.user ?? null, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- nav fragment ----
|
||||||
|
|
||||||
|
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => {
|
||||||
|
assert.equal(ADMIN_NAV.id, "admin");
|
||||||
|
assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
|
||||||
|
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
|
||||||
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]);
|
||||||
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]);
|
||||||
|
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- auth gates ----
|
||||||
|
|
||||||
|
test("requireAdmin: anonymous → 401→/login, signed-in non-admin → 403, admin → the user", () => {
|
||||||
|
assert.throws(() => requireAdmin(fakeCtx({ user: null })), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
|
||||||
|
assert.throws(() => requireAdmin(fakeCtx({ user: member })), (e: unknown) => e instanceof GuardError && e.status === 403);
|
||||||
|
assert.equal(requireAdmin(fakeCtx({ user: admin })), admin);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("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,78 @@
|
|||||||
|
// Shared plumbing for the admin example plugin: the section nav fragment, the admin-only gate, the
|
||||||
|
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers
|
||||||
|
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
|
||||||
|
// everything imports the host only through the #plugin-api barrel.
|
||||||
|
|
||||||
|
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api";
|
||||||
|
|
||||||
|
export const ADMIN_PERMISSION = "admin"; // role token gating the whole admin section
|
||||||
|
export const ADMIN_USERS_BASE = "/admin/users";
|
||||||
|
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||||
|
export const ADMIN_ROLES_BASE = "/admin/roles";
|
||||||
|
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||||
|
|
||||||
|
export type AdminScreen = "clients" | "groups" | "roles" | "users";
|
||||||
|
|
||||||
|
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
|
||||||
|
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
|
||||||
|
// non-admin), and current-marks the active item — so there is no `current`/`open` state here.
|
||||||
|
export const ADMIN_NAV: NavNode = {
|
||||||
|
children: [
|
||||||
|
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
|
||||||
|
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
|
||||||
|
{ href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" },
|
||||||
|
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
|
||||||
|
],
|
||||||
|
icon: "i-shield",
|
||||||
|
id: "admin",
|
||||||
|
label: "Admin",
|
||||||
|
permission: ADMIN_PERMISSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the
|
||||||
|
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
|
||||||
|
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
|
||||||
|
export function requireAdmin(ctx: RequestContext): User {
|
||||||
|
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
||||||
|
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin role 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: "That item doesn't exist.", title: "Not found" }, status: 404, view: "notice" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A capability the plugin needs isn't on ctx.system (Ory not wired). Login already requires these in
|
||||||
|
// a real deployment, so this is the honest 503 fallback for a misconfigured host, not a crash.
|
||||||
|
export function unavailable(ctx: RequestContext, what: string): RouteResult {
|
||||||
|
return { data: { chrome: ctx.chrome, message: `${what} is not configured on this deployment.`, title: "Admin unavailable" }, status: 503, view: "notice" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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 app.test.ts.
|
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
|
import type { Identity } from "#plugin-api";
|
||||||
import {
|
import {
|
||||||
buildUserFormModel,
|
buildUserFormModel,
|
||||||
buildUsersListModel,
|
buildUsersListModel,
|
||||||
@@ -10,7 +11,6 @@ import {
|
|||||||
toUserView,
|
toUserView,
|
||||||
updateIdentityPayload,
|
updateIdentityPayload,
|
||||||
} from "./admin-users.ts";
|
} 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 id = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
||||||
const identity = (n: number, over: Partial<Identity> = {}): Identity => ({
|
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" });
|
const all = buildUsersListModel({ identities: people, url: "http://x/admin/users" });
|
||||||
assert.equal(all.pagination.summary.total, 30);
|
assert.equal(all.pagination.summary.total, 30);
|
||||||
assert.equal(all.table.rows.length, 25); // default page size
|
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.
|
// Search narrows to one and shows a pill.
|
||||||
const one = buildUsersListModel({ identities: people, url: "http://x/admin/users?q=user7%40example.com" });
|
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", () => {
|
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
|
||||||
const m = buildUserFormModel({ csrfToken: "tok.sig" });
|
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.action, "/admin/users");
|
||||||
assert.equal(m.form.submitLabel, "Create user");
|
assert.equal(m.form.submitLabel, "Create user");
|
||||||
assert.equal(m.form.csrfToken, "tok.sig");
|
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", () => {
|
test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => {
|
||||||
const m = buildUserFormModel({ identity: identity(3) });
|
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.action, `/admin/users/${id(3)}`);
|
||||||
assert.equal(m.form.submitLabel, "Save changes");
|
assert.equal(m.form.submitLabel, "Save changes");
|
||||||
const email = m.form.fields.find((f) => f.name === "email")!;
|
const email = m.form.fields.find((f) => f.name === "email")!;
|
||||||
@@ -1,19 +1,11 @@
|
|||||||
// Built-in Users admin screen (todo §5): list Kratos identities (filter/sort/paginate) +
|
// Users admin screen: list Kratos identities (filter/sort/paginate) +
|
||||||
// create/edit/deactivate/delete/trigger-recovery. Writes go only to Kratos via the admin client
|
// create/edit/deactivate/delete/trigger-recovery. Writes go only to Kratos via the admin client
|
||||||
// (README "stateless"). Pure builders turn identities + the request URL into building-block view
|
// (README "stateless"). Pure builders turn identities + the request URL into building-block view
|
||||||
// models; `handleAdminUsers` is the imperative shell app.ts dispatches to — gated admin-only,
|
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
|
||||||
// CSRF-guarded, each action mapped to a RouteResult (render, or redirect after a write — PRG).
|
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
|
||||||
|
|
||||||
import { safeDecode } from "./admin-groups.ts";
|
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
|
||||||
import { ADMIN_USERS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||||
import type { RequestContext, User } from "./context.ts";
|
|
||||||
import type { Identity, KratosAdmin, RecoveryCode } from "./kratos-admin.ts";
|
|
||||||
import { KratosError } from "./kratos-public.ts";
|
|
||||||
import { parseListQuery } from "./list-query.ts";
|
|
||||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
|
||||||
import { paginate } from "./paginate.ts";
|
|
||||||
import type { RouteResult } from "./plugin.ts";
|
|
||||||
import { buildShellContext } from "./shell-context.ts";
|
|
||||||
|
|
||||||
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
|
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
@@ -117,11 +109,8 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
|||||||
export function buildUsersListModel(opts: {
|
export function buildUsersListModel(opts: {
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
identities: Identity[];
|
identities: Identity[];
|
||||||
menu?: MenuConfig;
|
|
||||||
url: URL | URLSearchParams | string;
|
url: URL | URLSearchParams | string;
|
||||||
user?: User | null;
|
|
||||||
}) {
|
}) {
|
||||||
const menu = opts.menu ?? DEFAULT_MENU;
|
|
||||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||||
const status = query.filters.status?.[0] ?? "all";
|
const status = query.filters.status?.[0] ?? "all";
|
||||||
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
||||||
@@ -144,17 +133,11 @@ export function buildUsersListModel(opts: {
|
|||||||
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken, status };
|
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken, status };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filterBar: listFilterBar(state, all.length),
|
|
||||||
nav: adminNav(opts.user?.roles ?? [], menu, "users"),
|
|
||||||
pagination: listPagination(state, page),
|
|
||||||
shell: buildShellContext({
|
|
||||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Admin" }, { label: "Users" }],
|
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Admin" }, { label: "Users" }],
|
||||||
csrfToken: opts.csrfToken ?? "",
|
filterBar: listFilterBar(state, all.length),
|
||||||
menu,
|
pagination: listPagination(state, page),
|
||||||
title: "Users",
|
|
||||||
user: opts.user ?? null,
|
|
||||||
}),
|
|
||||||
table: listTable(rows, state, sort),
|
table: listTable(rows, state, sort),
|
||||||
|
title: "Users",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,12 +219,9 @@ export function buildUserFormModel(opts: {
|
|||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
identity?: Identity | null;
|
identity?: Identity | null;
|
||||||
menu?: MenuConfig;
|
|
||||||
recovery?: RecoveryCode;
|
recovery?: RecoveryCode;
|
||||||
user?: User | null;
|
|
||||||
values?: Partial<UserInput>;
|
values?: Partial<UserInput>;
|
||||||
}) {
|
}) {
|
||||||
const menu = opts.menu ?? DEFAULT_MENU;
|
|
||||||
const editing = opts.identity != null;
|
const editing = opts.identity != null;
|
||||||
const view = editing ? toUserView(opts.identity!) : null;
|
const view = editing ? toUserView(opts.identity!) : null;
|
||||||
const np = editing ? nameParts(opts.identity!) : { first: opts.values?.first ?? "", last: opts.values?.last ?? "" };
|
const np = editing ? nameParts(opts.identity!) : { first: opts.values?.first ?? "", last: opts.values?.last ?? "" };
|
||||||
@@ -257,6 +237,7 @@ export function buildUserFormModel(opts: {
|
|||||||
if (!editing) fields.push({ autocomplete: "new-password", hint: "Optional — leave blank to have the user set one via a recovery code.", icon: "i-lock", id: "password", label: "Password", name: "password", optional: true, type: "password" });
|
if (!editing) fields.push({ autocomplete: "new-password", hint: "Optional — leave blank to have the user set one via a recovery code.", icon: "i-lock", id: "password", label: "Password", name: "password", optional: true, type: "password" });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: editing ? "Edit" : "New" }],
|
||||||
edit: editing ? {
|
edit: editing ? {
|
||||||
deleteAction: `${idPath}/delete`,
|
deleteAction: `${idPath}/delete`,
|
||||||
id: view!.id,
|
id: view!.id,
|
||||||
@@ -267,28 +248,13 @@ export function buildUserFormModel(opts: {
|
|||||||
} : undefined,
|
} : undefined,
|
||||||
error: opts.error,
|
error: opts.error,
|
||||||
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? "Save changes" : "Create user" },
|
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? "Save changes" : "Create user" },
|
||||||
nav: adminNav(opts.user?.roles ?? [], menu, "users"),
|
|
||||||
recovery: opts.recovery,
|
recovery: opts.recovery,
|
||||||
shell: buildShellContext({
|
|
||||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: editing ? "Edit" : "New" }],
|
|
||||||
csrfToken: opts.csrfToken ?? "",
|
|
||||||
menu,
|
|
||||||
title: editing ? "Edit user" : "New user",
|
title: editing ? "Edit user" : "New user",
|
||||||
user: opts.user ?? null,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- request handler (imperative shell) ----
|
// ---- request handler (imperative shell) ----
|
||||||
|
|
||||||
export interface AdminUsersDeps {
|
|
||||||
csrfSecret: string;
|
|
||||||
kratosAdmin: KratosAdmin;
|
|
||||||
menu: MenuConfig;
|
|
||||||
render: (view: string, data: Record<string, unknown>) => Promise<string>;
|
|
||||||
revoke?: (sub: string) => void; // optional instant-revoke (§9): kill the target's live tokens on deactivate/delete
|
|
||||||
}
|
|
||||||
|
|
||||||
function readUserInput(form: URLSearchParams): UserInput {
|
function readUserInput(form: URLSearchParams): UserInput {
|
||||||
return {
|
return {
|
||||||
email: (form.get("email") ?? "").trim(),
|
email: (form.get("email") ?? "").trim(),
|
||||||
@@ -298,101 +264,112 @@ function readUserInput(form: URLSearchParams): UserInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle a request under /admin/users. Returns null when the path isn't ours (app.ts falls
|
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (admin only) and
|
||||||
// through to its 404). Throws GuardError for auth/CSRF failures (app.ts maps it to a response).
|
// the Kratos capability (else a themed 503). Each route below is a thin handler over these.
|
||||||
export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, deps: AdminUsersDeps): Promise<RouteResult | null> {
|
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
|
||||||
const path = ctx.url.pathname;
|
|
||||||
if (path !== ADMIN_USERS_BASE && !path.startsWith(`${ADMIN_USERS_BASE}/`)) return null;
|
|
||||||
|
|
||||||
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
|
// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the
|
||||||
const { kratosAdmin, menu, render } = deps;
|
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
|
||||||
const method = (ctx.req.method ?? "GET").toUpperCase();
|
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler {
|
||||||
const seg = path.slice(ADMIN_USERS_BASE.length).split("/").filter(Boolean);
|
return async (ctx) => {
|
||||||
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
|
const user = requireAdmin(ctx);
|
||||||
|
const kratosAdmin = ctx.system?.kratosAdmin;
|
||||||
const renderList = async (): Promise<RouteResult> => {
|
if (!kratosAdmin) return unavailable(ctx, "Kratos identity admin");
|
||||||
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
return inner({ ctx, kratosAdmin, revoke: ctx.system?.revoke, user });
|
||||||
return { html: await render("admin/users", { model: buildUsersListModel({ csrfToken, identities, menu, url: ctx.url, user }) }) };
|
|
||||||
};
|
};
|
||||||
const renderForm = async (extra: Parameters<typeof buildUserFormModel>[0]): Promise<RouteResult> =>
|
}
|
||||||
({ html: await render("admin/user-form", { model: buildUserFormModel({ csrfToken, menu, user, ...extra }) }) });
|
|
||||||
|
|
||||||
// /admin/users — list (GET) · create (POST)
|
// Same, plus the target identity from ctx.params.id (unknown id → themed 404). The router already
|
||||||
if (seg.length === 0) {
|
// decoded the id and 404s malformed %-encoding, so no manual decode is needed here.
|
||||||
if (method === "GET") return renderList();
|
function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => Promise<RouteResult>): RouteHandler {
|
||||||
if (method === "POST") {
|
return withUser(async (deps) => {
|
||||||
const input = readUserInput(form!);
|
const id = deps.ctx.params["id"] ?? "";
|
||||||
|
const identity = await deps.kratosAdmin.getIdentity(id);
|
||||||
|
if (!identity) return notFound(deps.ctx);
|
||||||
|
return inner(deps, identity, id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
|
||||||
|
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "user-form" });
|
||||||
|
|
||||||
|
// GET /admin/users — the filtered/sorted/paged list.
|
||||||
|
export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
|
||||||
|
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
||||||
|
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ csrfToken: ctx.chrome.csrfToken, identities, url: ctx.url }) }, view: "users" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
await kratosAdmin.createIdentity(createIdentityPayload(input));
|
await kratosAdmin.createIdentity(createIdentityPayload(input));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof KratosError) return { ...(await renderForm({ error: createError(err), values: input })), status: 400 };
|
if (err instanceof KratosError) return { ...formResult(ctx, { error: createError(err), values: input }), status: 400 };
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
ctx.log.info("admin: user created", { actor: user.id, email: input.email });
|
ctx.log.info("admin: user created", { actor: user.id, email: input.email });
|
||||||
return { redirect: ADMIN_USERS_BASE };
|
return { redirect: ADMIN_USERS_BASE };
|
||||||
}
|
});
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// /admin/users/new — create form
|
// GET /admin/users/new — the empty create form.
|
||||||
if (seg.length === 1 && seg[0] === "new" && method === "GET") return renderForm({});
|
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})));
|
||||||
|
|
||||||
// /admin/users/:id …
|
// GET /admin/users/:id — the edit form, prefilled.
|
||||||
const targetId = safeDecode(seg[0]!); // malformed %-encoding → 404, not a 500 (matches groups/roles/clients)
|
export const usersEditForm = withTarget((deps, identity) => Promise.resolve(formResult(deps.ctx, { identity })));
|
||||||
if (targetId === null) return { html: await render("404", { title: "Not found" }), status: 404 };
|
|
||||||
const identity = await kratosAdmin.getIdentity(targetId);
|
|
||||||
if (!identity) return { html: await render("404", { title: "Not found" }), status: 404 };
|
|
||||||
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(targetId)}`;
|
|
||||||
|
|
||||||
if (seg.length === 1) {
|
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
|
||||||
if (method === "GET") return renderForm({ identity });
|
export const usersUpdate = withTarget(async ({ ctx, kratosAdmin }, identity, id) => {
|
||||||
if (method === "POST") {
|
const input = readUserInput((await guardedForm(ctx))!);
|
||||||
try {
|
try {
|
||||||
await kratosAdmin.updateIdentity(targetId, updateIdentityPayload(identity, readUserInput(form!)));
|
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof KratosError) return { ...(await renderForm({ error: "Could not save changes — check the fields and try again.", identity })), status: 400 };
|
if (err instanceof KratosError) return { ...formResult(ctx, { error: "Could not save changes — check the fields and try again.", identity }), status: 400 };
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
return { redirect: back };
|
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||||
}
|
});
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seg.length === 2) {
|
// POST /admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
|
||||||
const isSelf = targetId === user.id; // self-protection: an admin must not lock themselves out
|
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
|
||||||
if (seg[1] === "delete" && method === "GET") {
|
export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
||||||
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
|
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
|
||||||
const view = toUserView(identity);
|
if (id === user.id) return { ...formResult(ctx, { error: "You can't deactivate your own account.", identity }), status: 400 };
|
||||||
return { html: await render("admin/confirm", { model: buildConfirmModel({
|
|
||||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }],
|
|
||||||
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: "Delete user", csrfToken,
|
|
||||||
current: "users", menu, message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, title: "Delete user", user,
|
|
||||||
}) }) };
|
|
||||||
}
|
|
||||||
if (method === "POST") {
|
|
||||||
if (seg[1] === "state") {
|
|
||||||
if (isSelf) return { ...(await renderForm({ error: "You can't deactivate your own account.", identity })), status: 400 };
|
|
||||||
const nextState = identity.state === "inactive" ? "active" : "inactive";
|
const nextState = identity.state === "inactive" ? "active" : "inactive";
|
||||||
await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, nextState));
|
await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState));
|
||||||
if (nextState === "inactive") deps.revoke?.(targetId); // §9: a deactivation takes effect now, not after the JWT TTL
|
if (nextState === "inactive") revoke?.(id);
|
||||||
ctx.log.info("admin: user state changed", { actor: user.id, state: nextState, target: targetId });
|
ctx.log.info("admin: user state changed", { actor: user.id, state: nextState, target: id });
|
||||||
return { redirect: back };
|
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||||
}
|
});
|
||||||
if (seg[1] === "delete") {
|
|
||||||
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
|
// GET /admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
|
||||||
await kratosAdmin.deleteIdentity(targetId);
|
export const usersDeleteConfirm = withTarget((deps, identity, id) => {
|
||||||
deps.revoke?.(targetId); // §9: the account is gone — reject its live tokens immediately
|
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: "You can't delete your own account.", identity }), status: 400 });
|
||||||
ctx.log.info("admin: user deleted", { actor: user.id, target: targetId });
|
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
|
||||||
|
const view = toUserView(identity);
|
||||||
|
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||||
|
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }],
|
||||||
|
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: "Delete user",
|
||||||
|
message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, title: "Delete user",
|
||||||
|
}) }, view: "confirm" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
|
||||||
|
export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
||||||
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
|
if (id === user.id) return { ...formResult(ctx, { error: "You can't delete your own account.", identity }), status: 400 };
|
||||||
|
await kratosAdmin.deleteIdentity(id);
|
||||||
|
revoke?.(id);
|
||||||
|
ctx.log.info("admin: user deleted", { actor: user.id, target: id });
|
||||||
return { redirect: ADMIN_USERS_BASE };
|
return { redirect: ADMIN_USERS_BASE };
|
||||||
}
|
});
|
||||||
if (seg[1] === "recovery") {
|
|
||||||
const recovery = await kratosAdmin.createRecoveryCode(targetId);
|
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
|
||||||
return renderForm({ identity, recovery });
|
export const usersRecovery = withTarget(async ({ ctx, kratosAdmin }, identity, id) => {
|
||||||
}
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
}
|
const recovery = await kratosAdmin.createRecoveryCode(id);
|
||||||
}
|
return formResult(ctx, { identity, recovery });
|
||||||
return null;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function createError(err: KratosError): string {
|
function createError(err: KratosError): string {
|
||||||
return err.status === 409
|
return err.status === 409
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Admin example plugin: the Users / Groups / Roles / OAuth2-clients screens for running the system.
|
||||||
|
// These used to ship inside the core; they were extracted here so a fresh clone has no built-in admin
|
||||||
|
// GUI. Copy this folder to plugins/admin (then restart) to enable it — see README → Quick start.
|
||||||
|
//
|
||||||
|
// It is a *system* plugin: its handlers reach the host's Ory admin clients (Kratos/Keto/Hydra) and the
|
||||||
|
// instant-revoke hook via ctx.system, which the host populates when those services are wired (the dev
|
||||||
|
// stack wires all of them). Where a capability is absent the screen degrades to a themed 503.
|
||||||
|
|
||||||
|
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
|
||||||
|
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
||||||
|
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
|
||||||
|
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-roles.ts";
|
||||||
|
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||||
|
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
|
||||||
|
|
||||||
|
// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor
|
||||||
|
// to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are
|
||||||
|
// thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style.
|
||||||
|
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION });
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||||
|
|
||||||
|
nav: [ADMIN_NAV],
|
||||||
|
|
||||||
|
permissions: [{ description: "Administer users, groups, roles, and OAuth2 clients", token: ADMIN_PERMISSION }],
|
||||||
|
|
||||||
|
routes: [
|
||||||
|
// Users
|
||||||
|
r("GET", "/users", usersList),
|
||||||
|
r("POST", "/users", usersCreate),
|
||||||
|
r("GET", "/users/new", usersNewForm),
|
||||||
|
r("GET", "/users/:id", usersEditForm),
|
||||||
|
r("POST", "/users/:id", usersUpdate),
|
||||||
|
r("POST", "/users/:id/state", usersState),
|
||||||
|
r("GET", "/users/:id/delete", usersDeleteConfirm),
|
||||||
|
r("POST", "/users/:id/delete", usersDelete),
|
||||||
|
r("POST", "/users/:id/recovery", usersRecovery),
|
||||||
|
// Groups
|
||||||
|
r("GET", "/groups", groupsList),
|
||||||
|
r("POST", "/groups", groupsCreate),
|
||||||
|
r("GET", "/groups/new", groupsNewForm),
|
||||||
|
r("GET", "/groups/:name", groupsDetail),
|
||||||
|
r("POST", "/groups/:name/members", groupsAddMember),
|
||||||
|
r("GET", "/groups/:name/delete", groupsDeleteConfirm),
|
||||||
|
r("POST", "/groups/:name/delete", groupsDelete),
|
||||||
|
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
||||||
|
// Roles
|
||||||
|
r("GET", "/roles", rolesList),
|
||||||
|
r("POST", "/roles", rolesCreate),
|
||||||
|
r("GET", "/roles/new", rolesNewForm),
|
||||||
|
r("GET", "/roles/:name", rolesDetail),
|
||||||
|
r("POST", "/roles/:name/members", rolesAddMember),
|
||||||
|
r("GET", "/roles/:name/delete", rolesDeleteConfirm),
|
||||||
|
r("POST", "/roles/:name/delete", rolesDelete),
|
||||||
|
r("POST", "/roles/:name/members/delete", rolesRemoveMember),
|
||||||
|
// OAuth2 clients
|
||||||
|
r("GET", "/clients", clientsList),
|
||||||
|
r("POST", "/clients", clientsCreate),
|
||||||
|
r("GET", "/clients/new", clientsNewForm),
|
||||||
|
r("GET", "/clients/:id", clientsDetail),
|
||||||
|
r("GET", "/clients/:id/delete", clientsDeleteConfirm),
|
||||||
|
r("POST", "/clients/:id/delete", clientsDelete),
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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", { 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,
|
||||||
|
}) %>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<%#
|
<%#
|
||||||
OAuth2 clients admin list (todo §6): apps that log in *through* us (Hydra). Same building blocks as
|
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
|
||||||
the Roles screen, around the shell, backed by live Hydra OAuth2 clients (src/admin-clients.ts).
|
the Roles screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: model.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const filters = include("partials/filter-bar", model.filterBar);
|
const filters = include("partials/filter-bar", model.filterBar);
|
||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
const pager = include("partials/pagination", model.pagination);
|
||||||
@@ -11,11 +11,11 @@
|
|||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
body: filters + table + pager,
|
body: filters + table + pager,
|
||||||
brand: model.shell.brand,
|
brand: chrome.brand,
|
||||||
breadcrumbs: model.shell.breadcrumbs,
|
breadcrumbs: model.breadcrumbs,
|
||||||
csrfToken: model.shell.csrfToken,
|
csrfToken: chrome.csrfToken,
|
||||||
nav,
|
nav,
|
||||||
theme: model.shell.theme,
|
theme: chrome.theme,
|
||||||
title: model.shell.title,
|
title: model.title,
|
||||||
user: model.shell.user,
|
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, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members });
|
||||||
|
-%>
|
||||||
|
<%- 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,
|
||||||
|
}) %>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<%#
|
<%#
|
||||||
Groups admin list (todo §5): the same building blocks as the Users screen, around the shell, but
|
Groups admin list: the same building blocks as the Users screen, around the shell, but
|
||||||
backed by live Keto subject sets (src/admin-groups.ts). Filter/sort/page round-trip the URL.
|
backed by live Keto subject sets (admin-groups.ts). Filter/sort/page round-trip the URL.
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: model.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const filters = include("partials/filter-bar", model.filterBar);
|
const filters = include("partials/filter-bar", model.filterBar);
|
||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
const pager = include("partials/pagination", model.pagination);
|
||||||
@@ -11,11 +11,11 @@
|
|||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
body: filters + table + pager,
|
body: filters + table + pager,
|
||||||
brand: model.shell.brand,
|
brand: chrome.brand,
|
||||||
breadcrumbs: model.shell.breadcrumbs,
|
breadcrumbs: model.breadcrumbs,
|
||||||
csrfToken: model.shell.csrfToken,
|
csrfToken: chrome.csrfToken,
|
||||||
nav,
|
nav,
|
||||||
theme: model.shell.theme,
|
theme: chrome.theme,
|
||||||
title: model.shell.title,
|
title: model.title,
|
||||||
user: model.shell.user,
|
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,
|
||||||
|
}) %>
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
<%#
|
<%#
|
||||||
Admin OAuth2 client detail body (todo §6), captured into the shell content slot. Config:
|
Admin OAuth2 client detail body, captured into the shell content slot. Config:
|
||||||
client { firstParty, id, name, public, redirectUris[], scopes[] }
|
client { firstParty, id, name, public, redirectUris[], scopes[] }
|
||||||
created bool just registered → success banner
|
created bool just registered → success banner
|
||||||
secret? string one-time client secret (confidential clients), shown once right after create
|
secret? string one-time client secret (confidential clients), shown once right after create
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
-%>
|
-%>
|
||||||
<div class="form-page">
|
<div class="form-page">
|
||||||
<% if (locals.created) { -%>
|
<% if (locals.created) { -%>
|
||||||
<%- include("alert", { text: "Client registered.", tone: "pos" }) %>
|
<%- include("partials/alert", { text: "Client registered.", tone: "pos" }) %>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
<% if (locals.secret) { -%>
|
<% if (locals.secret) { -%>
|
||||||
<section class="form-card" aria-labelledby="secret-h">
|
<section class="form-card" aria-labelledby="secret-h">
|
||||||
+4
-4
@@ -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),
|
form { action, csrfToken, submitLabel, cancelHref, nameField, scopeField (field.ejs configs),
|
||||||
redirectUris: string (newline-separated), public: bool, firstParty: bool }
|
redirectUris: string (newline-separated), public: bool, firstParty: bool }
|
||||||
error? string shown when a write was rejected
|
error? string shown when a write was rejected
|
||||||
@@ -8,17 +8,17 @@
|
|||||||
-%>
|
-%>
|
||||||
<div class="form-page">
|
<div class="form-page">
|
||||||
<% if (locals.error) { -%>
|
<% 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="<%= form.action %>">
|
||||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||||
<%- include("field", form.nameField) %>
|
<%- include("partials/field", form.nameField) %>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="redirectUris">Redirect URIs</label>
|
<label for="redirectUris">Redirect URIs</label>
|
||||||
<textarea class="input" id="redirectUris" name="redirectUris" rows="3" placeholder="https://app.example.com/callback"><%= form.redirectUris %></textarea>
|
<textarea class="input" id="redirectUris" name="redirectUris" rows="3" placeholder="https://app.example.com/callback"><%= form.redirectUris %></textarea>
|
||||||
<span class="field-hint">One per line — where the app is sent back after sign-in.</span>
|
<span class="field-hint">One per line — where the app is sent back after sign-in.</span>
|
||||||
</div>
|
</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>
|
<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">Browser and mobile apps can't keep a secret — choose Public. Server-side apps that can store one — leave it Confidential.</span>
|
||||||
<label class="check"><input type="checkbox" name="firstParty"<% if (form.firstParty) { %> checked<% } %>> First-party (auto-grant consent — skip the consent screen)</label>
|
<label class="check"><input type="checkbox" name="firstParty"<% if (form.firstParty) { %> checked<% } %>> First-party (auto-grant consent — skip the consent screen)</label>
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<%#
|
<%#
|
||||||
Destructive-action confirm body (todo §5), captured into the shell content slot. Zero-JS: the
|
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:
|
delete is a deliberate second step (a POST form), with a cancel link back. Config:
|
||||||
message string
|
message string
|
||||||
confirm { action, label } the danger POST endpoint + button label
|
confirm { action, label } the danger POST endpoint + button label
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
<%#
|
<%#
|
||||||
Admin group membership body (todo §5), captured into the shell content slot. Config:
|
Admin group membership body, captured into the shell content slot. Config:
|
||||||
group { name }
|
group { name }
|
||||||
members { action, rows: { kind:"group"|"user", label, subject }[] } action = remove-member endpoint
|
members { action, rows: { kind:"group"|"user", label, subject }[] } action = remove-member endpoint
|
||||||
add { action, options: {label,value}[] } action = add-member endpoint
|
add { action, options: {label,value}[] } action = add-member endpoint
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
-%>
|
-%>
|
||||||
<div class="form-page">
|
<div class="form-page">
|
||||||
<% if (locals.error) { -%>
|
<% if (locals.error) { -%>
|
||||||
<%- include("alert", { text: locals.error, tone: "neg" }) %>
|
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
<section class="form-card" aria-labelledby="members-h">
|
<section class="form-card" aria-labelledby="members-h">
|
||||||
<h2 class="card-title" id="members-h">Members</h2>
|
<h2 class="card-title" id="members-h">Members</h2>
|
||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
<%#
|
<%#
|
||||||
Admin group create form body (todo §5), captured into the shell content slot. Config:
|
Admin group create form body, captured into the shell content slot. Config:
|
||||||
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
|
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
|
||||||
memberOptions: {label,value}[], selectedMember }
|
memberOptions: {label,value}[], selectedMember }
|
||||||
error? string shown when a write was rejected
|
error? string shown when a write was rejected
|
||||||
@@ -8,11 +8,11 @@
|
|||||||
-%>
|
-%>
|
||||||
<div class="form-page">
|
<div class="form-page">
|
||||||
<% if (locals.error) { -%>
|
<% 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="<%= form.action %>">
|
||||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||||
<%- include("field", form.nameField) %>
|
<%- include("partials/field", form.nameField) %>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="member">First member</label>
|
<label for="member">First member</label>
|
||||||
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a member…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
|
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a member…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<%# One-line notice body (not-found / unavailable). Data: message. %>
|
||||||
|
<section class="notice"><p><%= message %></p></section>
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
<%#
|
<%#
|
||||||
Admin role detail body (todo §5), captured into the shell content slot. Config:
|
Admin role detail body, captured into the shell content slot. Config:
|
||||||
role { name }
|
role { name }
|
||||||
members { action, rows: { kind:"group"|"user", label, subject }[] } action = revoke endpoint
|
members { action, rows: { kind:"group"|"user", label, subject }[] } action = revoke endpoint
|
||||||
effective { label }[] users who hold the role (expand)
|
effective { label }[] users who hold the role (expand)
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
-%>
|
-%>
|
||||||
<div class="form-page">
|
<div class="form-page">
|
||||||
<% if (locals.error) { -%>
|
<% if (locals.error) { -%>
|
||||||
<%- include("alert", { text: locals.error, tone: "neg" }) %>
|
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
<section class="form-card" aria-labelledby="members-h">
|
<section class="form-card" aria-labelledby="members-h">
|
||||||
<h2 class="card-title" id="members-h">Assigned to</h2>
|
<h2 class="card-title" id="members-h">Assigned to</h2>
|
||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
<%#
|
<%#
|
||||||
Admin role create form body (todo §5), captured into the shell content slot. Config:
|
Admin role create form body, captured into the shell content slot. Config:
|
||||||
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
|
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
|
||||||
memberOptions: {label,value}[], selectedMember }
|
memberOptions: {label,value}[], selectedMember }
|
||||||
error? string shown when a write was rejected
|
error? string shown when a write was rejected
|
||||||
@@ -8,11 +8,11 @@
|
|||||||
-%>
|
-%>
|
||||||
<div class="form-page">
|
<div class="form-page">
|
||||||
<% if (locals.error) { -%>
|
<% 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="<%= form.action %>">
|
||||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||||
<%- include("field", form.nameField) %>
|
<%- include("partials/field", form.nameField) %>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="member">Assign to</label>
|
<label for="member">Assign to</label>
|
||||||
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
|
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
|
||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
<%#
|
<%#
|
||||||
Admin user create/edit form body (todo §5), captured into the shell content slot. Config:
|
Admin user create/edit form body, captured into the shell content slot. Config:
|
||||||
form { action, csrfToken, submitLabel, cancelHref, fields: field.ejs config[] }
|
form { action, csrfToken, submitLabel, cancelHref, fields: field.ejs config[] }
|
||||||
edit? { nextLabel, stateAction, recoveryAction, deleteAction } (edit mode only)
|
edit? { nextLabel, stateAction, recoveryAction, deleteAction } (edit mode only)
|
||||||
recovery? { code? } shown after a recovery code is generated (recovery is code-based)
|
recovery? { code? } shown after a recovery code is generated (recovery is code-based)
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
-%>
|
-%>
|
||||||
<div class="form-page">
|
<div class="form-page">
|
||||||
<% if (locals.error) { -%>
|
<% if (locals.error) { -%>
|
||||||
<%- include("alert", { text: locals.error, tone: "neg" }) %>
|
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
<% if (recovery) { -%>
|
<% if (recovery) { -%>
|
||||||
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong>Recovery code generated</strong><span>Give it to the user — they enter it on the <a href="/recovery">password-reset screen</a> to set a new password (generate a fresh one if it has expired).</span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
|
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong>Recovery code generated</strong><span>Give it to the user — they enter it on the <a href="/recovery">password-reset screen</a> to set a new password (generate a fresh one if it has expired).</span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<form class="form-card" method="post" action="<%= form.action %>">
|
<form class="form-card" method="post" action="<%= form.action %>">
|
||||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||||
<% form.fields.forEach((field) => { -%>
|
<% form.fields.forEach((field) => { -%>
|
||||||
<%- include("field", field) %>
|
<%- include("partials/field", field) %>
|
||||||
<% }) -%>
|
<% }) -%>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<%#
|
||||||
|
Role admin detail page: the role-detail body (members · effective access) in the shell.
|
||||||
|
%><%
|
||||||
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
|
const body = include("partials/role-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, role: model.role });
|
||||||
|
-%>
|
||||||
|
<%- include("partials/shell", {
|
||||||
|
body,
|
||||||
|
brand: chrome.brand,
|
||||||
|
breadcrumbs: model.breadcrumbs,
|
||||||
|
csrfToken: chrome.csrfToken,
|
||||||
|
nav,
|
||||||
|
theme: chrome.theme,
|
||||||
|
title: model.title,
|
||||||
|
user: chrome.user,
|
||||||
|
}) %>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<%#
|
||||||
|
Role admin create page: the role-form body captured into the app shell.
|
||||||
|
%><%
|
||||||
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
|
const body = include("partials/role-form-body", { error: model.error, form: model.form });
|
||||||
|
-%>
|
||||||
|
<%- include("partials/shell", {
|
||||||
|
body,
|
||||||
|
brand: chrome.brand,
|
||||||
|
breadcrumbs: model.breadcrumbs,
|
||||||
|
csrfToken: chrome.csrfToken,
|
||||||
|
nav,
|
||||||
|
theme: chrome.theme,
|
||||||
|
title: model.title,
|
||||||
|
user: chrome.user,
|
||||||
|
}) %>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<%#
|
<%#
|
||||||
Roles admin list (todo §5): the same building blocks as the Groups screen, around the shell, backed
|
Roles admin list: the same building blocks as the Groups screen, around the shell, backed
|
||||||
by live Keto Role subject sets (src/admin-roles.ts). Filter/sort/page round-trip the URL.
|
by live Keto Role subject sets (admin-roles.ts). Filter/sort/page round-trip the URL.
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: model.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const filters = include("partials/filter-bar", model.filterBar);
|
const filters = include("partials/filter-bar", model.filterBar);
|
||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
const pager = include("partials/pagination", model.pagination);
|
||||||
@@ -11,11 +11,11 @@
|
|||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
body: filters + table + pager,
|
body: filters + table + pager,
|
||||||
brand: model.shell.brand,
|
brand: chrome.brand,
|
||||||
breadcrumbs: model.shell.breadcrumbs,
|
breadcrumbs: model.breadcrumbs,
|
||||||
csrfToken: model.shell.csrfToken,
|
csrfToken: chrome.csrfToken,
|
||||||
nav,
|
nav,
|
||||||
theme: model.shell.theme,
|
theme: chrome.theme,
|
||||||
title: model.shell.title,
|
title: model.title,
|
||||||
user: model.shell.user,
|
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", { edit: model.edit, error: model.error, form: model.form, 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,
|
||||||
|
}) %>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<%#
|
<%#
|
||||||
Users admin list (todo §5): the same building blocks as the dashboard, around the shell, but
|
Users admin list: the same building blocks as the dashboard, around the shell, but
|
||||||
backed by live Kratos identities (src/admin-users.ts). Filter/sort/page all round-trip the URL.
|
backed by live Kratos identities (admin-users.ts). Filter/sort/page all round-trip the URL.
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: model.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const filters = include("partials/filter-bar", model.filterBar);
|
const filters = include("partials/filter-bar", model.filterBar);
|
||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
const pager = include("partials/pagination", model.pagination);
|
||||||
@@ -11,11 +11,11 @@
|
|||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
body: filters + table + pager,
|
body: filters + table + pager,
|
||||||
brand: model.shell.brand,
|
brand: chrome.brand,
|
||||||
breadcrumbs: model.shell.breadcrumbs,
|
breadcrumbs: model.breadcrumbs,
|
||||||
csrfToken: model.shell.csrfToken,
|
csrfToken: chrome.csrfToken,
|
||||||
nav,
|
nav,
|
||||||
theme: model.shell.theme,
|
theme: chrome.theme,
|
||||||
title: model.shell.title,
|
title: model.title,
|
||||||
user: model.shell.user,
|
user: chrome.user,
|
||||||
}) %>
|
}) %>
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# Scheduling — the reference plugin
|
# Scheduling — the reference plugin
|
||||||
|
|
||||||
A worked example of the [plugin contract](../../docs/plugin-contract.md). Copy this folder, rename
|
A worked example of the [plugin contract](../../../README.md#building-plugins). Copy this folder into
|
||||||
it (the folder name becomes the plugin id and mount path), and point it at your own backend.
|
`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:
|
What it demonstrates:
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ What it demonstrates:
|
|||||||
service and renders the rows with the core building blocks (`shifts.ejs` → app shell, filter-bar,
|
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
|
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`/
|
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,
|
- **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,
|
`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`,
|
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// Reference plugin (todo §7): a worked example of the contract — a list page that fetches upstream
|
// 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
|
// 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: docs/plugin-contract.md.
|
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
||||||
|
|
||||||
import { definePlugin } from "../../src/plugin-api.ts";
|
import { definePlugin } from "#plugin-api";
|
||||||
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
import { assertHttpUrl, 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
|
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
|
||||||
@@ -19,7 +19,7 @@ export default definePlugin({
|
|||||||
|
|
||||||
// Merged into the global menu + filtered per user. "Overview" is `public`, so the "Scheduling"
|
// Merged into the global menu + filtered per user. "Overview" is `public`, so the "Scheduling"
|
||||||
// header shows for everyone (even signed out); "Shifts" needs `scheduling:read`, so the gated data
|
// header shows for everyone (even signed out); "Shifts" needs `scheduling:read`, so the gated data
|
||||||
// stays hidden until a reader signs in (§10 — a plugin may make a page + its menu option public).
|
// stays hidden until a reader signs in (a plugin may make a page + its menu option public).
|
||||||
nav: [{
|
nav: [{
|
||||||
children: [
|
children: [
|
||||||
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
|
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
|
||||||
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
// Import only from the plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
// Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
||||||
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
||||||
import { GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "../../src/plugin-api.ts";
|
import { GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api";
|
||||||
import {
|
import {
|
||||||
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
||||||
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
||||||
@@ -112,7 +112,7 @@ test("listShifts degrades to a recoverable error page when the upstream is down
|
|||||||
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
|
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- public overview handler (§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 role) ----
|
||||||
|
|
||||||
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
||||||
const anon = asView(await overview()(fakeCtx())); // user null, no roles
|
const anon = asView(await overview()(fakeCtx())); // user null, no roles
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
// 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
|
// 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.
|
// 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
|
// 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).
|
// One import from the host's #plugin-api barrel — the stable author surface (see README.md → Building plugins).
|
||||||
import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, tracedFetch } from "../../src/plugin-api.ts";
|
import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, tracedFetch } from "#plugin-api";
|
||||||
|
|
||||||
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page (§10)
|
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
||||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||||
export const READ = "scheduling:read"; // permission token gating the list + nav
|
export const READ = "scheduling:read"; // permission token gating the list + nav
|
||||||
export const WRITE = "scheduling:write"; // permission token gating create
|
export const WRITE = "scheduling:write"; // permission token gating create
|
||||||
@@ -56,7 +56,7 @@ export function assertHttpUrl(value: string, name: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
|
// 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.
|
// 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 {
|
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
|
||||||
const base = baseUrl.replace(/\/+$/, "");
|
const base = baseUrl.replace(/\/+$/, "");
|
||||||
@@ -172,7 +172,7 @@ export function listShifts(upstream: ShiftsUpstream): RouteHandler {
|
|||||||
try {
|
try {
|
||||||
shifts = await upstream.list();
|
shifts = await upstream.list();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); // plugin logging via ctx.log (§9)
|
ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); // plugin logging via ctx.log
|
||||||
error = "Couldn't reach the scheduling service — try again shortly.";
|
error = "Couldn't reach the scheduling service — try again shortly.";
|
||||||
}
|
}
|
||||||
const needle = q.toLowerCase();
|
const needle = q.toLowerCase();
|
||||||
@@ -185,7 +185,7 @@ export function newShiftForm(): RouteHandler {
|
|||||||
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome }), view: "shift-new" });
|
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome }), 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
|
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
||||||
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
||||||
// else a prompt to sign in. ctx.user may be null here, so read the role via can() (zero I/O).
|
// else a prompt to sign in. ctx.user may be null here, so read the role via can() (zero I/O).
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<%#
|
<%#
|
||||||
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
|
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
|
||||||
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
|
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
|
||||||
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
|
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Dev-only mock upstream for the reference plugin (plugins/scheduling) — a stand-in for the
|
// Dev-only mock upstream for the reference plugin (examples/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
|
// customer's real backend, ready for when you copy the reference plugin into plugins/. NOT part
|
||||||
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point SCHEDULING_UPSTREAM
|
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point SCHEDULING_UPSTREAM
|
||||||
// at your real service in production.
|
// at your real service in production.
|
||||||
//
|
//
|
||||||
@@ -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>
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"errors":null,"message":"not found","url":"https://gitea.larvit.se/api/swagger"}
|
||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
# plainpages (README: "OAuth2 provider"). The web app implements Hydra's login &
|
# plainpages (README: "OAuth2 provider"). The web app implements Hydra's login &
|
||||||
# consent steps at the URLs below, authenticating the user via their Kratos session;
|
# 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
|
# 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:
|
serve:
|
||||||
public:
|
public:
|
||||||
port: 4444
|
port: 4444
|
||||||
@@ -10,7 +10,7 @@ serve:
|
|||||||
port: 4445
|
port: 4445
|
||||||
|
|
||||||
# issuer = the public OAuth2 URL clients use; login/consent/logout hand the browser to
|
# 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:
|
urls:
|
||||||
self:
|
self:
|
||||||
issuer: http://127.0.0.1:4444/
|
issuer: http://127.0.0.1:4444/
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
|
|||||||
class User implements Namespace {}
|
class User implements Namespace {}
|
||||||
|
|
||||||
// A subject set: a named collection of users (and nested groups), resolved transitively.
|
// A subject set: a named collection of users (and nested groups), resolved transitively.
|
||||||
// The admin "Groups" screen (§5) manages membership; checks expand it automatically.
|
// The admin "Groups" screen manages membership; checks expand it automatically.
|
||||||
class Group implements Namespace {
|
class Group implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
members: (User | SubjectSet<Group, "members">)[]
|
members: (User | SubjectSet<Group, "members">)[]
|
||||||
|
|||||||
@@ -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`
|
# 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
|
# 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.
|
# browser sees them. The normal (10m) tokenizer TTL from kratos.yml is kept — no re-mint mid-test.
|
||||||
serve:
|
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:
|
# 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.
|
# 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
|
# 2. A public base_url on the compose-network hostname, so the Playwright runner can drive the
|
||||||
|
|||||||
+21
-18
@@ -1,27 +1,30 @@
|
|||||||
# Ory Kratos — identity & self-service auth. Identity schema (email, name) +
|
# Ory Kratos — identity & self-service auth. Identity schema (email, name) +
|
||||||
# password login; recovery & verification run on email codes. Every self-service
|
# 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
|
# courier/secrets come from the env. Session→JWT tokenizer wired below (signing
|
||||||
# key in tokenizer/jwks.json).
|
# key in tokenizer/jwks.json).
|
||||||
serve:
|
serve:
|
||||||
public:
|
public:
|
||||||
base_url: http://127.0.0.1:4433/
|
base_url: http://localhost:4433/
|
||||||
cors:
|
cors:
|
||||||
enabled: false
|
enabled: false
|
||||||
admin:
|
admin:
|
||||||
base_url: http://kratos:4434/
|
base_url: http://kratos:4434/
|
||||||
|
|
||||||
selfservice:
|
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:
|
allowed_return_urls:
|
||||||
- http://127.0.0.1:3000
|
- http://localhost:3000
|
||||||
methods:
|
methods:
|
||||||
password:
|
password:
|
||||||
enabled: true
|
enabled: true
|
||||||
code: # email one-time code — powers recovery + verification (not login)
|
code: # email one-time code — powers recovery + verification (not login)
|
||||||
enabled: true
|
enabled: true
|
||||||
# Social sign-in, OFF by default → clean clone is password-only. Activate via env only
|
# 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).
|
# the buttons from this list. SAML isn't in OSS Kratos — bridge it as OIDC (README).
|
||||||
# SELFSERVICE_METHODS_OIDC_ENABLED=true
|
# SELFSERVICE_METHODS_OIDC_ENABLED=true
|
||||||
# SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS=[{"id":"google","provider":"google",
|
# SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS=[{"id":"google","provider":"google",
|
||||||
@@ -33,37 +36,37 @@ selfservice:
|
|||||||
providers: []
|
providers: []
|
||||||
flows:
|
flows:
|
||||||
error:
|
error:
|
||||||
ui_url: http://127.0.0.1:3000/error
|
ui_url: http://localhost:3000/error
|
||||||
login:
|
login:
|
||||||
ui_url: http://127.0.0.1:3000/login
|
ui_url: http://localhost:3000/login
|
||||||
after:
|
after:
|
||||||
# After authenticating, land on our completion route — it mints the session JWT
|
# After authenticating, land on our completion route — it mints the session JWT
|
||||||
# (roles from Keto → metadata_public projection → tokenize) and sets our cookie (§4).
|
# (roles from Keto → metadata_public projection → tokenize) and sets our cookie.
|
||||||
default_browser_return_url: http://127.0.0.1:3000/auth/complete
|
default_browser_return_url: http://localhost:3000/auth/complete
|
||||||
registration:
|
registration:
|
||||||
ui_url: http://127.0.0.1:3000/registration
|
ui_url: http://localhost:3000/registration
|
||||||
after:
|
after:
|
||||||
password:
|
password:
|
||||||
hooks:
|
hooks:
|
||||||
- hook: session # log in immediately after sign-up
|
- hook: session # log in immediately after sign-up
|
||||||
- hook: show_verification_ui
|
- hook: show_verification_ui
|
||||||
settings:
|
settings:
|
||||||
ui_url: http://127.0.0.1:3000/settings
|
ui_url: http://localhost:3000/settings
|
||||||
privileged_session_max_age: 15m
|
privileged_session_max_age: 15m
|
||||||
required_aal: highest_available
|
required_aal: highest_available
|
||||||
recovery:
|
recovery:
|
||||||
enabled: true
|
enabled: true
|
||||||
use: code
|
use: code
|
||||||
ui_url: http://127.0.0.1:3000/recovery
|
ui_url: http://localhost:3000/recovery
|
||||||
verification:
|
verification:
|
||||||
enabled: true
|
enabled: true
|
||||||
use: code
|
use: code
|
||||||
ui_url: http://127.0.0.1:3000/verification
|
ui_url: http://localhost:3000/verification
|
||||||
after:
|
after:
|
||||||
default_browser_return_url: http://127.0.0.1:3000/
|
default_browser_return_url: http://localhost:3000/
|
||||||
logout:
|
logout:
|
||||||
after:
|
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.
|
# Dev mail catcher (compose.override.yml). Prod overrides via COURIER_SMTP_CONNECTION_URI.
|
||||||
courier:
|
courier:
|
||||||
@@ -79,7 +82,7 @@ identity:
|
|||||||
url: file:///etc/config/kratos/identity.schema.json
|
url: file:///etc/config/kratos/identity.schema.json
|
||||||
|
|
||||||
# "Stay signed in" backbone: a long-lived Kratos session that the app re-mints the
|
# "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,
|
# 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.
|
# so frequent users never lapse without a DB write per request.
|
||||||
session:
|
session:
|
||||||
@@ -89,7 +92,7 @@ session:
|
|||||||
name: plainpages_session
|
name: plainpages_session
|
||||||
persistent: true # survive browser restarts
|
persistent: true # survive browser restarts
|
||||||
same_site: Lax
|
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
|
# 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, roles from the
|
||||||
# metadata_public projection); signed with tokenizer/jwks.json.
|
# metadata_public projection); signed with tokenizer/jwks.json.
|
||||||
@@ -102,7 +105,7 @@ session:
|
|||||||
claims_mapper_url: file:///etc/config/kratos/tokenizer/plainpages.jsonnet
|
claims_mapper_url: file:///etc/config/kratos/tokenizer/plainpages.jsonnet
|
||||||
jwks_url: file:///etc/config/kratos/tokenizer/jwks.json
|
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:
|
secrets:
|
||||||
cookie:
|
cookie:
|
||||||
- PLEASE-CHANGE-ME-dev-kratos-cookie-secret
|
- PLEASE-CHANGE-ME-dev-kratos-cookie-secret
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// 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
|
// session as `session`; `sub` is set from the identity id (subject_source: id) and
|
||||||
// can't be overridden here. roles come from metadata_public — the per-login projection
|
// can't be overridden here. 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
|
// of Keto roles the app refreshes at login (metadata_admin is NOT carried in the session
|
||||||
|
|||||||
Generated
+377
-84
@@ -9,13 +9,13 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "3.1.10",
|
"ejs": "6.0.1",
|
||||||
"lucide-static": "1.18.0"
|
"lucide-static": "1.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "24.13.2",
|
"@types/node": "24.13.3",
|
||||||
"typescript": "5.9.3"
|
"typescript": "7.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
@@ -38,113 +38,406 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.2",
|
"version": "24.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/async": {
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
"version": "3.2.6",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||||
"license": "MIT"
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||||
"version": "1.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||||
"license": "MIT"
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/@typescript/typescript-darwin-x64": {
|
||||||
"version": "2.1.1",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||||
"license": "MIT",
|
"cpu": [
|
||||||
"dependencies": {
|
"x64"
|
||||||
"balanced-match": "^1.0.0"
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-loong64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-s390x": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-sunos-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ejs": {
|
"node_modules/ejs": {
|
||||||
"version": "3.1.10",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
|
||||||
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
|
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
|
||||||
"jake": "^10.8.5"
|
|
||||||
},
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"ejs": "bin/cli.js"
|
"ejs": "bin/cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.12.18"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/filelist": {
|
|
||||||
"version": "1.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
|
||||||
"integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"minimatch": "^5.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jake": {
|
|
||||||
"version": "10.9.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
|
|
||||||
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"async": "^3.2.6",
|
|
||||||
"filelist": "^1.0.4",
|
|
||||||
"picocolors": "^1.1.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"jake": "bin/cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-static": {
|
"node_modules/lucide-static": {
|
||||||
"version": "1.18.0",
|
"version": "1.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.28.0.tgz",
|
||||||
"integrity": "sha512-0WRXLQnjbte5SXuzom6yfeGlVSFsEsC9rzxn66DZN0pXows3+N34CQHy3BHI1qA3uH7u/SUzx8LQhjeAnxd8JQ==",
|
"integrity": "sha512-dC3VJwRFsjEVX7Iaq4rY88pm7Fi2OmOb8P0WRzXsUMgbt7sCmFX8bLhaDBeNW6JdRjuele+jKqqFaam4yr+Ygg==",
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/minimatch": {
|
|
||||||
"version": "5.1.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
|
||||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/picocolors": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc"
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.17"
|
"node": ">=16.20.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-x64": "7.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
|
|||||||
+10
-6
@@ -6,21 +6,25 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
|
"imports": {
|
||||||
|
"#menu-config": "./src/ui/menu-config.ts",
|
||||||
|
"#plugin-api": "./src/plugin-host/plugin-api.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/server.ts",
|
"start": "node src/server.ts",
|
||||||
"dev": "node --watch src/server.ts",
|
"dev": "node --watch src/server.ts",
|
||||||
"gen-jwks": "node src/gen-jwks.ts",
|
"gen-jwks": "node src/auth/gen-jwks.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\""
|
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"auto-release/**/*.test.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "3.1.10",
|
"ejs": "6.0.1",
|
||||||
"lucide-static": "1.18.0"
|
"lucide-static": "1.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "24.13.2",
|
"@types/node": "24.13.3",
|
||||||
"typescript": "5.9.3"
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
}
|
}
|
||||||
.auth-brand .brand-name { font-size: 16px; }
|
.auth-brand .brand-name { font-size: 16px; }
|
||||||
|
|
||||||
/* public landing — the ungated "/" (§10): centered intro + prominent sign-in/register actions */
|
/* public landing — the ungated "/": centered intro + prominent sign-in/register actions */
|
||||||
.landing {
|
.landing {
|
||||||
width: 100%; max-width: 560px;
|
width: 100%; max-width: 560px;
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 18px;
|
display: flex; flex-direction: column; align-items: center; gap: 18px;
|
||||||
|
|||||||
@@ -666,7 +666,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
/* the nav-toggle checkbox itself is visually hidden but focusable */
|
/* the nav-toggle checkbox itself is visually hidden but focusable */
|
||||||
#nav-toggle { position: absolute; opacity: 0; pointer-events: none; }
|
#nav-toggle { position: absolute; opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
/* admin forms (§5): create/edit user, account actions */
|
/* admin forms: create/edit user, account actions */
|
||||||
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
|
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
|
||||||
.form-card {
|
.form-card {
|
||||||
display: flex; flex-direction: column; gap: 14px;
|
display: flex; flex-direction: column; gap: 14px;
|
||||||
@@ -685,3 +685,9 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
.btn-danger { color: var(--neg); border-color: var(--neg-bd); }
|
.btn-danger { color: var(--neg); border-color: var(--neg-bd); }
|
||||||
.btn-danger:hover { background: var(--neg-bg); }
|
.btn-danger:hover { background: var(--neg-bg); }
|
||||||
.recovery-code code { font-size: 1.15rem; font-weight: 600; letter-spacing: 0.04em; }
|
.recovery-code code { font-size: 1.15rem; font-weight: 600; letter-spacing: 0.04em; }
|
||||||
|
.code-block { margin: 0; padding: 12px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); overflow-x: auto; font-size: var(--fz-sm); }
|
||||||
|
/* Chromeless shell: a page may drop the sidebar for a focused single column. */
|
||||||
|
.app-bare { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.app-bare .content { grid-column: 1; }
|
||||||
|
/* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */
|
||||||
|
.shell-auth { flex: 1 1 auto; overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Prunes the app's container package in the Gitea registry: a commit-hash tag survives only
|
||||||
|
// while its commit is a branch head or carries a vX.Y.Z release tag. Untagged sha256:* package
|
||||||
|
// versions are CHILD manifests (arch image + provenance) of the tagged OCI indexes — deleting
|
||||||
|
// one that a surviving tag still references breaks that image, so only the ones no kept tag
|
||||||
|
// references are removed. Named tags (1.2.3, latest, …) are never touched.
|
||||||
|
import { planHashTagDeletions, selectOrphanedManifests } from "./select-versions.ts";
|
||||||
|
|
||||||
|
function env(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (value === undefined || value === "") throw new Error(`Missing env var ${name}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registryToken = env("REGISTRY_TOKEN");
|
||||||
|
const registryUser = env("REGISTRY_USER");
|
||||||
|
const repoToken = env("REPO_TOKEN");
|
||||||
|
const repository = env("REPOSITORY");
|
||||||
|
const serverUrl = env("SERVER_URL").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
const [owner, name] = repository.split("/");
|
||||||
|
if (owner === undefined || name === undefined || owner === "" || name === "") {
|
||||||
|
throw new Error(`REPOSITORY must be owner/name, got "${repository}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOk(url: string, init: RequestInit): Promise<Response> {
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
if (!res.ok) throw new Error(`${init.method ?? "GET"} ${url} -> ${res.status}: ${await res.text()}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiGetAllPages<T>(path: string, token: string): Promise<T[]> {
|
||||||
|
const all: T[] = [];
|
||||||
|
const limit = 50;
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const sep = path.includes("?") ? "&" : "?";
|
||||||
|
const res = await fetchOk(`${serverUrl}/api/v1${path}${sep}limit=${limit}&page=${page}`, {
|
||||||
|
headers: { authorization: `token ${token}` },
|
||||||
|
});
|
||||||
|
const batch = (await res.json()) as T[];
|
||||||
|
// Stop on an empty page, not a short one — a lowered server page-size cap would otherwise
|
||||||
|
// silently truncate the list, and a truncated branch/tag list deletes protected images.
|
||||||
|
if (batch.length === 0) return all;
|
||||||
|
all.push(...batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function childDigests(tag: string): Promise<string[]> {
|
||||||
|
const res = await fetchOk(`${serverUrl}/v2/${owner}/${name}/manifests/${tag}`, {
|
||||||
|
headers: {
|
||||||
|
accept: [
|
||||||
|
"application/vnd.oci.image.index.v1+json",
|
||||||
|
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||||
|
"application/vnd.oci.image.manifest.v1+json",
|
||||||
|
"application/vnd.docker.distribution.manifest.v2+json",
|
||||||
|
].join(", "),
|
||||||
|
authorization: `Basic ${Buffer.from(`${registryUser}:${registryToken}`).toString("base64")}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const manifest = (await res.json()) as { manifests?: { digest: string }[] };
|
||||||
|
return (manifest.manifests ?? []).map((m) => m.digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteVersion(version: string): Promise<void> {
|
||||||
|
const url = `${serverUrl}/api/v1/packages/${owner}/container/${name}/${encodeURIComponent(version)}`;
|
||||||
|
const res = await fetch(url, { headers: { authorization: `token ${registryToken}` }, method: "DELETE" });
|
||||||
|
if (res.status === 404) {
|
||||||
|
console.log(`already gone: ${version}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`DELETE ${url} -> ${res.status}: ${await res.text()}`);
|
||||||
|
console.log(`deleted: ${version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const branches = await apiGetAllPages<{ commit: { id: string } }>(`/repos/${owner}/${name}/branches`, repoToken);
|
||||||
|
if (branches.length === 0) throw new Error("no branches returned — refusing to prune with an empty keep-set");
|
||||||
|
const tags = await apiGetAllPages<{ commit: { sha: string }; name: string }>(`/repos/${owner}/${name}/tags`, repoToken);
|
||||||
|
const packages = await apiGetAllPages<{ name: string; version: string }>(
|
||||||
|
`/packages/${owner}?type=container&q=${encodeURIComponent(name)}`,
|
||||||
|
registryToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
const versions = packages.filter((p) => p.name === name).map((p) => p.version);
|
||||||
|
const plan = planHashTagDeletions({
|
||||||
|
branchHeadShas: branches.map((b) => b.commit.id),
|
||||||
|
releaseTagShas: tags.filter((t) => /^v\d+\.\d+\.\d+$/.test(t.name)).map((t) => t.commit.sha),
|
||||||
|
versions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const referenced = new Set<string>();
|
||||||
|
for (const tag of plan.keptTags) {
|
||||||
|
for (const digest of await childDigests(tag)) referenced.add(digest);
|
||||||
|
}
|
||||||
|
const orphans = selectOrphanedManifests({ referencedDigests: [...referenced], versions });
|
||||||
|
|
||||||
|
for (const version of [...plan.deletions, ...orphans]) await deleteVersion(version);
|
||||||
|
console.log(
|
||||||
|
`kept ${plan.keptTags.length} tags; deleted ${plan.deletions.length} hash tags and ${orphans.length} orphaned manifests`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { planHashTagDeletions, selectOrphanedManifests } from "./select-versions.ts";
|
||||||
|
|
||||||
|
const headSha = "0582809000000000000000000000000000000001";
|
||||||
|
const releaseSha = "50006dd000000000000000000000000000000002";
|
||||||
|
const staleSha = "c8981c1000000000000000000000000000000003";
|
||||||
|
|
||||||
|
test("planHashTagDeletions deletes hash tags that are neither a branch head nor release-tagged", () => {
|
||||||
|
const plan = planHashTagDeletions({
|
||||||
|
branchHeadShas: [headSha],
|
||||||
|
releaseTagShas: [releaseSha],
|
||||||
|
versions: [headSha, releaseSha, staleSha],
|
||||||
|
});
|
||||||
|
assert.deepEqual(plan.deletions, [staleSha]);
|
||||||
|
assert.deepEqual(plan.keptTags, [headSha, releaseSha]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("planHashTagDeletions keeps named tags and excludes sha256 manifest versions from keptTags", () => {
|
||||||
|
const plan = planHashTagDeletions({
|
||||||
|
branchHeadShas: [],
|
||||||
|
releaseTagShas: [],
|
||||||
|
versions: ["0.0.1", "0", "latest", "some-manual-tag", `sha256:${"a".repeat(64)}`, staleSha],
|
||||||
|
});
|
||||||
|
assert.deepEqual(plan.deletions, [staleSha]);
|
||||||
|
assert.deepEqual(plan.keptTags, ["0.0.1", "0", "latest", "some-manual-tag"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("planHashTagDeletions with no versions plans nothing", () => {
|
||||||
|
const plan = planHashTagDeletions({ branchHeadShas: [headSha], releaseTagShas: [], versions: [] });
|
||||||
|
assert.deepEqual(plan.deletions, []);
|
||||||
|
assert.deepEqual(plan.keptTags, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selectOrphanedManifests picks only sha256 versions unreferenced by kept tags", () => {
|
||||||
|
const referenced = `sha256:${"b".repeat(64)}`;
|
||||||
|
const orphaned = `sha256:${"c".repeat(64)}`;
|
||||||
|
const orphans = selectOrphanedManifests({
|
||||||
|
referencedDigests: [referenced],
|
||||||
|
versions: ["0.0.1", "latest", headSha, referenced, orphaned],
|
||||||
|
});
|
||||||
|
assert.deepEqual(orphans, [orphaned]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selectOrphanedManifests with nothing referenced orphans every sha256 version", () => {
|
||||||
|
const manifest = `sha256:${"d".repeat(64)}`;
|
||||||
|
assert.deepEqual(selectOrphanedManifests({ referencedDigests: [], versions: [manifest, "latest"] }), [manifest]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const hashTag = /^[0-9a-f]{40}$/;
|
||||||
|
const manifestVersion = /^sha256:[0-9a-f]{64}$/;
|
||||||
|
|
||||||
|
export type HashTagPlan = {
|
||||||
|
deletions: string[];
|
||||||
|
keptTags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function planHashTagDeletions(input: {
|
||||||
|
branchHeadShas: string[];
|
||||||
|
releaseTagShas: string[];
|
||||||
|
versions: string[];
|
||||||
|
}): HashTagPlan {
|
||||||
|
const keep = new Set([...input.branchHeadShas, ...input.releaseTagShas]);
|
||||||
|
const deletions: string[] = [];
|
||||||
|
const keptTags: string[] = [];
|
||||||
|
for (const version of input.versions) {
|
||||||
|
if (manifestVersion.test(version)) continue;
|
||||||
|
if (hashTag.test(version) && !keep.has(version)) deletions.push(version);
|
||||||
|
else keptTags.push(version);
|
||||||
|
}
|
||||||
|
return { deletions, keptTags };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectOrphanedManifests(input: {
|
||||||
|
referencedDigests: string[];
|
||||||
|
versions: string[];
|
||||||
|
}): string[] {
|
||||||
|
const referenced = new Set(input.referencedDigests);
|
||||||
|
return input.versions.filter((v) => manifestVersion.test(v) && !referenced.has(v));
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user