Compare commits
90 Commits
1ff25e1f15
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 910ac18798 | |||
| 48c89baf8e | |||
| b863ea26d9 | |||
| 3c0878ed1a | |||
| 21877aeadd | |||
| 85bfadd040 | |||
| 346d232cf7 | |||
| f4969d5c1f | |||
| 76995008eb | |||
| dfd26ada34 | |||
| 5c84cc77fe | |||
| 943520566e | |||
| 8d1e8be6fc | |||
| dbd8af08ba | |||
| 2a4ed30e93 | |||
| 15c5a09667 | |||
| 73e6d5fb08 | |||
| f527850107 | |||
| 6f689e517b | |||
| ea259f09a8 | |||
| ec35aaaa9e | |||
| 14c09a76c0 | |||
| 2be8ebfa67 | |||
| 5b7ad118ab | |||
| 437a2e8580 | |||
| 57a1a2899f | |||
| 352de29013 | |||
| de4eb7d9ee | |||
| a8a9744744 | |||
| 10152d2c6f | |||
| 4e7d897516 | |||
| cd46c77332 | |||
| 16c9822c29 | |||
| 520d2c928d | |||
| be3553c1e2 | |||
| cef2d4f4f2 | |||
| b336dee6d7 | |||
| af157c95fe | |||
| 3f30cfafe6 | |||
| 25b24a1057 | |||
| 1905d2c310 | |||
| 16da74f03f | |||
| bb8e6bc8e8 | |||
| 0f20b386e2 | |||
| 6815caa47e | |||
| 9e5b60a333 | |||
| 21a00f895a | |||
| 2ab482e774 | |||
| f46e97383e | |||
| ce69269ae4 | |||
| 364b04795c | |||
| 19987ba1b0 | |||
| c12fd2742f | |||
| 48655c0ad2 | |||
| a8d4139dd8 | |||
| a190166421 | |||
| ac52abd330 | |||
| a2edfbee0c | |||
| 635006b5fc | |||
| 3e70768433 | |||
| 56ff987c97 | |||
| d8df129fca | |||
| 270d80c8d0 | |||
| 2a3ace885d | |||
| e9a7c0e726 | |||
| 7e3e8acb28 | |||
| eed1050c08 | |||
| 6f11a9928b | |||
| 070d745a4b | |||
| 9370fb36f2 | |||
| 3c5c0c911c | |||
| 44a777dc3f | |||
| 1dfc6aac3a | |||
| 0c0a3bc905 | |||
| 935674bc49 | |||
| e8dbb2a809 | |||
| 79f26491db | |||
| 77b7e6143f | |||
| 18b6a5a64a | |||
| ad3bb3198e | |||
| 74ebf2e7ec | |||
| 53e996394f | |||
| 3491e660e9 | |||
| 667d589401 | |||
| 5a968fd076 | |||
| b81c136fe5 | |||
| c9e25f2852 | |||
| 603e29e8f1 | |||
| 055bfd6506 | |||
| 80cc692634 |
@@ -19,4 +19,4 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||||
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
||||||
node:24.22.1-alpine3.24 node registry-cleanup/cleanup.ts
|
node:24.22.2-alpine3.24 node registry-cleanup/cleanup.ts
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
GIT_TAG: ${{ github.ref_name }}
|
GIT_TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.1-alpine3.24 \
|
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||||
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
|
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
|
||||||
- name: Promote the commit-hash image to semver + latest
|
- name: Promote the commit-hash image to semver + latest
|
||||||
env:
|
env:
|
||||||
@@ -98,9 +98,9 @@ jobs:
|
|||||||
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
|
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
|
||||||
VERSION=${VERSION#v}
|
VERSION=${VERSION#v}
|
||||||
# An empty dispatch input falls back to the branch name, so gate this like a tag.
|
# An empty dispatch input falls back to the branch name, so gate this like a tag.
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.1-alpine3.24 \
|
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||||
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
|
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||||
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
|
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
|
||||||
node:24.22.1-alpine3.24 \
|
node:24.22.2-alpine3.24 \
|
||||||
node release-tooling/dockerhub-overview.ts "$VERSION"
|
node release-tooling/dockerhub-overview.ts "$VERSION"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
-e RENOVATE_PLATFORM=gitea \
|
-e RENOVATE_PLATFORM=gitea \
|
||||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||||
-e RENOVATE_TOKEN \
|
-e RENOVATE_TOKEN \
|
||||||
renovate/renovate:44.181.5
|
renovate/renovate:44.186.6
|
||||||
|
|
||||||
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
||||||
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
|
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
|
||||||
@@ -58,11 +58,11 @@ jobs:
|
|||||||
if [ -z "$BUMPS" ]; then
|
if [ -z "$BUMPS" ]; then
|
||||||
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
|
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
|
||||||
fi
|
fi
|
||||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.1-alpine3.24 \
|
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||||
node release-tooling/next-version.ts "$LATEST" $BUMPS)
|
node release-tooling/next-version.ts "$LATEST" $BUMPS)
|
||||||
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
|
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
|
||||||
git show origin/main:src/plugin-host/plugin.ts \
|
git show origin/main:src/plugin-host/plugin.ts \
|
||||||
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.1-alpine3.24 \
|
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||||
node release-tooling/contract-version.ts "$NEXT" -
|
node release-tooling/contract-version.ts "$NEXT" -
|
||||||
echo "Releasing $LATEST -> $NEXT"
|
echo "Releasing $LATEST -> $NEXT"
|
||||||
git tag "$NEXT" origin/main
|
git tag "$NEXT" origin/main
|
||||||
|
|||||||
@@ -221,6 +221,9 @@ Revisit only if the stated reason stops holding.
|
|||||||
adds you to any group, including one holding every permission; `users:write` mints a recovery code
|
adds you to any group, including one holding every permission; `users:write` mints a recovery code
|
||||||
for any account. The containment the split buys is real on the **read** half only (`users:read` is
|
for any account. The containment the split buys is real on the **read** half only (`users:read` is
|
||||||
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
|
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
|
||||||
|
- **The CSRF token is deliberately unbound to the session** (`src/auth/csrf.ts`; README → Security
|
||||||
|
model): the usual cost of a signed double-submit token, covered by `SameSite=Lax` and HSTS.
|
||||||
|
**Valid while the session cookie stays `SameSite=Lax` or stricter.**
|
||||||
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed
|
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed
|
||||||
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
|
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
|
||||||
Kratos' wire shape — don't rename it.
|
Kratos' wire shape — don't rename it.
|
||||||
@@ -355,6 +358,7 @@ Docker Compose. **Never run `node`, `npm`, or `tsc` on the host.**
|
|||||||
```bash
|
```bash
|
||||||
docker compose up # dev server, live reload
|
docker compose up # dev server, live reload
|
||||||
docker compose run --rm --no-deps web npm run typecheck # strict type check (--no-deps: skip Ory)
|
docker compose run --rm --no-deps web npm run typecheck # strict type check (--no-deps: skip Ory)
|
||||||
|
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --rm --no-deps --build e2e npm run typecheck # the E2E specs
|
||||||
docker compose run --rm --no-deps web npm test # tests
|
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
|
||||||
```
|
```
|
||||||
@@ -367,7 +371,7 @@ docker compose -f compose.yml up --build -d # production
|
|||||||
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
|
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
|
||||||
copy-pasteable; deeper detail lives in its own section, linked.
|
copy-pasteable; deeper detail lives in its own section, linked.
|
||||||
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
|
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
|
||||||
by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups
|
by **what an adopter reaches for first**, not by architectural layering: Goals → Audience → Overview → Glossary → Users, groups
|
||||||
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
||||||
Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
|
Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
|
||||||
map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
|
map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
|
||||||
@@ -388,7 +392,7 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
|
|||||||
If a file genuinely must be plain JavaScript, use `.js`; `"type": "module"` is set in both
|
If a file genuinely must be plain JavaScript, use `.js`; `"type": "module"` is set in both
|
||||||
`package.json`s, so `.js` is ESM.
|
`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 typechecks and tests above; all 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.
|
- English everywhere.
|
||||||
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
|
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
|
||||||
@@ -430,11 +434,9 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
|
|||||||
Don't funnel many routes into one dispatcher that re-parses `ctx.url.pathname`: it duplicates the
|
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
|
URL shape, ignores the router's params, and has to re-handle HEAD. Factor shared per-request setup
|
||||||
into a small `withX` wrapper — see `examples/plugins/admin/`.
|
into a small `withX` wrapper — see `examples/plugins/admin/`.
|
||||||
- **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking
|
- **A function stays within 50 lines and complexity 20** (ESLint's defaults, measured by
|
||||||
canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin
|
`src/function-limits.test.ts`). Split one that outgrows them; never add it to the test's
|
||||||
routing, 405/404 and error mapping. The pure parts are already extracted and separately tested;
|
`grandfathered` list or raise a ceiling there.
|
||||||
what remains is orchestration. Planned split along those seams; don't grow it further without
|
|
||||||
taking one out.
|
|
||||||
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer agents.
|
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer agents.
|
||||||
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
|
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
|
||||||
`src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own
|
`src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own
|
||||||
@@ -460,3 +462,13 @@ Default to **no comment**. Delete one that restates the adjacent code, repeats a
|
|||||||
elsewhere, justifies self-evident code, or records history. Write one only for what a competent
|
elsewhere, justifies self-evident code, or records history. Write one only for what a competent
|
||||||
reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external
|
reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external
|
||||||
constraint. See [Prose discipline](#prose-discipline).
|
constraint. See [Prose discipline](#prose-discipline).
|
||||||
|
|
||||||
|
## Scoring run
|
||||||
|
|
||||||
|
The comprehension panel's seats fill their prompts from these fields.
|
||||||
|
|
||||||
|
- **Language:** strict TypeScript on Node 24, with EJS views and plain CSS
|
||||||
|
- **Kind:** a server-rendered web GUI host with a plugin system
|
||||||
|
- **Domain:** web authentication, authorization and server-rendered admin GUIs
|
||||||
|
- **Domain docs:** the Ory Kratos, Keto and Hydra documentation
|
||||||
|
- **3am question:** a visitor on an old TV browser reports that the sign-in button is blank. Which file and rule?
|
||||||
|
|||||||
@@ -4,6 +4,94 @@ The release version **is** the plugin contract version (`HOST_API_VERSION`), so
|
|||||||
contract break: a plugin's `apiVersion` must match the host's `major.minor` or discovery refuses it
|
contract break: a plugin's `apiVersion` must match the host's `major.minor` or discovery refuses it
|
||||||
at boot. Entries start at 0.3.0.
|
at boot. Entries start at 0.3.0.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### A language chosen on a sign-in link no longer drops an absolute return target
|
||||||
|
|
||||||
|
`/login?return_to=<absolute URL>&locale=sv-SE` passed the visitor to the dashboard after sign-in and
|
||||||
|
dropped the OAuth2 login challenge in `return_to`. The target now reaches Kratos, and the chosen
|
||||||
|
language is lost across that round-trip instead.
|
||||||
|
|
||||||
|
### The admin example refuses a permission save made from an out-of-date page
|
||||||
|
|
||||||
|
Two administrators editing one user's or group's permissions no longer overwrite each other. The
|
||||||
|
form carries a token for the grants it showed. A save that would change something, made after those
|
||||||
|
grants changed, is refused with "These permissions changed while you had the page open", and the page
|
||||||
|
shows the current grants. A POST with no token is refused the same way, so a script posting to
|
||||||
|
`/admin/users/:id/permissions` or `/admin/groups/:name/permissions` must first read the token from the
|
||||||
|
page's `baseline` field.
|
||||||
|
|
||||||
|
### The admin example confirms a permission save and reports a partial one
|
||||||
|
|
||||||
|
A save now lands on the page with "Permissions saved." If the permission service fails midway, the
|
||||||
|
page says the save stopped part-way instead of showing the generic error page. On a user, the
|
||||||
|
changes that landed still reach the user's live sessions at once when the revocation denylist is on.
|
||||||
|
|
||||||
|
### A read-only holder sees View, not Edit, on the admin example's users list
|
||||||
|
|
||||||
|
A holder of `users:read` without `users:write` gets a "View" row action with an eye icon. The admin
|
||||||
|
example's catalogs gain `admin.grants.partial`, `admin.grants.saved`, `admin.grants.stale` and
|
||||||
|
`admin.users.view`, so a translated catalog mounted under `locales/plugins/admin/` must add them.
|
||||||
|
|
||||||
|
### Every page carries a smaller icon sprite
|
||||||
|
|
||||||
|
`i-chart`, `i-copy` and `i-download` are gone from the inlined icon sprite, so a plugin that
|
||||||
|
referenced one now renders a blank icon. Nothing in the host or the example plugins used them. Pick
|
||||||
|
another id from `views/partials/icons.ejs`, or inline your own SVG.
|
||||||
|
|
||||||
|
### The README defines its terms and states its intended scale
|
||||||
|
|
||||||
|
A new Glossary defines host, plugin, manifest, chrome, nav fragment, permission name, system plugin
|
||||||
|
and denylist. Plugin dependencies also states the scale Plainpages is sized for: a handful of plugins
|
||||||
|
per deployment.
|
||||||
|
|
||||||
|
### The mobile nav stays reachable on engines without `:has()`
|
||||||
|
|
||||||
|
At 860px and narrower, an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23,
|
||||||
|
and Firefox below 121) hid the sidebar off-canvas with no way to open it, so the pages the nav links
|
||||||
|
to could not be reached. Now the sidebar moves off-canvas, behind the hamburger, only where `:has()`
|
||||||
|
works. Elsewhere it stays in the flow above the content and the hamburger is hidden.
|
||||||
|
|
||||||
|
### The whole nav tree shows on engines without `:has()`
|
||||||
|
|
||||||
|
On an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23, and Firefox below 121)
|
||||||
|
every nested nav entry was hidden with nothing to open it. Now the tree folds only where `:has()`
|
||||||
|
works and stays fully open elsewhere, with its toggles hidden. The theme switch, which also needs
|
||||||
|
`:has()`, is hidden there.
|
||||||
|
|
||||||
|
### Button and badge text meets 4.5:1 contrast
|
||||||
|
|
||||||
|
The primary button and the current page button take their text colour from the new `--on-accent`
|
||||||
|
token: white in light mode, near-black in dark mode, where white on the accent was 2.7:1. The
|
||||||
|
light-mode success, warning and info badge text is darker. A plugin that overrides `--accent` or
|
||||||
|
`--l-accent` should set `--l-on-accent` and `--d-on-accent` to match.
|
||||||
|
|
||||||
|
### Unused auth stylesheet rules are gone
|
||||||
|
|
||||||
|
`public/css/auth.css` no longer holds the `.auth-view` screen switch, `.tpl-*` preview controls,
|
||||||
|
`.forgot-alert`, `.register-alert`, `.remember` and `.auth-foot`; no core view used them. A plugin
|
||||||
|
view that did must carry its own rules.
|
||||||
|
|
||||||
|
### Colours render on engines without `oklch()`
|
||||||
|
|
||||||
|
The theme palette in `public/css/styles.css` is hex, not `oklch()`. On an engine older than
|
||||||
|
Chromium 111 (Tizen 2024, webOS TV 24) `var(--accent)` was invalid, so the primary button was white
|
||||||
|
text on no background. A plugin that sets a colour custom property, such as `--accent` or `--l-accent`,
|
||||||
|
should use hex, `rgb()` or `hsl()` too.
|
||||||
|
|
||||||
|
### One horizontal inset across the content pane
|
||||||
|
|
||||||
|
The topbar, filter bar, pager, form page and empty-table message inset their content by `--pad-x`
|
||||||
|
(12px), as table cells do, instead of 16px. A plugin that overrides `--pad-x` now moves all of them
|
||||||
|
together.
|
||||||
|
|
||||||
|
### Pages are not kept by a shared cache
|
||||||
|
|
||||||
|
Every page and JSON response now carries `Cache-Control: private, no-store`, so a cache or reverse
|
||||||
|
proxy in front of Plainpages can't store a signed-in page and serve it to the next visitor. A route
|
||||||
|
whose page is the same for everyone can still set its own `cache-control` through
|
||||||
|
`RouteResult.headers`; assets are unchanged.
|
||||||
|
|
||||||
## 0.4.0
|
## 0.4.0
|
||||||
|
|
||||||
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
|
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
|
||||||
|
|||||||
+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.22.1-alpine3.24
|
FROM node:24.22.2-alpine3.24
|
||||||
|
|
||||||
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
|
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
|
||||||
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
|
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
|
- [Goals](#goals)
|
||||||
|
- [Audience](#audience)
|
||||||
- [Overview](#overview)
|
- [Overview](#overview)
|
||||||
|
- [Glossary](#glossary)
|
||||||
- [Users, groups & permissions](#users-groups--permissions)
|
- [Users, groups & permissions](#users-groups--permissions)
|
||||||
- [naming a permission](#naming-a-permission)
|
- [naming a permission](#naming-a-permission)
|
||||||
- [a worked example](#a-worked-example)
|
- [a worked example](#a-worked-example)
|
||||||
@@ -121,6 +124,64 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
- [Project layout](#project-layout)
|
- [Project layout](#project-layout)
|
||||||
- [Extending the core](#extending-the-core)
|
- [Extending the core](#extending-the-core)
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Achieve:** Less boilerplate for a server-rendered web GUI: a new project starts at
|
||||||
|
its own domain, not at sign-in, a menu or a table.
|
||||||
|
- Signal: a new plugin reaches a working, gated page with a menu, tables, filters and
|
||||||
|
forms built only from the core building blocks.
|
||||||
|
- Counter-signal: the core grows features only one project needs.
|
||||||
|
- Why: boilerplate removed by absorbing a project's domain only moves it into the core.
|
||||||
|
2. **Achieve:** Every building block works for the person using it: a non-technical user
|
||||||
|
gets what they meant on the first try, and can't hurt themselves by accident.
|
||||||
|
- Signal: bugs users hit in a shipped flow get fixed and stay fixed, such as a pasted
|
||||||
|
code being refused, a double click acting twice, or an admin locking themselves out.
|
||||||
|
- Counter-signal: a fix adds client-side JavaScript, or a setting every plugin has to
|
||||||
|
know about.
|
||||||
|
- Why: a fix that breaks zero-JS or simplicity moves the irritation from users to every
|
||||||
|
plugin author.
|
||||||
|
3. **Maintain:** Plainpages solves only the parts every project shares: auth, the GUI
|
||||||
|
building blocks (menu, tables, filters, forms) and the plugin structure. Everything
|
||||||
|
else is a plugin.
|
||||||
|
4. **Maintain:** Every page is a server-rendered document that works with zero JavaScript,
|
||||||
|
on old and embedded browsers too. A newer browser only makes it nicer.
|
||||||
|
5. **Maintain:** State lives in the URL. Only state the URL can't carry lives in the
|
||||||
|
database, never in local storage or JavaScript. A cookie carries only what must be bound
|
||||||
|
to the browser: the session and its CSRF token.
|
||||||
|
6. **Maintain:** The frontend is semantic: the HTML says what each thing is, and the CSS
|
||||||
|
styles meaning, not looks. That keeps every page accessible.
|
||||||
|
7. **Achieve:** Hackable: a plugin can change anything, and Plainpages stays out of its way.
|
||||||
|
- Signal: a plugin replaces any page, partial, menu entry or string without forking the
|
||||||
|
host, and fewer plugins need a host change to get unblocked.
|
||||||
|
- Counter-signal: plugins break on upgrade with no version signal, or a plugin mistake
|
||||||
|
shows up at runtime instead of at boot.
|
||||||
|
- Why: freedom that breaks silently costs more than the boilerplate it saved.
|
||||||
|
8. **Achieve:** Speed: pages render fast and stay small.
|
||||||
|
- Signal: server time per page and bytes sent per page fall.
|
||||||
|
- Counter-signal: a building block loses a feature or accessibility to save bytes.
|
||||||
|
- Why: a fast page that does less only moves the work back into each plugin.
|
||||||
|
9. **Achieve:** LLM-friendly: an LLM builds a correct plugin from the README and the
|
||||||
|
examples alone.
|
||||||
|
- Signal: an agent given only those writes a plugin that passes discovery and its E2E
|
||||||
|
test on the first try.
|
||||||
|
- Counter-signal: the README grows to restate what the code already shows.
|
||||||
|
- Why: docs that pass the test by growing cost every later reader, human or LLM.
|
||||||
|
10. **Maintain:** Every behaviour is an explicit configuration choice, the same in every
|
||||||
|
environment.
|
||||||
|
|
||||||
|
## Audience
|
||||||
|
|
||||||
|
- **Developers starting a web GUI project, and the LLMs that build plugins with them.**
|
||||||
|
They may rely on `@plainpages/plugin-api`, the core partials and the packaging promises,
|
||||||
|
all versioned by `apiVersion`. A change that breaks a plugin takes a major bump.
|
||||||
|
- **Operators** who self-host Plainpages. They may rely on semver-tagged images, on
|
||||||
|
configuration through environment variables and the drop-in mounts (`plugins/`,
|
||||||
|
`config/`, `locales/`), and on upgrades within a minor version that need no change on
|
||||||
|
their side.
|
||||||
|
- **Visitors** to a site built on Plainpages, on any browser, including the older engines in
|
||||||
|
TVs, kiosks, thin clients and embedded panels. They may rely on pages that load fast and
|
||||||
|
work without JavaScript, with a keyboard and a screen reader.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu,
|
Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu,
|
||||||
@@ -136,11 +197,9 @@ public front with an authenticated area behind it.
|
|||||||
- **You add:** everything else domain-specific, as **plugins** — built from the same building
|
- **You add:** everything else domain-specific, as **plugins** — built from the same building
|
||||||
blocks the admin plugin uses.
|
blocks the admin plugin uses.
|
||||||
|
|
||||||
**Priorities:** simplicity, few dependencies, strict TypeScript, no build step, Docker-only,
|
Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
|
||||||
environment-agnostic (no `NODE_ENV` — every behaviour is an explicit config toggle). Identity,
|
services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
|
||||||
sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar services. The shape
|
the hot path is the same I/O-free JWT check, and the host stores nothing of its own.
|
||||||
doesn't change as it grows: every plugin is the same self-contained folder, the hot path is the
|
|
||||||
same I/O-free JWT check, and there is no app database.
|
|
||||||
|
|
||||||
**Plugins are the extension model.** The plugin API is the product's main surface: powerful,
|
**Plugins are the extension model.** The plugin API is the product's main surface: powerful,
|
||||||
predictable, and overloadable — a plugin can take over as much of a page as it wants. The host
|
predictable, and overloadable — a plugin can take over as much of a page as it wants. The host
|
||||||
@@ -152,6 +211,24 @@ feature removes the need for JS (theme switching, popovers, disclosure) we use i
|
|||||||
avoid is shipping a client-side runtime, not using the platform. Markup is semantic and accessible
|
avoid is shipping a client-side runtime, not using the platform. Markup is semantic and accessible
|
||||||
(see [AGENTS.md](AGENTS.md)).
|
(see [AGENTS.md](AGENTS.md)).
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Host** — the `web` container: the Node process that discovers plugins, serves every page and
|
||||||
|
owns no state of its own. Everything outside it (a plugin's upstream, Ory) is reached over the network.
|
||||||
|
- **Plugin** — a folder under `plugins/` that adds domain features.
|
||||||
|
- **Manifest** — the object a plugin's `plugin.ts` exports as its default: its routes, nav,
|
||||||
|
permissions and hooks. The host checks it at boot and refuses a bad one ([The manifest](#the-manifest)).
|
||||||
|
- **Chrome** — everything on a page that isn't the page's own content: brand, menu, user, theme and
|
||||||
|
CSRF token. A handler gets it as `ctx.chrome` ([RequestContext](#requestcontext)).
|
||||||
|
- **Nav fragment** — the `nav` entries one plugin contributes. The host merges every plugin's
|
||||||
|
fragment into the one global menu and filters it per user ([The menu system](#the-menu-system)).
|
||||||
|
- **Permission name** — a `<resource>:<action>` string such as `scheduling:write`, granted in Keto and
|
||||||
|
carried in the session JWT's `permissions` claim ([Naming a permission](#naming-a-permission)).
|
||||||
|
- **System plugin** — a plugin that administers Plainpages' own identity stack, through the
|
||||||
|
privileged `ctx.system` ([System capabilities](#system-capabilities-the-ctxsystem-surface)).
|
||||||
|
- **Denylist** — the optional in-memory list, per instance, of subjects revoked just now, so a
|
||||||
|
deactivation or a user's own permission change lands before the JWT expires ([Instant revoke](#instant-revoke-the-optional-denylist)).
|
||||||
|
|
||||||
## Users, groups & permissions
|
## Users, groups & permissions
|
||||||
|
|
||||||
Authorization here is two hops: a **user** — directly, or through a **group** — is granted a
|
Authorization here is two hops: a **user** — directly, or through a **group** — is granted a
|
||||||
@@ -735,6 +812,9 @@ included, since the plugin folder *is* the repo. A baked image needs no extra st
|
|||||||
above every plugin, and a plugin resolves it from there — nothing to declare, just import it. A
|
above every plugin, and a plugin resolves it from there — nothing to declare, just import it. A
|
||||||
copy inside your plugin's own `node_modules` would shadow it with a *second* instance of the host's
|
copy inside your plugin's own `node_modules` would shadow it with a *second* instance of the host's
|
||||||
contract, turning a sign-in redirect into a 500, so discovery refuses one there at boot.
|
contract, turning a sign-in redirect into a 500, so discovery refuses one there at boot.
|
||||||
|
- **Plainpages is sized for a handful of plugins per deployment, about ten.** Each plugin carries
|
||||||
|
its own dependency tree, so every one adds to disk use and to the memory of the `web` process;
|
||||||
|
hundreds of plugins is not a goal.
|
||||||
- **The host never upgrades or dedupes your dependencies.** Two plugins depending on the same package
|
- **The host never upgrades or dedupes your dependencies.** Two plugins depending on the same package
|
||||||
each get their own copy at their own version, so neither can break the other by upgrading — and
|
each get their own copy at their own version, so neither can break the other by upgrading — and
|
||||||
keeping yours current, and audited, is yours to own. Renovate here watches every manifest in this
|
keeping yours current, and audited, is yours to own. Renovate here watches every manifest in this
|
||||||
@@ -961,6 +1041,15 @@ underneath it — cramped, but nothing is unreachable. List state
|
|||||||
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the
|
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the
|
||||||
URL is the only state the UI keeps.
|
URL is the only state the UI keeps.
|
||||||
|
|
||||||
|
**Two browser floors** serve goal 4. Every page's content and actions stay reachable on
|
||||||
|
Chromium 87, Safari 15 and Firefox 115, the engines in years-old TVs, kiosks and Qt panels. Only
|
||||||
|
features [Baseline Widely available](https://web-platform-dx.github.io/baseline/) (in every
|
||||||
|
major engine for 30 months) may shape how a page looks; anything newer, like popovers or anchor
|
||||||
|
positioning, only enhances. A feature the floor engines lack, such as `:has()`, may
|
||||||
|
decorate a page but never decide whether something shows or can be read. A colour is hex, `rgb()` or
|
||||||
|
`hsl()`, never `oklch()`: a custom property holding it passes unchecked, and the `var()` reading it
|
||||||
|
then renders transparent.
|
||||||
|
|
||||||
**A field whose options depend on another field is asked for in steps, one GET form each**, so every
|
**A field whose options depend on another field is asked for in steps, one GET form each**, so every
|
||||||
choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made.
|
choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made.
|
||||||
Once the query names a value, that field renders read-only (`field` with `readonly`, and a `link` back
|
Once the query names a value, that field renders read-only (`field` with `readonly`, and a `link` back
|
||||||
@@ -1319,6 +1408,11 @@ authorization graph, so treat it the same. `compose.yml` publishes none of the s
|
|||||||
so a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our two cookies
|
so a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our two cookies
|
||||||
obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
|
obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
|
||||||
|
|
||||||
|
**The CSRF token isn't bound to the session.** It is a signed double-submit token, so an attacker who
|
||||||
|
can write cookies on your origin can plant one they know. `SameSite=Lax` keeps a cross-site page
|
||||||
|
out, and HSTS (`SECURE_COOKIES=true`) a network attacker. A hostile page on a sibling subdomain can
|
||||||
|
still set cookies, so host nothing untrusted on one.
|
||||||
|
|
||||||
**Offboarding is not instant by default** — a revoked permission or deactivated identity lands
|
**Offboarding is not instant by default** — a revoked permission or deactivated identity lands
|
||||||
within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on.
|
within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on.
|
||||||
|
|
||||||
@@ -1393,12 +1487,13 @@ session lives in Kratos and the data lives outside the process.
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Type check and unit tests run off the Ory stack — `--no-deps` keeps `web` from dragging up its
|
Type checks and unit tests run off the Ory stack — `--no-deps` keeps each service from dragging up
|
||||||
`depends_on` services:
|
its `depends_on` services:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose run --rm --no-deps web npm run typecheck # strict tsc --noEmit
|
docker compose run --rm --no-deps web npm run typecheck # strict tsc --noEmit
|
||||||
docker compose run --rm --no-deps web npm test # node --test (units)
|
docker compose run --rm --no-deps web npm test # node --test (units)
|
||||||
|
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --rm --no-deps --build e2e npm run typecheck # the E2E specs
|
||||||
```
|
```
|
||||||
|
|
||||||
### End-to-end (Playwright)
|
### End-to-end (Playwright)
|
||||||
@@ -1437,7 +1532,7 @@ Ory-backed ones share one backend and stay on Chromium.
|
|||||||
|
|
||||||
### The full gate (one command)
|
### The full gate (one command)
|
||||||
|
|
||||||
`ci.sh` is the whole gate in one reproducible command — typecheck → unit tests →
|
`ci.sh` is the whole gate in one reproducible command — typecheck (the specs in `e2e-tests/` too) → unit tests →
|
||||||
each E2E suite against its own fresh stack, with a guaranteed `down -v` after each (even on
|
each E2E suite against its own fresh stack, with a guaranteed `down -v` after each (even on
|
||||||
failure) and a non-zero exit on the first failure. Run it locally before a release, or wire
|
failure) and a non-zero exit on the first failure. Run it locally before a release, or wire
|
||||||
it into your CI service:
|
it into your CI service:
|
||||||
@@ -1580,6 +1675,11 @@ Every response carries security headers (`src/http/security-headers.ts`): a stri
|
|||||||
so a branding logo must live under `/public/` or be a `data:` URI; a plugin route can override any
|
so a branding logo must live under `/public/` or be a `data:` URI; a plugin route can override any
|
||||||
header per-response via `RouteResult.headers`.
|
header per-response via `RouteResult.headers`.
|
||||||
|
|
||||||
|
Every page and JSON response is `Cache-Control: private, no-store`, so a cache in front of `web`
|
||||||
|
never keeps one user's page for the next. A route may override it only for a page that is the same for
|
||||||
|
every visitor: no user, no CSRF token, no `Set-Cookie`, since the host adds a fresh CSRF cookie to
|
||||||
|
every plugin route.
|
||||||
|
|
||||||
A deep link reached while signed out — or after the session JWT lapses mid-task — bounces to the
|
A deep link reached while signed out — or after the session JWT lapses mid-task — bounces to the
|
||||||
themed sign-in and returns to the **page that was requested** (`return_to`, validated host-relative
|
themed sign-in and returns to the **page that was requested** (`return_to`, validated host-relative
|
||||||
by `localPath` in `src/http/safe-url.ts`, so a crafted value can't turn login completion into an open
|
by `localPath` in `src/http/safe-url.ts`, so a crafted value can't turn login completion into an open
|
||||||
@@ -1713,7 +1813,7 @@ unnecessary here — the signature itself is already invalid.
|
|||||||
```
|
```
|
||||||
src/ The app — strict tsc, no build step. *.test.ts sit beside their module.
|
src/ The app — strict tsc, no build step. *.test.ts sit beside their module.
|
||||||
server.ts Entry point; config.ts env loader; logger.ts structured log + trace span + tracedFetch
|
server.ts Entry point; config.ts env loader; logger.ts structured log + trace span + tracedFetch
|
||||||
*.test.ts Topology guards with no source counterpart (compose/kratos/keto/hydra/postgres/ci-gate)
|
*.test.ts Guards with no source counterpart (compose/kratos/keto/hydra/postgres/ci-gate/function-limits)
|
||||||
http/ Request pipeline: app.ts (createApp), builtin-routes, context, body, cookie,
|
http/ Request pipeline: app.ts (createApp), builtin-routes, context, body, cookie,
|
||||||
static, safe-url (safeUrl + localPath), security-headers
|
static, safe-url (safeUrl + localPath), security-headers
|
||||||
auth/ Identity + the session-JWT hot path: jwt · jwt-middleware · jwks · gen-jwks (CLI) ·
|
auth/ Identity + the session-JWT hot path: jwt · jwt-middleware · jwks · gen-jwks (CLI) ·
|
||||||
@@ -1725,8 +1825,8 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
|
|||||||
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
|
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
|
||||||
(ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) ·
|
(ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) ·
|
||||||
storage-provisioning (the DDL; bootstrap-only, holds the driver)
|
storage-provisioning (the DDL; bootstrap-only, holds the driver)
|
||||||
ui/ chrome (the one global menu) · shell-context · dashboard · nav (composeNav) ·
|
ui/ chrome (`ctx.chrome`: menu, brand, user) · nav (composeNav) · menu-config
|
||||||
menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
|
(`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
|
||||||
|
|
||||||
plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules
|
plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules
|
||||||
views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503,
|
views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503,
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ docker compose build web
|
|||||||
step "Typecheck"
|
step "Typecheck"
|
||||||
docker compose run --rm --no-deps web npm run typecheck
|
docker compose run --rm --no-deps web npm run typecheck
|
||||||
|
|
||||||
|
# The specs import @playwright/test, which only the runner image installs, so they typecheck there
|
||||||
|
# rather than in the web image. --no-deps skips web; the visual file just names the e2e service.
|
||||||
|
step "Typecheck e2e-tests"
|
||||||
|
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --rm --no-deps --build e2e npm run typecheck
|
||||||
|
|
||||||
step "Unit tests"
|
step "Unit tests"
|
||||||
units=$(docker compose run --rm --no-deps web npm test 2>&1) || { echo "$units"; exit 1; }
|
units=$(docker compose run --rm --no-deps web npm test 2>&1) || { echo "$units"; exit 1; }
|
||||||
echo "$units" | grep -E '^. (tests|pass|fail) ' || true
|
echo "$units" | grep -E '^. (tests|pass|fail) ' || true
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
||||||
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
|
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.22.1-alpine3.24
|
image: node:24.22.2-alpine3.24
|
||||||
command: node /srv/server.ts
|
command: node /srv/server.ts
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
@@ -58,7 +58,7 @@ 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.32.0
|
image: axllent/mailpit:v1.33.0
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Comprehension history
|
||||||
|
|
||||||
|
## 2026-10-31T00:16:02Z, PR #181 at 56ff987, against eed1050
|
||||||
|
|
||||||
|
Ruling: better
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | same | better | same | better | better |
|
||||||
|
| Inherited architect | same | better | same | better | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
|
||||||
|
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a colour edit that breaks the browser floor fails a test instead of an old engine
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
|
||||||
|
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a red gate explains the symptom in the file it lands on
|
||||||
|
- Self-sufficiency: `README.md:1027` "Two browser floors": the colour rule now sits beside the floor rule
|
||||||
|
|
||||||
|
## 2026-10-31T00:43:19Z, PR #182 at 19987ba, against 3e70768
|
||||||
|
|
||||||
|
Ruling: better
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | better | better | better | better | better |
|
||||||
|
| Inherited architect | better | better | better | better | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Navigation, Shape, Self-sufficiency, Overall: `public/css/auth.css:1` `auth.css`: about 120 lines of rules for markup no view renders are gone, so a cold reader no longer rules them out first
|
||||||
|
- Locality, Self-sufficiency, Overall: `public/css/styles.css:31` `--on-accent`: the text colour on the accent is one token where four hard-coded `#fff` stood
|
||||||
|
- Self-sufficiency, Navigation, Overall: `public/css/styles.css:287` `@supports not selector(:has(a))`: the nav comment, the partial's header and the rule all say the tree folds only where `:has()` works
|
||||||
|
- Locality: `public/css/styles.css:241` `@supports selector(:has(a))`: the nav-fold behaviour now sits in two blocks about 50 lines apart, the second order-dependent; the comment names it
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Navigation, Shape, Overall: `public/css/auth.css:1` `auth.css`: the false `:target` banner and the phantom state machine are gone, so every selector left names something a view emits
|
||||||
|
- Self-sufficiency, Overall: `views/partials/nav-tree.ejs:4` `nav-tree.ejs`: the header and `public/css/styles.css:194` now say the branch folds only where `:has()` works; the old text was untrue on old engines
|
||||||
|
- Locality, Overall: `public/css/styles.css:31` `--l-on-accent`: one token feeds `.btn-primary`, `.page-btn[aria-current]` and `.brand-mark`
|
||||||
|
- Locality: `public/css/styles.css:287` `@supports not selector(:has(a))`: its `.nav-disc` rule sits about 85 lines from the nav rules it overrides, and `stylesheet-floor.test.ts` guards the order for `.theme-switch` only
|
||||||
|
- Self-sufficiency: `src/ui/stylesheet-contrast.test.ts:29` `stylesheet-contrast.test.ts`: the darker badge colours now have a stated 4.5:1 reason someone can find
|
||||||
|
|
||||||
|
## 2026-10-31T01:09:38Z, PR #183 at 0f20b38, against 364b047
|
||||||
|
|
||||||
|
Ruling: better
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | same | same | same | better | better |
|
||||||
|
| Inherited architect | same | better | same | better | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Overall: `public/css/styles.css:1` `App Shell Template header`: the false `light-dark()` bullet is gone, so no reader has to work out which of two comments is lying
|
||||||
|
- Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment say what an engine without `:has()` gets, without opening the CHANGELOG
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Overall: `public/css/styles.css:5` `Architecture` header: the false `light-dark()` bullet is gone, so the header no longer contradicts the TOKENS comment
|
||||||
|
- Locality, Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment put the dependency of the open state on `:has()` where an editor would change it
|
||||||
|
|
||||||
|
## 2026-10-31T01:19:49Z, PR #184 at af157c9, against bb8e6bc
|
||||||
|
|
||||||
|
Ruling: better
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | better | same | same | better | better |
|
||||||
|
| Inherited architect | better | better | same | better | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Navigation, Overall: `public/css/styles.css:12` `TOKENS` header comment: says all three theme rules need `:has()`, names the engines without it and points to the `@supports not selector(:has(a))` block, where the old text called the swaps "rock-solid"
|
||||||
|
- Navigation, Self-sufficiency, Overall: `README.md:214` `## Glossary`: defines host, plugin, manifest, chrome, nav fragment, permission name, system plugin and denylist and links each to its home section
|
||||||
|
- Self-sufficiency: `README.md:815` `Plugin dependencies`: the scale bullet states the plugin count the per-plugin dependency design assumes
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Locality, Overall: `public/css/styles.css:12` `1. TOKENS` comment: an editor of the theme mapping learns of the distant `:has()` fallback from the comment, where the old text said the swap could not fail
|
||||||
|
- Navigation, Self-sufficiency, Overall: `README.md:214` `## Glossary`: a cold reader reaches "chrome" or "denylist" and their home sections in one hop
|
||||||
|
- Self-sufficiency: `README.md:815` `### Plugin dependencies`: the scale bullet gives the plugin count the dependency design assumes
|
||||||
|
|
||||||
|
## 2026-10-31T07:26:19Z, PR #187 at 10152d2, against b336dee
|
||||||
|
|
||||||
|
Ruling: better
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | same | same | better | better | better |
|
||||||
|
| Inherited architect | same | same | better | better | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Overall: `e2e-tests/mock-oidc.ts:18` `idToken`: the helpers' signatures now say what `res`, `nonce` and the `codes` map hold
|
||||||
|
- Self-sufficiency: `e2e-tests/proxy.ts:14` `toKratos`: `(path: string): boolean` shows the routing predicate's contract
|
||||||
|
- Self-sufficiency: `e2e-tests/tsconfig.json:2` `extends`: the specs are held to the host's strict flags, and the Dockerfile comment explains the path link
|
||||||
|
- Shape, Overall: `src/ui/icons.ts:4` `ICON_NAMES`: the comment "the icons the UI actually references" is true again
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Overall: `e2e-tests/mock-oidc.ts:18` `b64`: typed helpers replace a trace through the `createServer` callback
|
||||||
|
- Self-sufficiency: `e2e-tests/proxy.ts:14` `toKratos`: the predicate's contract is readable on its own line
|
||||||
|
- Self-sufficiency: `e2e-tests/Dockerfile:11` `COPY tsconfig.json`: the relative `extends` path works in the checkout and in the image, and a comment names the link
|
||||||
|
- Shape, Overall: `src/ui/icons.ts:4` `ICON_NAMES`: the registry matches its description, with no dead ids
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Reading cost
|
||||||
|
|
||||||
|
## Reading cost, chunk 57e864bce02a, finished 2026-10-30T23:59:00Z
|
||||||
|
|
||||||
|
100624 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `src/http/app.test.ts` 9
|
||||||
|
- `README.md` 7
|
||||||
|
- `src/http/app.ts` 4
|
||||||
|
- `todo.md` 3
|
||||||
|
- `e2e-tests/playwright.config.ts` 2
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `CHANGELOG.md` 1
|
||||||
|
- `ci.sh` 1
|
||||||
|
- `examples/plugins/scheduling/views/shifts.ejs` 1
|
||||||
|
- `src/auth/csrf.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk df5e30a4db5f, finished 2026-10-31T00:22:00Z
|
||||||
|
|
||||||
|
80251 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 5
|
||||||
|
- `README.md` 3
|
||||||
|
- `CHANGELOG.md` 2
|
||||||
|
- `public/css/styles.css` 2
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `src/ui/stylesheet-floor.test.ts` 1
|
||||||
|
- `src/ui/theme-switch.test.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 3bf0b1a97d77, finished 2026-10-31T00:48:14Z
|
||||||
|
|
||||||
|
167985 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `public/css/styles.css` 17
|
||||||
|
- `todo.md` 7
|
||||||
|
- `README.md` 6
|
||||||
|
- `public/css/auth.css` 5
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `CHANGELOG.md` 1
|
||||||
|
- `examples/plugins/scheduling/public/scheduling.css` 1
|
||||||
|
- `examples/plugins/scheduling/views/overview.ejs` 1
|
||||||
|
- `examples/plugins/scheduling/views/shifts.ejs` 1
|
||||||
|
- `src/http/app.test.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 91d11dbe63f9, finished 2026-10-31T01:13:44Z
|
||||||
|
|
||||||
|
69010 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 7
|
||||||
|
- `README.md` 4
|
||||||
|
- `public/css/styles.css` 4
|
||||||
|
- `CHANGELOG.md` 1
|
||||||
|
- `src/ui/stylesheet-floor.test.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 70e46c48db80, finished 2026-10-31T07:17:56Z
|
||||||
|
|
||||||
|
70843 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `README.md` 18
|
||||||
|
- `todo.md` 6
|
||||||
|
- `public/css/styles.css` 5
|
||||||
|
- `CHANGELOG.md` 2
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `src/auth/jwt-middleware.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 9e8ffd0aa8dc, finished 2026-10-31T07:36:53Z
|
||||||
|
|
||||||
|
98975 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `README.md` 9
|
||||||
|
- `ci.sh` 3
|
||||||
|
- `e2e-tests/visual.spec.ts` 3
|
||||||
|
- `todo.md` 3
|
||||||
|
- `CHANGELOG.md` 2
|
||||||
|
- `e2e-tests/compose.visual.yml` 2
|
||||||
|
- `e2e-tests/language.spec.ts` 2
|
||||||
|
- `e2e-tests/mock-oidc.ts` 2
|
||||||
|
- `src/ci-gate.test.ts` 2
|
||||||
|
- `.dockerignore` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 25b7840ff611, finished 2026-10-31T07:46:18Z
|
||||||
|
|
||||||
|
73781 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 5
|
||||||
|
- `README.md` 2
|
||||||
|
- `src/http/app.ts` 2
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `src/http/app.test.ts` 1
|
||||||
|
- `src/ui/chrome.test.ts` 1
|
||||||
|
- `src/ui/chrome.ts` 1
|
||||||
|
- `views/index.ejs` 1
|
||||||
|
- `views/partials/shell.ejs` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z
|
||||||
|
|
||||||
|
154733 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 10
|
||||||
|
- `README.md` 6
|
||||||
|
- `package.json` 3
|
||||||
|
- `.gitignore` 1
|
||||||
|
- `.npmrc` 1
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `ci.sh` 1
|
||||||
|
- `public/css/styles.css` 1
|
||||||
|
- `renovate.json` 1
|
||||||
|
- `src/e2e-console-guard.test.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 2f36d29bd746, finished 2026-10-31T08:19:09Z
|
||||||
|
|
||||||
|
85029 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `e2e-tests/full-flow.spec.ts` 4
|
||||||
|
- `README.md` 3
|
||||||
|
- `src/auth/routes.test.ts` 3
|
||||||
|
- `todo.md` 3
|
||||||
|
- `src/auth/kratos-public.ts` 2
|
||||||
|
- `src/http/app.test.ts` 2
|
||||||
|
- `ci.sh` 1
|
||||||
|
- `compose.yml` 1
|
||||||
|
- `e2e-tests/auth-refresh.spec.ts` 1
|
||||||
|
- `src/auth/flow-view.ts` 1
|
||||||
@@ -8,6 +8,8 @@ COPY e2e-tests/package.json e2e-tests/package-lock.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY e2e-tests/ ./
|
COPY e2e-tests/ ./
|
||||||
|
# e2e-tests/tsconfig.json extends it, so the specs are held to the host's strictness flags.
|
||||||
|
COPY tsconfig.json /tsconfig.json
|
||||||
|
|
||||||
# Runs as the invoking `--user` so artifacts land owned by them, not root — and an arbitrary uid has
|
# Runs as the invoking `--user` so artifacts land owned by them, not root — and an arbitrary uid has
|
||||||
# no passwd entry here, so its home would be the unwritable `/`. npm's cache follows HOME.
|
# no passwd entry here, so its home would be the unwritable `/`. npm's cache follows HOME.
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ services:
|
|||||||
|
|
||||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.22.1-alpine3.24
|
image: node:24.22.2-alpine3.24
|
||||||
command: ["node", "/server.ts"]
|
command: ["node", "/server.ts"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
||||||
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
||||||
mock-oidc:
|
mock-oidc:
|
||||||
image: node:24.22.1-alpine3.24
|
image: node:24.22.2-alpine3.24
|
||||||
command: ["node", "/mock-oidc.ts"]
|
command: ["node", "/mock-oidc.ts"]
|
||||||
environment:
|
environment:
|
||||||
ISSUER: http://mock-oidc:9000
|
ISSUER: http://mock-oidc:9000
|
||||||
@@ -81,7 +81,7 @@ services:
|
|||||||
|
|
||||||
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
||||||
proxy:
|
proxy:
|
||||||
image: node:24.22.1-alpine3.24
|
image: node:24.22.2-alpine3.24
|
||||||
command: ["node", "/proxy.ts"]
|
command: ["node", "/proxy.ts"]
|
||||||
depends_on:
|
depends_on:
|
||||||
web:
|
web:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Browser, Page } from "@playwright/test";
|
import type { Browser, Page } from "@playwright/test";
|
||||||
import { expect, test, watchedPage } from "./console-guard.ts";
|
import { allowConsole, expect, test, watchedPage } from "./console-guard.ts";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
// Full browser E2E: the real Playwright UI against the live stack via the same-origin
|
// Full browser E2E: the real Playwright UI against the live stack via the same-origin
|
||||||
@@ -136,7 +136,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async () => {
|
test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async ({ browser }) => {
|
||||||
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
|
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
|
||||||
// enforces it); pick the first option (a user) from the required picker.
|
// enforces it); pick the first option (a user) from the required picker.
|
||||||
const group = `e2e-grp-${suffix}`;
|
const group = `e2e-grp-${suffix}`;
|
||||||
@@ -150,6 +150,10 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed
|
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed
|
||||||
// checkbox list rather than a create form — there is no Permissions screen to visit.
|
// checkbox list rather than a create form — there is no Permissions screen to visit.
|
||||||
await page.goto(`/admin/groups/${group}`);
|
await page.goto(`/admin/groups/${group}`);
|
||||||
|
allowConsole(/status of 409 .*\/admin\/groups\/[^/]+\/permissions/); // the stale save below, refused on purpose
|
||||||
|
// A second browser signed in as the same admin, opened before the save below, so its form carries the old grants.
|
||||||
|
const staleTab = await watchedPage(await browser.newContext({ storageState: await page.context().storageState() }));
|
||||||
|
await staleTab.goto(`/admin/groups/${group}`);
|
||||||
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
|
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
|
||||||
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
|
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
|
||||||
await expect(scheduling).not.toBeChecked();
|
await expect(scheduling).not.toBeChecked();
|
||||||
@@ -157,7 +161,16 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
|
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
|
await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
|
||||||
|
await expect(page.getByText("Permissions saved.")).toBeVisible();
|
||||||
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
|
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
|
||||||
|
|
||||||
|
// The other tab no longer matches Keto, so its save is refused and shows the grant made above.
|
||||||
|
await staleTab.locator('input[name="permission"][value="scheduling:write"]').check();
|
||||||
|
await staleTab.locator('form:has(input[name="permission"]) button[type="submit"]').click();
|
||||||
|
await expect(staleTab.getByText("changed while you had the page open")).toBeVisible();
|
||||||
|
await expect(staleTab.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
|
||||||
|
await expect(staleTab.locator('input[name="permission"][value="scheduling:write"]')).not.toBeChecked();
|
||||||
|
await staleTab.context().close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
|
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
|
||||||
@@ -240,6 +253,17 @@ test("return_to: a deep link while logged out returns to that page after login",
|
|||||||
await expect(page.locator("h1")).toHaveText("Users");
|
await expect(page.locator("h1")).toHaveText("Users");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("settings: an anonymous visit signs in first, then lands on the settings page", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
await page.goto("/settings");
|
||||||
|
await expect(page).toHaveURL(/\/login(\?|$)/);
|
||||||
|
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
|
||||||
|
await page.fill('input[name="password"]', ADMIN_PASSWORD);
|
||||||
|
await page.locator('.auth-form button[type="submit"]').click();
|
||||||
|
await expect(page).toHaveURL(/\/settings(\?|$)/);
|
||||||
|
await expect(page.locator("h1")).toHaveText("Account settings");
|
||||||
|
});
|
||||||
|
|
||||||
test("mocked SSO login: the provider button signs a user in via OIDC", async ({ page }) => {
|
test("mocked SSO login: the provider button signs a user in via OIDC", async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// 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).
|
||||||
import { createServer } from "node:http";
|
import { createServer, type ServerResponse } from "node:http";
|
||||||
import { createSign, generateKeyPairSync, randomUUID } from "node:crypto";
|
import { createSign, generateKeyPairSync, randomUUID } from "node:crypto";
|
||||||
|
|
||||||
const ISSUER = process.env.ISSUER ?? "http://mock-oidc:9000";
|
const ISSUER = process.env.ISSUER ?? "http://mock-oidc:9000";
|
||||||
@@ -15,8 +15,8 @@ const KID = "mock-1";
|
|||||||
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||||
const jwk = { ...publicKey.export({ format: "jwk" }), alg: "RS256", kid: KID, use: "sig" };
|
const jwk = { ...publicKey.export({ format: "jwk" }), alg: "RS256", kid: KID, use: "sig" };
|
||||||
|
|
||||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
|
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
|
||||||
function idToken(nonce) {
|
function idToken(nonce: string): string {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const header = b64({ alg: "RS256", kid: KID, typ: "JWT" });
|
const header = b64({ alg: "RS256", kid: KID, typ: "JWT" });
|
||||||
const payload = b64({
|
const payload = b64({
|
||||||
@@ -27,8 +27,8 @@ function idToken(nonce) {
|
|||||||
return `${header}.${payload}.${sig}`;
|
return `${header}.${payload}.${sig}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codes = new Map(); // single-use auth code → the nonce Kratos sent (echoed into the id_token)
|
const codes = new Map<string, string>(); // single-use auth code → the nonce Kratos sent (echoed into the id_token)
|
||||||
const json = (res, body) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
|
const json = (res: ServerResponse, body: unknown): void => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
|
||||||
|
|
||||||
createServer((req, res) => {
|
createServer((req, res) => {
|
||||||
const url = new URL(req.url ?? "/", ISSUER);
|
const url = new URL(req.url ?? "/", ISSUER);
|
||||||
|
|||||||
Generated
+395
-1
@@ -6,7 +6,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "plainpages-e2e",
|
"name": "plainpages-e2e",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.64.1"
|
"@playwright/test": "1.64.1",
|
||||||
|
"@types/node": "24.19.2",
|
||||||
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
@@ -25,6 +27,356 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "24.19.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
|
||||||
|
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"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/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.64.1",
|
"version": "1.64.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||||
@@ -53,6 +405,48 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"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": {
|
||||||
|
"version": "7.24.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||||
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@
|
|||||||
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
|
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "playwright test"
|
"test": "playwright test",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.64.1"
|
"@playwright/test": "1.64.1",
|
||||||
|
"@types/node": "24.19.2",
|
||||||
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
|||||||
testDir: ".",
|
testDir: ".",
|
||||||
outputDir: "artifacts/test-output",
|
outputDir: "artifacts/test-output",
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
|
workers: 4, // Playwright's default is half the cores, so a run's parallelism would depend on the machine
|
||||||
forbidOnly: true,
|
forbidOnly: true,
|
||||||
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
|
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
|
||||||
use: {
|
use: {
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ const PORT = Number(process.env.PORT ?? 80);
|
|||||||
|
|
||||||
// Kratos public owns these prefixes (self-service flows, sessions, its well-known/schemas); the
|
// Kratos public owns these prefixes (self-service flows, sessions, its well-known/schemas); the
|
||||||
// browser hits them via the flow action + OIDC callbacks. Everything else is the web app.
|
// browser hits them via the flow action + OIDC callbacks. Everything else is the web app.
|
||||||
const toKratos = (path) => ["/self-service", "/sessions", "/.well-known/ory", "/schemas"].some((p) => path === p || path.startsWith(`${p}/`));
|
const toKratos = (path: string): boolean => ["/self-service", "/sessions", "/.well-known/ory", "/schemas"].some((p) => path === p || path.startsWith(`${p}/`));
|
||||||
|
|
||||||
createServer((req, res) => {
|
createServer((req, res) => {
|
||||||
const target = toKratos(req.url ?? "/") ? KRATOS : WEB;
|
const target = toKratos(req.url ?? "/") ? KRATOS : WEB;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": { "lib": ["ESNext", "DOM"] },
|
||||||
|
"include": ["*.ts"]
|
||||||
|
}
|
||||||
@@ -10,6 +10,15 @@ const SESSION_COOKIE = "plainpages_jwt"; // src/auth/login.ts — web verifies i
|
|||||||
const shot = (page: Page, name: string): Promise<Buffer> =>
|
const shot = (page: Page, name: string): Promise<Buffer> =>
|
||||||
page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/${name}.png` });
|
page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/${name}.png` });
|
||||||
|
|
||||||
|
// Either side of the stylesheet's 860px and 560px breakpoints.
|
||||||
|
const VIEWPORTS = [
|
||||||
|
{ name: "desktop-xl", width: 1920, height: 1080 },
|
||||||
|
{ name: "laptop", width: 1280, height: 800 },
|
||||||
|
{ name: "tablet", width: 768, height: 1024 },
|
||||||
|
{ name: "phone", width: 390, height: 844 },
|
||||||
|
{ name: "phone-small", width: 320, height: 568 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
|
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
|
||||||
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
|
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
|
||||||
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
|
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
|
||||||
@@ -34,7 +43,7 @@ for (const [name, path, tail] of [
|
|||||||
["the starter dashboard", "/dashboard", ".form-actions .btn"],
|
["the starter dashboard", "/dashboard", ".form-actions .btn"],
|
||||||
["the public landing", "/", ".landing-actions .btn"],
|
["the public landing", "/", ".landing-actions .btn"],
|
||||||
] as const) {
|
] as const) {
|
||||||
for (const width of [1280, 390]) {
|
for (const { width } of VIEWPORTS) {
|
||||||
test(`${name} scrolls to its end at ${width}px wide`, async ({ page }) => {
|
test(`${name} scrolls to its end at ${width}px wide`, async ({ page }) => {
|
||||||
await page.setViewportSize({ width, height: 200 });
|
await page.setViewportSize({ width, height: 200 });
|
||||||
await page.goto(path);
|
await page.goto(path);
|
||||||
@@ -77,13 +86,21 @@ test("captures the live pages for review", async ({ page }) => {
|
|||||||
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");
|
||||||
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
await page.goto("/dashboard");
|
|
||||||
await shot(page, "live-04-mobile");
|
|
||||||
await page.setViewportSize({ width: 1280, height: 800 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The page is a document, so a column wider than the viewport can only show as sideways scroll.
|
||||||
|
for (const { name, width, height } of VIEWPORTS) {
|
||||||
|
for (const [path, label] of [["/dashboard", "dashboard"], ["/", "landing"], ["/scheduling", "plugin"]] as const) {
|
||||||
|
test(`the ${label} page fits ${width}px wide, with no sideways scroll`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height });
|
||||||
|
await page.goto(path);
|
||||||
|
const [scrollWidth, clientWidth] = await page.evaluate(() => [document.documentElement.scrollWidth, document.documentElement.clientWidth] as const);
|
||||||
|
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
|
||||||
|
await shot(page, `live-${label}-${name}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 }) => {
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
const missing = await page.evaluate(() => {
|
const missing = await page.evaluate(() => {
|
||||||
@@ -148,6 +165,21 @@ test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({
|
|||||||
expect(offCanvas).toBe(true);
|
expect(offCanvas).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the hamburger opens the drawer over the scrim, and a link in it navigates", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
await page.locator(".hamburger").click();
|
||||||
|
|
||||||
|
const sidebar = page.locator(".sidebar");
|
||||||
|
await expect(sidebar).toBeInViewport({ ratio: 1 });
|
||||||
|
// A click is refused while the scrim sits above the drawer, which a lower z-index would cause.
|
||||||
|
await sidebar.locator("summary").first().click(); // the dashboard is the current page, so its links are in a folded branch
|
||||||
|
const link = sidebar.locator(".nav-children a.nav-self").first();
|
||||||
|
const target = await link.getAttribute("href");
|
||||||
|
await link.click();
|
||||||
|
await expect(page).toHaveURL((url) => url.pathname === target?.split("?")[0]);
|
||||||
|
});
|
||||||
|
|
||||||
test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => {
|
test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => {
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
|
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
|
||||||
@@ -174,6 +206,50 @@ test("the public landing at / is ungated and links to sign in + register", async
|
|||||||
await shot(page, "live-05-public-landing");
|
await shot(page, "live-05-public-landing");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Measured on the rendered elements, so a theme block that drops --on-accent fails here.
|
||||||
|
const contrastOf = (el: Element): number => {
|
||||||
|
const luminance = (rgb: string): number => {
|
||||||
|
const [r = 0, g = 0, b = 0] = (rgb.match(/[\d.]+/g) ?? []).map((c) => {
|
||||||
|
const v = Number(c) / 255;
|
||||||
|
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||||
|
});
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
};
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
const [hi = 0, lo = 0] = [luminance(style.color), luminance(style.backgroundColor)].sort((x, y) => y - x);
|
||||||
|
return (hi + 0.05) / (lo + 0.05);
|
||||||
|
};
|
||||||
|
for (const [name, colorScheme, forceDark] of [["light", "light", false], ["dark", "dark", false], ["forced dark", "light", true]] as const) {
|
||||||
|
test(`text on the accent reaches 4.5:1 in ${name} mode`, async ({ page, context }) => {
|
||||||
|
await context.clearCookies();
|
||||||
|
await page.emulateMedia({ colorScheme });
|
||||||
|
await page.goto("/");
|
||||||
|
if (forceDark) await page.locator("#theme-dark").check({ force: true });
|
||||||
|
// No page of this stack has a second page of results, so add the current-page button.
|
||||||
|
await page.locator("#main-content").evaluate((el) => el.insertAdjacentHTML("beforeend", '<a class="page-btn" aria-current="page" href="#">2</a>'));
|
||||||
|
for (const selector of [".landing .btn-primary", ".brand-mark", ".page-btn"]) {
|
||||||
|
await expect.poll(() => page.locator(selector).first().evaluate(contrastOf), { message: selector }).toBeGreaterThanOrEqual(4.5); // polls past the colour transition
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a nav branch is folded until its toggle opens it — no JavaScript", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
const child = page.locator('.sidebar a[href="/scheduling"]');
|
||||||
|
await expect(child).toBeHidden();
|
||||||
|
await page.locator('.sidebar summary[aria-label="Toggle Scheduling"]').click();
|
||||||
|
await expect(child).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the reference plugin's lead text is inset by --pad-x, like the bars around it", async ({ page }) => {
|
||||||
|
await page.goto("/scheduling");
|
||||||
|
const [inset, padX] = await page.locator(".scheduling-page > p").evaluate((p) => [
|
||||||
|
p.getBoundingClientRect().left - (p.parentElement as HTMLElement).getBoundingClientRect().left,
|
||||||
|
parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--pad-x")),
|
||||||
|
] as const);
|
||||||
|
expect(inset).toBe(padX);
|
||||||
|
});
|
||||||
|
|
||||||
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
|
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
|
||||||
allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it
|
allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it
|
||||||
const res = await page.goto("/no-such-page");
|
const res = await page.goto("/no-such-page");
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import type { PermissionDecl } from "@plainpages/plugin-api";
|
import type { PermissionDecl } from "@plainpages/plugin-api";
|
||||||
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
|
import type { KetoClient } from "@plainpages/plugin-api";
|
||||||
|
import { applyGrants, baselineToken, buildPermissionPicker, grantDiff, grantTuple, groupSubject, isStale, userSubject } from "./admin-grants.ts";
|
||||||
|
|
||||||
const declared: PermissionDecl[] = [
|
const declared: PermissionDecl[] = [
|
||||||
{ description: "View users", name: "users:read" },
|
{ description: "View users", name: "users:read" },
|
||||||
@@ -81,3 +82,48 @@ test("buildPermissionPicker says so when no plugin declares a permission, rather
|
|||||||
assert.deepEqual(picker.choices, []);
|
assert.deepEqual(picker.choices, []);
|
||||||
assert.ok(picker.empty);
|
assert.ok(picker.empty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("baselineToken depends on the offered grants held, not on their order or on undeclared ones", () => {
|
||||||
|
const token = baselineToken(declared, ["users:read", "users:write"]);
|
||||||
|
assert.match(token, /^[0-9a-f]+$/);
|
||||||
|
assert.equal(baselineToken(declared, ["users:write", "users:read"]), token);
|
||||||
|
assert.equal(baselineToken(declared, ["users:read", "users:write", "legacy:thing"]), token); // undeclared: never shown, never saved
|
||||||
|
assert.notEqual(baselineToken(declared, ["users:read"]), token);
|
||||||
|
assert.notEqual(baselineToken(declared, []), token);
|
||||||
|
assert.notEqual(baselineToken(declared, []), ""); // an empty set is a state too, not an absent token
|
||||||
|
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: ["users:read", "users:write"] }).baseline, token);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isStale refuses a missing or old token, but only for a save that would change something", () => {
|
||||||
|
const held = ["users:read"];
|
||||||
|
const change = grantDiff(declared, held, ["users:read", "users:write"]);
|
||||||
|
const nothing = grantDiff(declared, held, ["users:read"]);
|
||||||
|
const current = new URLSearchParams({ baseline: baselineToken(declared, held) });
|
||||||
|
const old = new URLSearchParams({ baseline: baselineToken(declared, []) });
|
||||||
|
assert.equal(isStale(current, declared, held, change), false);
|
||||||
|
assert.equal(isStale(old, declared, held, change), true);
|
||||||
|
assert.equal(isStale(new URLSearchParams(), declared, held, change), true); // no token at all
|
||||||
|
assert.equal(isStale(old, declared, held, nothing), false); // a repeated save has nothing to refuse
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyGrants applies writes then deletes, and on a Keto failure stops and reports what was applied", async () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const keto = (failOn?: string): KetoClient => ({
|
||||||
|
check: async () => false,
|
||||||
|
deleteTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`delete ${tp.object}`); },
|
||||||
|
expand: async () => ({ type: "leaf" }),
|
||||||
|
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
|
||||||
|
writeTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`write ${tp.object}`); },
|
||||||
|
});
|
||||||
|
const diff = { grant: ["a:read", "b:read"], revoke: ["c:read"] };
|
||||||
|
|
||||||
|
const ok = await applyGrants(keto(), userSubject("u1"), diff);
|
||||||
|
assert.deepEqual(ok, { applied: diff });
|
||||||
|
assert.deepEqual(log, ["write a:read", "write b:read", "delete c:read"]);
|
||||||
|
|
||||||
|
log.length = 0;
|
||||||
|
const failed = await applyGrants(keto("b:read"), userSubject("u1"), diff);
|
||||||
|
assert.deepEqual(failed.applied, { grant: ["a:read"], revoke: [] });
|
||||||
|
assert.ok(failed.error instanceof Error);
|
||||||
|
assert.deepEqual(log, ["write a:read"]); // nothing after the failure was attempted
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,11 +6,17 @@
|
|||||||
// what the installed plugins declare in code. Nothing here invents a name, which is why the old
|
// what the installed plugins declare in code. Nothing here invents a name, which is why the old
|
||||||
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
|
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
|
||||||
|
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
|
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
|
||||||
|
|
||||||
const PERMISSION_NS = "Permission";
|
const PERMISSION_NS = "Permission";
|
||||||
const GRANTED = "granted";
|
const GRANTED = "granted";
|
||||||
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
|
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
|
||||||
|
export const BASELINE_FIELD = "baseline"; // the hidden field carrying baselineToken
|
||||||
|
export const SAVED_NOTICE = "permissions-saved"; // the `info-msg` a successful save redirects with
|
||||||
|
|
||||||
|
// Only that exact value counts, so a crafted link can't put words on the page.
|
||||||
|
export const isSavedNotice = (url: URL): boolean => url.searchParams.get("info-msg") === SAVED_NOTICE;
|
||||||
|
|
||||||
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
|
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
|
||||||
|
|
||||||
@@ -53,6 +59,7 @@ export interface PermissionChoice {
|
|||||||
|
|
||||||
export interface PermissionPicker {
|
export interface PermissionPicker {
|
||||||
action: string;
|
action: string;
|
||||||
|
baseline: string; // baselineToken of the direct grants this form was rendered from
|
||||||
choices: PermissionChoice[];
|
choices: PermissionChoice[];
|
||||||
empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer
|
empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer
|
||||||
error?: string; // a rejected save (e.g. the self-revoke guard), rendered above the list
|
error?: string; // a rejected save (e.g. the self-revoke guard), rendered above the list
|
||||||
@@ -60,6 +67,7 @@ export interface PermissionPicker {
|
|||||||
hint: string;
|
hint: string;
|
||||||
inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row
|
inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row
|
||||||
legend: string;
|
legend: string;
|
||||||
|
notice?: string; // a confirmation, rendered above the list
|
||||||
// Set for a group: its members hold these transitively, so a change reaches them at their next
|
// Set for a group: its members hold these transitively, so a change reaches them at their next
|
||||||
// re-mint rather than at once. The user picker revokes live tokens, so it says nothing.
|
// re-mint rather than at once. The user picker revokes live tokens, so it says nothing.
|
||||||
pending: string | undefined;
|
pending: string | undefined;
|
||||||
@@ -90,6 +98,7 @@ export function buildPermissionPicker(opts: {
|
|||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
action: opts.action,
|
action: opts.action,
|
||||||
|
baseline: baselineToken(opts.declared, opts.direct),
|
||||||
choices,
|
choices,
|
||||||
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
|
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
|
||||||
field: PERMISSIONS_FIELD,
|
field: PERMISSIONS_FIELD,
|
||||||
@@ -107,7 +116,7 @@ export function buildPermissionPicker(opts: {
|
|||||||
// What a submitted set changes. Pure so the diff is testable without Keto: only declared names are
|
// What a submitted set changes. Pure so the diff is testable without Keto: only declared names are
|
||||||
// considered, so a crafted POST cannot grant something no plugin gates on, and a held-but-undeclared
|
// considered, so a crafted POST cannot grant something no plugin gates on, and a held-but-undeclared
|
||||||
// name (left over from an uninstalled plugin) is never silently revoked by an unrelated save.
|
// name (left over from an uninstalled plugin) is never silently revoked by an unrelated save.
|
||||||
export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): { grant: string[]; revoke: string[] } {
|
export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): GrantChange {
|
||||||
const offered = new Set(declared.map((d) => d.name));
|
const offered = new Set(declared.map((d) => d.name));
|
||||||
const heldSet = new Set(held);
|
const heldSet = new Set(held);
|
||||||
const wantedSet = new Set(wanted.filter((name) => offered.has(name)));
|
const wantedSet = new Set(wanted.filter((name) => offered.has(name)));
|
||||||
@@ -117,7 +126,32 @@ export function grantDiff(declared: readonly PermissionDecl[], held: string[], w
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: { grant: string[]; revoke: string[] }): Promise<void> {
|
// A token for the direct grants a form was rendered from. Like grantDiff it covers only the declared
|
||||||
for (const name of diff.grant) await keto.writeTuple(grantTuple(name, subject));
|
// names the form offered. It is a digest so an empty set is still a token; it is a guard against a
|
||||||
for (const name of diff.revoke) await keto.deleteTuple(grantTuple(name, subject));
|
// stale page, not a secret, since anyone who can read the page can compute it.
|
||||||
|
export function baselineToken(declared: readonly PermissionDecl[], held: string[]): string {
|
||||||
|
const offered = new Set(declared.map((d) => d.name));
|
||||||
|
return createHash("sha256").update(held.filter((name) => offered.has(name)).sort().join("\n")).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the form's token is missing or no longer matches Keto *and* the save would change
|
||||||
|
// something. A save that changes nothing, such as a double-clicked Save whose first POST landed, is
|
||||||
|
// harmless, so it is not refused.
|
||||||
|
export const isStale = (form: { get(name: string): string | null }, declared: readonly PermissionDecl[], held: string[], diff: GrantChange): boolean =>
|
||||||
|
(diff.grant.length > 0 || diff.revoke.length > 0) && form.get(BASELINE_FIELD) !== baselineToken(declared, held);
|
||||||
|
|
||||||
|
export interface GrantChange { grant: string[]; revoke: string[] }
|
||||||
|
|
||||||
|
// KetoClient writes one tuple per call, so the writes and deletes run one by one and stop at the first
|
||||||
|
// failure. What was applied by then is returned beside the error, so the caller can still revoke live
|
||||||
|
// tokens and tell the admin the save is only partly done.
|
||||||
|
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: GrantChange): Promise<{ applied: GrantChange; error?: unknown }> {
|
||||||
|
const applied: GrantChange = { grant: [], revoke: [] };
|
||||||
|
try {
|
||||||
|
for (const name of diff.grant) { await keto.writeTuple(grantTuple(name, subject)); applied.grant.push(name); }
|
||||||
|
for (const name of diff.revoke) { await keto.deleteTuple(grantTuple(name, subject)); applied.revoke.push(name); }
|
||||||
|
} catch (error) {
|
||||||
|
return { applied, error };
|
||||||
|
}
|
||||||
|
return { applied };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
// each returning a RouteResult.
|
// each returning a RouteResult.
|
||||||
|
|
||||||
import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
|
import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
|
||||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
|
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE } from "./admin-grants.ts";
|
||||||
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
|
|
||||||
const GROUP_NS = "Group";
|
const GROUP_NS = "Group";
|
||||||
@@ -347,13 +347,14 @@ export const groupsCreate = withGroups(async (deps) => {
|
|||||||
// GET /admin/groups/new — the create form.
|
// GET /admin/groups/new — the create form.
|
||||||
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
|
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
|
||||||
|
|
||||||
// GET /admin/groups/:name — the detail + membership page.
|
// The detail + membership page. `notice` and `error` ride the permission picker, which is where a save reports.
|
||||||
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
|
async function groupDetailPage({ ctx, keto, kratosAdmin }: GroupsDeps, name: string, say: { error?: string; notice?: string } = {}): Promise<RouteResult> {
|
||||||
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
||||||
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
|
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
|
||||||
const subject = groupSubject(name);
|
const subject = groupSubject(name);
|
||||||
const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]);
|
const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]);
|
||||||
const permissions = buildPermissionPicker({
|
const permissions: PermissionPicker = {
|
||||||
|
...buildPermissionPicker({
|
||||||
action: `${detailHref(name)}/permissions`,
|
action: `${detailHref(name)}/permissions`,
|
||||||
declared: ctx.declaredPermissions,
|
declared: ctx.declaredPermissions,
|
||||||
direct,
|
direct,
|
||||||
@@ -361,22 +362,36 @@ export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, nam
|
|||||||
readOnly: !can(ctx, permissionName("groups", "write")),
|
readOnly: !can(ctx, permissionName("groups", "write")),
|
||||||
t: ctx.t,
|
t: ctx.t,
|
||||||
transitive: true, // members inherit, so a change here lands at their next re-mint, not at once
|
transitive: true, // members inherit, so a change here lands at their next re-mint, not at once
|
||||||
});
|
}),
|
||||||
|
...(say.error ? { error: say.error } : {}),
|
||||||
|
...(say.notice ? { notice: say.notice } : {}),
|
||||||
|
};
|
||||||
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t }) }, view: "group-detail" };
|
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t }) }, view: "group-detail" };
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// GET /admin/groups/:name — the detail + membership page.
|
||||||
|
export const groupsDetail = withGroupName((deps, name) =>
|
||||||
|
groupDetailPage(deps, name, isSavedNotice(deps.ctx.url) ? { notice: deps.ctx.t("admin.grants.saved") } : {}));
|
||||||
|
|
||||||
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
|
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
|
||||||
// a group's permissions transitively, so the change reaches them at their next login or re-mint —
|
// a group's permissions transitively, so the change reaches them at their next login or re-mint —
|
||||||
// the documented instant-revoke tradeoff for anything held through a group.
|
// the documented instant-revoke tradeoff for anything held through a group.
|
||||||
export const groupsPermissions = withGroupName(async ({ ctx, keto, user }, name) => {
|
export const groupsPermissions = withGroupName(async (deps, name) => {
|
||||||
|
const { ctx, keto, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
const subject = groupSubject(name);
|
const subject = groupSubject(name);
|
||||||
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
|
const held = await heldPermissions(keto, subject);
|
||||||
await applyGrants(keto, subject, diff);
|
const diff = grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD));
|
||||||
if (diff.grant.length > 0 || diff.revoke.length > 0) {
|
if (isStale(form, ctx.declaredPermissions, held, diff)) return { ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.stale") })), status: 409 };
|
||||||
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: diff.grant.join(","), group: name, revoked: diff.revoke.join(",") });
|
const { applied, error } = await applyGrants(keto, subject, diff);
|
||||||
|
if (applied.grant.length > 0 || applied.revoke.length > 0) {
|
||||||
|
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: applied.grant.join(","), group: name, revoked: applied.revoke.join(",") });
|
||||||
}
|
}
|
||||||
return { redirect: detailHref(name) };
|
if (error) {
|
||||||
|
ctx.log.error("admin: group permissions only partly saved", { actor: user.id, error: String(error), group: name });
|
||||||
|
return partialSave(ctx, async () => ({ ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.partial") })), status: 502 }));
|
||||||
|
}
|
||||||
|
return { redirect: `${detailHref(name)}?info-msg=${SAVED_NOTICE}` };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
|
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ export function unavailable(ctx: RequestContext, what: string): RouteResult {
|
|||||||
return { data: { chrome: ctx.chrome, message: ctx.t("admin.unavailable.message", { what }), title: ctx.t("admin.unavailable.title") }, status: 503, view: "notice" };
|
return { data: { chrome: ctx.chrome, message: ctx.t("admin.unavailable.message", { what }), title: ctx.t("admin.unavailable.title") }, status: 503, view: "notice" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The page a partial permission save reports on reads Keto again. If that read fails too, the report
|
||||||
|
// stands alone on the notice page, so the admin still hears that part of the save landed.
|
||||||
|
export async function partialSave(ctx: RequestContext, render: () => Promise<RouteResult>): Promise<RouteResult> {
|
||||||
|
try {
|
||||||
|
return await render();
|
||||||
|
} catch {
|
||||||
|
return { data: { chrome: ctx.chrome, message: ctx.t("admin.grants.partial"), title: ctx.t("admin.unavailable.title") }, status: 502, view: "notice" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields
|
// Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields
|
||||||
// (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs.
|
// (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs.
|
||||||
export function buildConfirmModel(opts: {
|
export function buildConfirmModel(opts: {
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ test("buildUsersListModel filters by search + status, sorts, and paginates", ()
|
|||||||
|
|
||||||
// Edit link points at the per-identity route.
|
// Edit link points at the per-identity route.
|
||||||
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/admin/users/${people[0]!.id}$`));
|
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/admin/users/${people[0]!.id}$`));
|
||||||
|
assert.deepEqual([all.table.rows[0]!.actions![0]!.icon, all.table.rows[0]!.actions![0]!.label], ["i-edit", "Edit"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The detail page of a `users:read` holder is read-only, so the row action must not promise an edit.
|
||||||
|
test("buildUsersListModel: a read-only holder's row action says View, with the same target", () => {
|
||||||
|
const people: Identity[] = [{ id: "u1", traits: { email: "a@x.io" } }];
|
||||||
|
const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/admin/users" }).table.rows[0]!.actions![0]!;
|
||||||
|
assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/admin/users/u1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
|
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
// a shared `withUser` gate.
|
// a shared `withUser` gate.
|
||||||
|
|
||||||
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
||||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
|
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
||||||
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||||
|
|
||||||
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
|
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
@@ -138,12 +138,12 @@ export function buildUsersListModel(opts: {
|
|||||||
canWrite: opts.canWrite !== false,
|
canWrite: opts.canWrite !== false,
|
||||||
filterBar: listFilterBar(state, all.length, t),
|
filterBar: listFilterBar(state, all.length, t),
|
||||||
pagination: listPagination(state, page, t),
|
pagination: listPagination(state, page, t),
|
||||||
table: listTable(rows, state, sort, t),
|
table: listTable(rows, state, sort, t, opts.canWrite !== false),
|
||||||
title: t("admin.users.title"),
|
title: t("admin.users.title"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
|
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate, canWrite: boolean) {
|
||||||
return {
|
return {
|
||||||
actions: true,
|
actions: true,
|
||||||
caption: t("admin.users.title"),
|
caption: t("admin.users.title"),
|
||||||
@@ -153,7 +153,7 @@ function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "des
|
|||||||
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
|
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
|
||||||
}),
|
}),
|
||||||
rows: rows.map((u) => ({
|
rows: rows.map((u) => ({
|
||||||
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: t("common.edit") }],
|
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: canWrite ? "i-edit" : "i-eye", label: t(canWrite ? "common.edit" : "admin.users.view") }],
|
||||||
cells: [
|
cells: [
|
||||||
{ user: { initials: u.initials, name: u.name } },
|
{ user: { initials: u.initials, name: u.name } },
|
||||||
u.email,
|
u.email,
|
||||||
@@ -329,7 +329,7 @@ export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx
|
|||||||
|
|
||||||
// GET /admin/users/:id — the edit form, prefilled.
|
// GET /admin/users/:id — the edit form, prefilled.
|
||||||
export const usersEditForm = withTarget(async (deps, identity, id) => {
|
export const usersEditForm = withTarget(async (deps, identity, id) => {
|
||||||
const permissions = await userPermissionPicker(deps, id);
|
const permissions = await userPermissionPicker(deps, id, undefined, isSavedNotice(deps.ctx.url) ? deps.ctx.t("admin.grants.saved") : undefined);
|
||||||
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
|
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ const canWriteUsers = (ctx: RequestContext): boolean => can(ctx, permissionName(
|
|||||||
// The checkbox list of declared permissions: ticked where this user holds one, and disabled where
|
// The checkbox list of declared permissions: ticked where this user holds one, and disabled where
|
||||||
// the grant comes from a group (real, but removed on that group). Undefined when Keto isn't wired —
|
// the grant comes from a group (real, but removed on that group). Undefined when Keto isn't wired —
|
||||||
// the rest of the edit page still works.
|
// the rest of the edit page still works.
|
||||||
async function userPermissionPicker(deps: UsersDeps, id: string, error?: string): Promise<PermissionPicker | undefined> {
|
async function userPermissionPicker(deps: UsersDeps, id: string, error?: string, notice?: string): Promise<PermissionPicker | undefined> {
|
||||||
if (!deps.keto) return undefined;
|
if (!deps.keto) return undefined;
|
||||||
const subject = userSubject(id);
|
const subject = userSubject(id);
|
||||||
const [direct, effective] = await Promise.all([
|
const [direct, effective] = await Promise.all([
|
||||||
@@ -355,6 +355,7 @@ async function userPermissionPicker(deps: UsersDeps, id: string, error?: string)
|
|||||||
t: deps.ctx.t,
|
t: deps.ctx.t,
|
||||||
}),
|
}),
|
||||||
...(error ? { error } : {}),
|
...(error ? { error } : {}),
|
||||||
|
...(notice ? { notice } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,21 +367,30 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
|
|||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
|
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
|
||||||
const subject = userSubject(id);
|
const subject = userSubject(id);
|
||||||
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
|
const held = await heldPermissions(keto, subject);
|
||||||
|
const refuse = async (error: string, status: number): Promise<RouteResult> => {
|
||||||
|
const permissions = await userPermissionPicker(deps, id, error);
|
||||||
|
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status };
|
||||||
|
};
|
||||||
|
const diff = grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD));
|
||||||
|
if (isStale(form, ctx.declaredPermissions, held, diff)) return refuse(ctx.t("admin.grants.stale"), 409);
|
||||||
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
|
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
|
||||||
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
|
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
|
||||||
// next request — leaving a `curl` against Keto as the only way back in.
|
// next request — leaving a `curl` against Keto as the only way back in.
|
||||||
if (id === user.id && diff.revoke.length > 0) {
|
if (id === user.id && diff.revoke.length > 0) {
|
||||||
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
|
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
|
||||||
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
|
return refuse(ctx.t("admin.grants.selfRevoke"), 400);
|
||||||
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status: 400 };
|
|
||||||
}
|
}
|
||||||
await applyGrants(keto, subject, diff);
|
const { applied, error } = await applyGrants(keto, subject, diff);
|
||||||
if (diff.grant.length > 0 || diff.revoke.length > 0) {
|
if (applied.grant.length > 0 || applied.revoke.length > 0) {
|
||||||
revoke?.(id);
|
revoke?.(id);
|
||||||
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: diff.grant.join(","), revoked: diff.revoke.join(","), target: id });
|
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: applied.grant.join(","), revoked: applied.revoke.join(","), target: id });
|
||||||
}
|
}
|
||||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
if (error) {
|
||||||
|
ctx.log.error("admin: user permissions only partly saved", { actor: user.id, error: String(error), target: id });
|
||||||
|
return partialSave(ctx, () => refuse(ctx.t("admin.grants.partial"), 502));
|
||||||
|
}
|
||||||
|
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${SAVED_NOTICE}` };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
|
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
|
||||||
|
|||||||
@@ -53,9 +53,12 @@ const messages = {
|
|||||||
"admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.",
|
"admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.",
|
||||||
"admin.grants.legend": "Permissions",
|
"admin.grants.legend": "Permissions",
|
||||||
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
|
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
|
||||||
|
"admin.grants.partial": "Saving stopped part-way because the permission service failed, so only some changes were applied. Check the permissions and make any remaining changes again.",
|
||||||
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
|
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
|
||||||
"admin.grants.save": "Save permissions",
|
"admin.grants.save": "Save permissions",
|
||||||
|
"admin.grants.saved": "Permissions saved.",
|
||||||
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
|
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
|
||||||
|
"admin.grants.stale": "These permissions changed while you had the page open, so nothing was saved. The list now shows the current state. Review it and save again.",
|
||||||
|
|
||||||
"admin.groups.actions": "Group actions",
|
"admin.groups.actions": "Group actions",
|
||||||
"admin.groups.addMember": "Add a member",
|
"admin.groups.addMember": "Add a member",
|
||||||
@@ -149,6 +152,7 @@ const messages = {
|
|||||||
"admin.users.status.inactive": "Inactive",
|
"admin.users.status.inactive": "Inactive",
|
||||||
"admin.users.status.label": "Status",
|
"admin.users.status.label": "Status",
|
||||||
"admin.users.title": "Users",
|
"admin.users.title": "Users",
|
||||||
|
"admin.users.view": "View",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminMessages = typeof messages;
|
export type AdminMessages = typeof messages;
|
||||||
|
|||||||
@@ -53,9 +53,12 @@ const messages: AdminMessages = {
|
|||||||
"admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.",
|
"admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.",
|
||||||
"admin.grants.legend": "Behörigheter",
|
"admin.grants.legend": "Behörigheter",
|
||||||
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
|
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
|
||||||
|
"admin.grants.partial": "Sparandet avbröts halvvägs eftersom behörighetstjänsten gav fel, så bara en del av ändringarna genomfördes. Kontrollera behörigheterna och gör de återstående ändringarna igen.",
|
||||||
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
|
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
|
||||||
"admin.grants.save": "Spara behörigheter",
|
"admin.grants.save": "Spara behörigheter",
|
||||||
|
"admin.grants.saved": "Behörigheterna är sparade.",
|
||||||
"admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.",
|
"admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.",
|
||||||
|
"admin.grants.stale": "Behörigheterna ändrades medan du hade sidan öppen, så ingenting sparades. Listan visar nu aktuellt läge. Granska den och spara igen.",
|
||||||
|
|
||||||
"admin.groups.actions": "Gruppåtgärder",
|
"admin.groups.actions": "Gruppåtgärder",
|
||||||
"admin.groups.addMember": "Lägg till en medlem",
|
"admin.groups.addMember": "Lägg till en medlem",
|
||||||
@@ -149,6 +152,7 @@ const messages: AdminMessages = {
|
|||||||
"admin.users.status.inactive": "Inaktiv",
|
"admin.users.status.inactive": "Inaktiv",
|
||||||
"admin.users.status.label": "Status",
|
"admin.users.status.label": "Status",
|
||||||
"admin.users.title": "Användare",
|
"admin.users.title": "Användare",
|
||||||
|
"admin.users.view": "Visa",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default messages;
|
export default messages;
|
||||||
|
|||||||
@@ -7,13 +7,16 @@
|
|||||||
there) and every row when `readOnly` (the viewer holds :read but not :write). Neither can be diffed
|
there) and every row when `readOnly` (the viewer holds :read but not :write). Neither can be diffed
|
||||||
into an accidental revoke, because grantDiff compares against the direct grants only.
|
into an accidental revoke, because grantDiff compares against the direct grants only.
|
||||||
|
|
||||||
Locals: csrfToken, permissions ({ action, choices, empty, error, field, hint, inheritedNote, legend, pending, readOnly, submit }).
|
Locals: csrfToken, permissions ({ action, baseline, choices, empty, error, field, hint, inheritedNote, legend, notice, pending, readOnly, submit }).
|
||||||
%>
|
%>
|
||||||
<section class="form-card" aria-labelledby="permissions-h">
|
<section class="form-card" aria-labelledby="permissions-h">
|
||||||
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
|
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
|
||||||
<% if (permissions.error) { -%>
|
<% if (permissions.error) { -%>
|
||||||
<%- include("partials/alert", { text: permissions.error, tone: "neg" }) %>
|
<%- include("partials/alert", { text: permissions.error, tone: "neg" }) %>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
|
<% if (permissions.notice) { -%>
|
||||||
|
<%- include("partials/alert", { text: permissions.notice, tone: "pos" }) %>
|
||||||
|
<% } -%>
|
||||||
<% if (permissions.empty) { -%>
|
<% if (permissions.empty) { -%>
|
||||||
<p class="cell-muted"><%= permissions.empty %></p>
|
<p class="cell-muted"><%= permissions.empty %></p>
|
||||||
<% } else { -%>
|
<% } else { -%>
|
||||||
@@ -26,8 +29,10 @@
|
|||||||
<% }) -%>
|
<% }) -%>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<% } else { -%>
|
<% } else { -%>
|
||||||
<form method="post" action="<%= localeHref(permissions.action) %>">
|
<%# The fragment brings the card, and the notice in it, into view on a long page. %>
|
||||||
|
<form method="post" action="<%= localeHref(permissions.action + '#permissions-h') %>">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<input type="hidden" name="baseline" value="<%= permissions.baseline %>">
|
||||||
<fieldset class="check-group">
|
<fieldset class="check-group">
|
||||||
<legend class="sr-only"><%= permissions.legend %></legend>
|
<legend class="sr-only"><%= permissions.legend %></legend>
|
||||||
<% permissions.choices.forEach((c) => { -%>
|
<% permissions.choices.forEach((c) => { -%>
|
||||||
|
|||||||
@@ -4,3 +4,9 @@
|
|||||||
.scheduling-page .table-wrap {
|
.scheduling-page .table-wrap {
|
||||||
margin-top: var(--space-3, 0.75rem);
|
margin-top: var(--space-3, 0.75rem);
|
||||||
}
|
}
|
||||||
|
/* Inset what sits directly in the pane by --pad-x, as the filter bar, table and pager already are. */
|
||||||
|
.scheduling-page > p,
|
||||||
|
.scheduling-page > .alert,
|
||||||
|
.scheduling-page > .btn {
|
||||||
|
margin-inline: var(--pad-x);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream
|
Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream
|
||||||
service; this view renders them with the core building blocks inside the native app shell
|
service; this view renders them with the core building blocks inside the native app shell
|
||||||
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
|
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
|
||||||
alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL.
|
alert) — see the README. Zero-JS: search round-trips the URL.
|
||||||
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
|
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
|
||||||
%><%
|
%><%
|
||||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
|
|||||||
Generated
+22
-8
@@ -7,13 +7,14 @@
|
|||||||
"name": "plainpages",
|
"name": "plainpages",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "7.0.2",
|
"ejs": "6.0.1",
|
||||||
"lucide-static": "1.61.0",
|
"lucide-static": "1.64.0",
|
||||||
"postgres": "3.4.9"
|
"postgres": "3.4.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "24.19.2",
|
"@types/node": "24.19.2",
|
||||||
|
"acorn": "8.19.0",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -386,10 +387,23 @@
|
|||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/acorn": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"acorn": "bin/acorn"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ejs": {
|
"node_modules/ejs": {
|
||||||
"version": "7.0.2",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
|
||||||
"integrity": "sha512-LcDSEP6Oclb9sTiSyG0MpG0RuKvLDRxCOOt+2sIk+kdk1rMkFU8SRnT0qaNt5zvU0LOV1YXXOvIoZL/sKwabvw==",
|
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"ejs": "bin/cli.js"
|
"ejs": "bin/cli.js"
|
||||||
@@ -399,9 +413,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-static": {
|
"node_modules/lucide-static": {
|
||||||
"version": "1.61.0",
|
"version": "1.64.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.64.0.tgz",
|
||||||
"integrity": "sha512-yLAiQB3MWUJEstsEDAwenSA3m7vr1tdLvgnC16Q/XMxRRma0DWwMs8M2bWlvgShrIs1m2mWMJ+0AiahqQO4GrA==",
|
"integrity": "sha512-UxWmMteOXbKHI5E4f2qVtxCAAsI9gsInYK1lNw0nrOA0w4FkJeOVZ+UOV6aGA2377FZe3qyiFYq3lqjBKZ6OuA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/postgres": {
|
"node_modules/postgres": {
|
||||||
|
|||||||
+3
-2
@@ -17,13 +17,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "7.0.2",
|
"ejs": "6.0.1",
|
||||||
"lucide-static": "1.61.0",
|
"lucide-static": "1.64.0",
|
||||||
"postgres": "3.4.9"
|
"postgres": "3.4.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "24.19.2",
|
"@types/node": "24.19.2",
|
||||||
|
"acorn": "8.19.0",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,3 @@
|
|||||||
/* =============================================================
|
|
||||||
Auth flow — login / register / forgot password
|
|
||||||
Built on the shared tokens in styles.css. Pure HTML/CSS:
|
|
||||||
- screens switch via :target (no JS)
|
|
||||||
- SSO is a config section toggled by a class/checkbox; providers
|
|
||||||
are a <ul> you extend by adding one <li class="sso-item">.
|
|
||||||
============================================================= */
|
|
||||||
|
|
||||||
.auth-stage {
|
.auth-stage {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
display: grid; place-items: center;
|
display: grid; place-items: center;
|
||||||
@@ -34,13 +26,6 @@
|
|||||||
.landing-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 4px; }
|
.landing-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 4px; }
|
||||||
.landing-actions .btn { height: 40px; padding: 0 20px; font-size: var(--fz); }
|
.landing-actions .btn { height: 40px; padding: 0 20px; font-size: var(--fz); }
|
||||||
|
|
||||||
/* ---- screen switching via :target (login is the default) ---- */
|
|
||||||
.auth-view { display: none; }
|
|
||||||
#login { display: block; }
|
|
||||||
#register:target, #forgot:target { display: block; }
|
|
||||||
body:has(#register:target) #login,
|
|
||||||
body:has(#forgot:target) #login { display: none; }
|
|
||||||
|
|
||||||
/* card */
|
/* card */
|
||||||
.auth-card {
|
.auth-card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -123,8 +108,6 @@ body:has(#forgot:target) #login { display: none; }
|
|||||||
|
|
||||||
.field-hint { font-size: var(--fz-xs); color: var(--text-faint); }
|
.field-hint { font-size: var(--fz-xs); color: var(--text-faint); }
|
||||||
|
|
||||||
.remember { margin-top: 2px; }
|
|
||||||
|
|
||||||
/* full-width primary button (extends .btn / .btn-primary from styles.css) */
|
/* full-width primary button (extends .btn / .btn-primary from styles.css) */
|
||||||
.btn-block { width: 100%; justify-content: center; height: 40px; font-size: var(--fz); }
|
.btn-block { width: 100%; justify-content: center; height: 40px; font-size: var(--fz); }
|
||||||
|
|
||||||
@@ -138,74 +121,6 @@ body:has(#forgot:target) #login { display: none; }
|
|||||||
.auth-aside a { color: var(--accent); }
|
.auth-aside a { color: var(--accent); }
|
||||||
.auth-aside a:hover { text-decoration: underline; }
|
.auth-aside a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* legal footnote */
|
|
||||||
.auth-foot { margin: 0; text-align: center; font-size: var(--fz-xs); color: var(--text-faint); }
|
|
||||||
.auth-foot a { color: var(--text-muted); text-decoration: underline; }
|
|
||||||
|
|
||||||
/* =============================================================
|
|
||||||
Template preview controls (delete this block + its markup for
|
|
||||||
production). Drives theme + SSO on/off with zero JS.
|
|
||||||
============================================================= */
|
|
||||||
.tpl-controls {
|
|
||||||
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
|
|
||||||
z-index: 50;
|
|
||||||
display: flex; align-items: center; gap: 12px;
|
|
||||||
padding: 6px 8px 6px 14px;
|
|
||||||
background: var(--surface); border: 1px solid var(--border);
|
|
||||||
border-radius: 99px; box-shadow: 0 6px 22px rgba(0,0,0,.12);
|
|
||||||
}
|
|
||||||
.tpl-label { font-size: var(--fz-xs); color: var(--text-faint);
|
|
||||||
text-transform: uppercase; letter-spacing: .05em; font-weight: 600; }
|
|
||||||
.tpl-sep { width: 1px; height: 20px; background: var(--border); }
|
|
||||||
.tpl-controls .theme-switch { width: 156px; }
|
|
||||||
|
|
||||||
/* on/off switch */
|
|
||||||
.tpl-toggle { display: inline-flex; align-items: center; gap: 8px;
|
|
||||||
font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer; }
|
|
||||||
.tpl-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
|
|
||||||
.tpl-track { width: 32px; height: 18px; border-radius: 99px;
|
|
||||||
background: var(--border-2); position: relative; transition: background .15s ease; }
|
|
||||||
.tpl-track::after { content: ""; position: absolute; top: 2px; left: 2px;
|
|
||||||
width: 14px; height: 14px; border-radius: 50%; background: #fff;
|
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: transform .15s ease; }
|
|
||||||
.tpl-toggle:has(input:checked) .tpl-track { background: var(--accent); }
|
|
||||||
.tpl-toggle:has(input:checked) .tpl-track::after { transform: translateX(14px); }
|
|
||||||
.tpl-toggle:has(input:focus-visible) .tpl-track {
|
|
||||||
outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
|
||||||
.tpl-label { display: none; }
|
|
||||||
.tpl-controls { gap: 10px; padding: 6px 10px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =============================================================
|
|
||||||
Forgot-password feedback (Post/Redirect/Get, no JS).
|
|
||||||
The form POSTs to its own URL; on the redirect-GET the server
|
|
||||||
re-renders #forgot with a state class so the matching alert shows:
|
|
||||||
<section id="forgot" class="auth-view state-sent"> … </section>
|
|
||||||
(state-sent | state-error). No extra view is added.
|
|
||||||
The preview radios below demonstrate the same states live.
|
|
||||||
============================================================= */
|
|
||||||
.forgot-alert { display: none; }
|
|
||||||
#forgot.state-sent .forgot-alert.is-sent,
|
|
||||||
body:has(#fstate-sent:checked) #forgot .forgot-alert.is-sent { display: flex; }
|
|
||||||
#forgot.state-error .forgot-alert.is-error,
|
|
||||||
body:has(#fstate-error:checked) #forgot .forgot-alert.is-error { display: flex; }
|
|
||||||
|
|
||||||
/* the Forgot-state control only appears while the forgot view is open */
|
|
||||||
.tpl-forgot { display: none; }
|
|
||||||
body:has(#forgot:target) .tpl-forgot { display: flex; }
|
|
||||||
.tpl-controls .segmented { padding: 1px; }
|
|
||||||
.tpl-controls .segmented label { padding: 3px 8px; }
|
|
||||||
|
|
||||||
/* =============================================================
|
|
||||||
Field-level errors (e.g. "email already used"), server-driven.
|
|
||||||
The server re-renders the field's form with a state class and
|
|
||||||
adds aria-invalid="true" to the input:
|
|
||||||
<section id="register" class="auth-view state-email-taken"> … </section>
|
|
||||||
Reusable: drop a <p class="field-error"> under any field and gate
|
|
||||||
it on whatever state your server sets.
|
|
||||||
============================================================= */
|
|
||||||
.field-error {
|
.field-error {
|
||||||
display: none; align-items: center; gap: 6px; margin: 0;
|
display: none; align-items: center; gap: 6px; margin: 0;
|
||||||
font-size: var(--fz-xs); color: var(--neg);
|
font-size: var(--fz-xs); color: var(--neg);
|
||||||
@@ -213,36 +128,3 @@ body:has(#forgot:target) .tpl-forgot { display: flex; }
|
|||||||
.field.has-error .field-error { display: flex; } /* server-rendered field shows its own error */
|
.field.has-error .field-error { display: flex; } /* server-rendered field shows its own error */
|
||||||
.field-error > .ico { flex: 0 0 auto; color: var(--neg); }
|
.field-error > .ico { flex: 0 0 auto; color: var(--neg); }
|
||||||
.field-error a { color: var(--neg); font-weight: 600; text-decoration: underline; }
|
.field-error a { color: var(--neg); font-weight: 600; text-decoration: underline; }
|
||||||
|
|
||||||
/* register form-level error banner (pairs with field-level errors) */
|
|
||||||
.register-alert { display: none; }
|
|
||||||
|
|
||||||
/* --- state A: email already taken — field-level error only --- */
|
|
||||||
#register.state-email-taken .err-email,
|
|
||||||
body:has(#rstate-taken:checked) #register .err-email { display: flex; }
|
|
||||||
#register.state-email-taken #reg-email,
|
|
||||||
body:has(#rstate-taken:checked) #register #reg-email { border-color: var(--neg); }
|
|
||||||
|
|
||||||
/* --- state B: multiple errors — banner + several field errors together --- */
|
|
||||||
#register.state-invalid .register-alert,
|
|
||||||
body:has(#rstate-combined:checked) #register .register-alert { display: flex; }
|
|
||||||
#register.state-invalid .err-email,
|
|
||||||
#register.state-invalid .err-password,
|
|
||||||
body:has(#rstate-combined:checked) #register .err-email,
|
|
||||||
body:has(#rstate-combined:checked) #register .err-password { display: flex; }
|
|
||||||
#register.state-invalid #reg-email,
|
|
||||||
#register.state-invalid #reg-password,
|
|
||||||
body:has(#rstate-combined:checked) #register #reg-email,
|
|
||||||
body:has(#rstate-combined:checked) #register #reg-password { border-color: var(--neg); }
|
|
||||||
|
|
||||||
/* red focus ring on any invalid register input */
|
|
||||||
#register.state-email-taken #reg-email:focus,
|
|
||||||
#register.state-invalid #reg-email:focus,
|
|
||||||
#register.state-invalid #reg-password:focus,
|
|
||||||
body:has(#rstate-taken:checked) #register #reg-email:focus,
|
|
||||||
body:has(#rstate-combined:checked) #register #reg-email:focus,
|
|
||||||
body:has(#rstate-combined:checked) #register #reg-password:focus { box-shadow: 0 0 0 2px var(--neg-bg); }
|
|
||||||
|
|
||||||
/* the Register-state control only appears while the register view is open */
|
|
||||||
.tpl-register { display: none; }
|
|
||||||
body:has(#register:target) .tpl-register { display: flex; }
|
|
||||||
|
|||||||
+58
-40
@@ -2,8 +2,6 @@
|
|||||||
App Shell Template — pure HTML/CSS, no JavaScript
|
App Shell Template — pure HTML/CSS, no JavaScript
|
||||||
-------------------------------------------------------------
|
-------------------------------------------------------------
|
||||||
Architecture
|
Architecture
|
||||||
- Design tokens via light-dark() so theme follows system by
|
|
||||||
default and can be forced Light / Dark via radio + :has().
|
|
||||||
- Everything is a reusable "block": .nav-item, .filter,
|
- Everything is a reusable "block": .nav-item, .filter,
|
||||||
.chip, .badge, .btn, .menu, .table — combine freely.
|
.chip, .badge, .btn, .menu, .table — combine freely.
|
||||||
============================================================= */
|
============================================================= */
|
||||||
@@ -11,43 +9,48 @@
|
|||||||
/* ---------- 1. TOKENS ----------------------------------------
|
/* ---------- 1. TOKENS ----------------------------------------
|
||||||
Two raw palettes (--l-* light, --d-* dark) are defined once.
|
Two raw palettes (--l-* light, --d-* dark) are defined once.
|
||||||
A small mapping (--bg: var(--l-bg) ...) picks the active set.
|
A small mapping (--bg: var(--l-bg) ...) picks the active set.
|
||||||
Theme resolution (no JS):
|
Theme resolution (no JS); all three rules below need :has():
|
||||||
• default + @media(dark) → follows the OS (Auto)
|
• default + @media(dark) → follows the OS (Auto)
|
||||||
• html:has(#theme-light) → force light
|
• html:has(#theme-light) → force light
|
||||||
• html:has(#theme-dark) → force dark
|
• html:has(#theme-dark) → force dark
|
||||||
|
Engines without :has() (Chromium below 105, Firefox below 121,
|
||||||
|
Safari below 15.4) ignore all three and stay light; see the @supports not
|
||||||
|
selector(:has(a)) block further down.
|
||||||
We avoid light-dark()/color-scheme for app colors because their
|
We avoid light-dark()/color-scheme for app colors because their
|
||||||
resolution is unreliable across engines; :has() variable swaps
|
resolution is unreliable across engines. color-scheme is still
|
||||||
are rock-solid. color-scheme is still set as a hint for native
|
set as a hint for native widgets (date picker, scrollbars,
|
||||||
widgets (date picker, scrollbars, selects).
|
selects).
|
||||||
------------------------------------------------------------- */
|
------------------------------------------------------------- */
|
||||||
:root {
|
:root {
|
||||||
|
/* Hex, never oklch: below Chromium 111 `var(--accent)` is invalid at computed-value time and the
|
||||||
|
primary button loses its background. A fallback declaration cannot catch that. */
|
||||||
/* ---- raw LIGHT palette ---- */
|
/* ---- raw LIGHT palette ---- */
|
||||||
--l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc;
|
--l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc;
|
||||||
--l-border:#e5e5e8; --l-border-2:#d6d6da;
|
--l-border:#e5e5e8; --l-border-2:#d6d6da;
|
||||||
--l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2;
|
--l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2;
|
||||||
--l-accent:oklch(0.52 0.12 256); --l-accent-bg:oklch(0.96 0.03 256);
|
--l-accent:#3669ac; --l-accent-bg:#e5f3ff;
|
||||||
--l-accent-bd:oklch(0.82 0.07 256); --l-focus:oklch(0.52 0.14 256);
|
--l-accent-bd:#a7c7f2; --l-focus:#2a68b7; --l-on-accent:#ffffff;
|
||||||
--l-pos:oklch(0.54 0.13 150); --l-pos-bg:oklch(0.95 0.03 150); --l-pos-bd:oklch(0.83 0.07 150);
|
--l-pos:#1f7a3d; --l-pos-bg:#e1f5e4; --l-pos-bd:#a8d5b0;
|
||||||
--l-neg:oklch(0.55 0.18 25); --l-neg-bg:oklch(0.96 0.03 25); --l-neg-bd:oklch(0.85 0.08 25);
|
--l-neg:#c53637; --l-neg-bg:#ffebe8; --l-neg-bd:#febab4;
|
||||||
--l-warn:oklch(0.55 0.12 75); --l-warn-bg:oklch(0.96 0.04 85); --l-warn-bd:oklch(0.84 0.08 85);
|
--l-warn:#8f5d00; --l-warn-bg:#fef0d4; --l-warn-bd:#e3c78e;
|
||||||
--l-info:oklch(0.54 0.12 245);--l-info-bg:oklch(0.96 0.03 245);--l-info-bd:oklch(0.83 0.07 245);
|
--l-info:#1d6aa5;--l-info-bg:#e2f5ff;--l-info-bd:#a2cdf2;
|
||||||
|
|
||||||
/* ---- raw DARK palette ---- */
|
/* ---- raw DARK palette ---- */
|
||||||
--d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e;
|
--d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e;
|
||||||
--d-border:#2a2a31; --d-border-2:#3a3a43;
|
--d-border:#2a2a31; --d-border-2:#3a3a43;
|
||||||
--d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74;
|
--d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74;
|
||||||
--d-accent:oklch(0.70 0.12 256); --d-accent-bg:oklch(0.32 0.06 256);
|
--d-accent:#6ba0e8; --d-accent-bg:#1d3351;
|
||||||
--d-accent-bd:oklch(0.48 0.08 256); --d-focus:oklch(0.74 0.13 256);
|
--d-accent-bd:#3f5f8a; --d-focus:#72adfb; --d-on-accent:#0d0d0f;
|
||||||
--d-pos:oklch(0.74 0.14 150); --d-pos-bg:oklch(0.30 0.05 150); --d-pos-bd:oklch(0.45 0.07 150);
|
--d-pos:#62c37a; --d-pos-bg:#1a3520; --d-pos-bd:#376040;
|
||||||
--d-neg:oklch(0.74 0.16 25); --d-neg-bg:oklch(0.30 0.07 25); --d-neg-bd:oklch(0.47 0.10 25);
|
--d-neg:#ff7e76; --d-neg-bg:#4b1d1b; --d-neg-bd:#8a423d;
|
||||||
--d-warn:oklch(0.80 0.13 85); --d-warn-bg:oklch(0.31 0.05 80); --d-warn-bd:oklch(0.48 0.07 80);
|
--d-warn:#e4b750; --d-warn-bg:#3e2d10; --d-warn-bd:#72592c;
|
||||||
--d-info:oklch(0.74 0.12 245);--d-info-bg:oklch(0.31 0.06 245);--d-info-bd:oklch(0.47 0.08 245);
|
--d-info:#65b2f1;--d-info-bg:#12334c;--d-info-bd:#305f85;
|
||||||
|
|
||||||
/* ---- active mapping: default = LIGHT ---- */
|
/* ---- active mapping: default = LIGHT ---- */
|
||||||
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
|
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
|
||||||
--border:var(--l-border); --border-2:var(--l-border-2);
|
--border:var(--l-border); --border-2:var(--l-border-2);
|
||||||
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
|
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
|
||||||
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus);
|
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent);
|
||||||
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
|
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
|
||||||
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
|
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
|
||||||
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
|
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
|
||||||
@@ -58,7 +61,7 @@
|
|||||||
--radius: 5px;
|
--radius: 5px;
|
||||||
--nav-w: 264px;
|
--nav-w: 264px;
|
||||||
--row-h: 34px;
|
--row-h: 34px;
|
||||||
--pad-x: 12px;
|
--pad-x: 12px; /* side padding of the content pane's bars and form page, and of every table cell, so their text lines up */
|
||||||
--fz: 13px;
|
--fz: 13px;
|
||||||
--fz-sm: 12px;
|
--fz-sm: 12px;
|
||||||
--fz-xs: 11px;
|
--fz-xs: 11px;
|
||||||
@@ -74,7 +77,7 @@
|
|||||||
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
|
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
|
||||||
--border:var(--d-border); --border-2:var(--d-border-2);
|
--border:var(--d-border); --border-2:var(--d-border-2);
|
||||||
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
|
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
|
||||||
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus);
|
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent);
|
||||||
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
|
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
|
||||||
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
|
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
|
||||||
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
|
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
|
||||||
@@ -88,7 +91,7 @@ html:has(#theme-dark:checked) {
|
|||||||
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
|
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
|
||||||
--border:var(--d-border); --border-2:var(--d-border-2);
|
--border:var(--d-border); --border-2:var(--d-border-2);
|
||||||
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
|
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
|
||||||
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus);
|
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent);
|
||||||
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
|
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
|
||||||
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
|
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
|
||||||
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
|
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
|
||||||
@@ -101,7 +104,7 @@ html:has(#theme-light:checked) {
|
|||||||
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
|
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
|
||||||
--border:var(--l-border); --border-2:var(--l-border-2);
|
--border:var(--l-border); --border-2:var(--l-border-2);
|
||||||
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
|
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
|
||||||
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus);
|
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent);
|
||||||
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
|
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
|
||||||
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
|
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
|
||||||
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
|
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
|
||||||
@@ -169,9 +172,9 @@ summary { list-style: none; cursor: pointer; }
|
|||||||
.brand-mark {
|
.brand-mark {
|
||||||
width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto;
|
width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
display: grid; place-items: center; color: #fff;
|
display: grid; place-items: center; color: var(--on-accent);
|
||||||
}
|
}
|
||||||
.brand-mark .ico { stroke: #fff; }
|
.brand-mark .ico { stroke: var(--on-accent); }
|
||||||
.brand-logo { width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; object-fit: contain; }
|
.brand-logo { width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; object-fit: contain; }
|
||||||
.brand-name { font-weight: 650; letter-spacing: -.01em; }
|
.brand-name { font-weight: 650; letter-spacing: -.01em; }
|
||||||
.brand-sub { color: var(--text-faint); font-size: var(--fz-xs); margin-left: auto; }
|
.brand-sub { color: var(--text-faint); font-size: var(--fz-xs); margin-left: auto; }
|
||||||
@@ -189,9 +192,10 @@ summary { list-style: none; cursor: pointer; }
|
|||||||
• CLICKABLE = label is <a href> → navigates on click.
|
• CLICKABLE = label is <a href> → navigates on click.
|
||||||
STATIC = label is <span> → not a navigation target.
|
STATIC = label is <span> → not a navigation target.
|
||||||
So "header" and "clickable" are independent: any combination works.
|
So "header" and "clickable" are independent: any combination works.
|
||||||
Children sit in a sibling <ul class="nav-children">, revealed by
|
Children sit in a sibling <ul class="nav-children">. Where :has() works, a
|
||||||
:has() on THIS row's own <details open> (scoped with > so a node
|
branch is folded unless THIS row's own <details open> (scoped with > so a node
|
||||||
only ever controls its own subtree — infinite depth, no JS).
|
only ever controls its own subtree — infinite depth, no JS). Without :has()
|
||||||
|
every branch stays open, since nothing could reopen one.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.nav-row { display: flex; align-items: center; gap: 2px; }
|
.nav-row { display: flex; align-items: center; gap: 2px; }
|
||||||
|
|
||||||
@@ -235,8 +239,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.nav-self[aria-current="page"] .ico { color: var(--accent); }
|
.nav-self[aria-current="page"] .ico { color: var(--accent); }
|
||||||
|
|
||||||
/* children + infinite indentation with a guide line per level */
|
/* children + infinite indentation with a guide line per level */
|
||||||
.nav-node > .nav-children { display: none; }
|
@supports selector(:has(a)) {
|
||||||
.nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; }
|
.nav-node > .nav-children { display: none; }
|
||||||
|
.nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; }
|
||||||
|
}
|
||||||
.nav-children { padding-left: 22px; position: relative; }
|
.nav-children { padding-left: 22px; position: relative; }
|
||||||
.nav-children::before {
|
.nav-children::before {
|
||||||
content: ""; position: absolute; left: 10px; top: 2px; bottom: 2px;
|
content: ""; position: absolute; left: 10px; top: 2px; bottom: 2px;
|
||||||
@@ -279,6 +285,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
outline: 2px solid var(--focus); outline-offset: 1px;
|
outline: 2px solid var(--focus); outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Without :has() every branch is open and every theme rule is inert: neither control does anything.
|
||||||
|
After the base rules, since @supports adds no specificity. */
|
||||||
|
@supports not selector(:has(a)) {
|
||||||
|
.nav-disc { visibility: hidden; } /* keeps its width, so the labels stay aligned */
|
||||||
|
.theme-switch { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.footer-actions { display: flex; align-items: center; gap: 4px; }
|
.footer-actions { display: flex; align-items: center; gap: 4px; }
|
||||||
.profile {
|
.profile {
|
||||||
display: flex; align-items: center; gap: 9px; flex: 1 1 auto;
|
display: flex; align-items: center; gap: 9px; flex: 1 1 auto;
|
||||||
@@ -309,7 +322,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
}
|
}
|
||||||
.btn:hover { background: var(--surface-2); }
|
.btn:hover { background: var(--surface-2); }
|
||||||
.btn-primary { background: var(--accent); border-color: var(--accent);
|
.btn-primary { background: var(--accent); border-color: var(--accent);
|
||||||
color: #fff; font-weight: 550; }
|
color: var(--on-accent); font-weight: 550; }
|
||||||
.btn-primary:hover { filter: brightness(1.06); }
|
.btn-primary:hover { filter: brightness(1.06); }
|
||||||
.btn-ghost { background: transparent; border-color: transparent; }
|
.btn-ghost { background: transparent; border-color: transparent; }
|
||||||
.btn-ghost:hover { background: var(--surface-2); }
|
.btn-ghost:hover { background: var(--surface-2); }
|
||||||
@@ -336,7 +349,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.topbar {
|
.topbar {
|
||||||
flex: 0 0 auto; height: 48px;
|
flex: 0 0 auto; height: 48px;
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
padding: 0 16px; border-bottom: 1px solid var(--border);
|
padding: 0 var(--pad-x); border-bottom: 1px solid var(--border);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.hamburger { display: none; } /* shown only on narrow */
|
.hamburger { display: none; } /* shown only on narrow */
|
||||||
@@ -352,7 +365,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.filters {
|
.filters {
|
||||||
flex: 0 0 auto; background: var(--surface);
|
flex: 0 0 auto; background: var(--surface);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
padding: 10px 16px;
|
padding: 10px var(--pad-x);
|
||||||
display: flex; flex-direction: column; gap: 9px;
|
display: flex; flex-direction: column; gap: 9px;
|
||||||
}
|
}
|
||||||
.filter-row {
|
.filter-row {
|
||||||
@@ -584,7 +597,7 @@ table.table {
|
|||||||
.col-num { text-align: right; }
|
.col-num { text-align: right; }
|
||||||
.cell-strong { font-weight: 550; }
|
.cell-strong { font-weight: 550; }
|
||||||
.cell-muted { color: var(--text-muted); }
|
.cell-muted { color: var(--text-muted); }
|
||||||
.table-empty { text-align: center; color: var(--text-muted); padding: 28px 12px; }
|
.table-empty { text-align: center; color: var(--text-muted); padding: 28px var(--pad-x); }
|
||||||
.cell-mono { font-variant-numeric: tabular-nums;
|
.cell-mono { font-variant-numeric: tabular-nums;
|
||||||
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); }
|
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); }
|
||||||
|
|
||||||
@@ -648,7 +661,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
.pager {
|
.pager {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
padding: 8px 16px; border-top: 1px solid var(--border);
|
padding: 8px var(--pad-x); border-top: 1px solid var(--border);
|
||||||
background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted);
|
background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.pager .spacer { flex: 1 1 auto; }
|
.pager .spacer { flex: 1 1 auto; }
|
||||||
@@ -663,20 +676,25 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
}
|
}
|
||||||
.page-btn:hover { background: var(--surface-2); color: var(--text); }
|
.page-btn:hover { background: var(--surface-2); color: var(--text); }
|
||||||
.page-btn[aria-current="page"] { background: var(--accent); border-color: var(--accent);
|
.page-btn[aria-current="page"] { background: var(--accent); border-color: var(--accent);
|
||||||
color: #fff; font-weight: 600; }
|
color: var(--on-accent); font-weight: 600; }
|
||||||
.page-btn:disabled { opacity: .4; cursor: not-allowed; }
|
.page-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
|
||||||
/* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */
|
/* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */
|
||||||
.scrim { display: none; }
|
.scrim { display: none; }
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.app { grid-template-columns: minmax(0, 1fr); }
|
.app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr; }
|
||||||
|
.content { grid-column: 1; grid-row: 2; }
|
||||||
|
.search { min-width: 150px; flex: 1 1 auto; }
|
||||||
|
|
||||||
|
/* Off-canvas only where :has() works: without it nothing can read #nav-toggle, so the sidebar
|
||||||
|
stays in the flow above the content and the hamburger stays hidden. */
|
||||||
|
@supports selector(:has(a)) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed; inset: 0 auto 0 0; width: min(86vw, var(--nav-w));
|
position: fixed; inset: 0 auto 0 0; width: min(86vw, var(--nav-w));
|
||||||
z-index: 60; transform: translateX(-100%);
|
z-index: 60; transform: translateX(-100%);
|
||||||
box-shadow: 2px 0 24px rgba(0,0,0,.18);
|
box-shadow: 2px 0 24px rgba(0,0,0,.18);
|
||||||
}
|
}
|
||||||
.content { grid-column: 1; }
|
|
||||||
.hamburger { display: inline-flex; }
|
.hamburger { display: inline-flex; }
|
||||||
|
|
||||||
/* checkbox-driven open state */
|
/* checkbox-driven open state */
|
||||||
@@ -685,7 +703,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
display: block; position: fixed; inset: 0; z-index: 50;
|
display: block; position: fixed; inset: 0; z-index: 50;
|
||||||
background: rgba(0,0,0,.42);
|
background: rgba(0,0,0,.42);
|
||||||
}
|
}
|
||||||
.search { min-width: 150px; flex: 1 1 auto; }
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.crumbs, .page-sub { display: none; }
|
.crumbs, .page-sub { display: none; }
|
||||||
@@ -696,7 +714,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
#nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
|
#nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
/* admin forms: 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 var(--pad-x); 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;
|
||||||
padding: 18px; background: var(--surface);
|
padding: 18px; background: var(--surface);
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ services:
|
|||||||
|
|
||||||
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:v1.32.0
|
image: axllent/mailpit:v1.33.0
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+45
-36
@@ -38,52 +38,36 @@ const FORBIDDEN: RouteResult = { status: 403, view: "403" };
|
|||||||
const selfOrigin = (ctx: RequestContext, secure: boolean): string =>
|
const selfOrigin = (ctx: RequestContext, secure: boolean): string =>
|
||||||
`${secure ? "https" : "http"}://${ctx.req.headers.host ?? "127.0.0.1:3000"}`;
|
`${secure ? "https" : "http"}://${ctx.req.headers.host ?? "127.0.0.1:3000"}`;
|
||||||
|
|
||||||
// Themed Kratos self-service page (one route per AUTH_FLOWS entry: login/registration/recovery/
|
// Where Kratos lands the browser after sign-in, or undefined for its default. A host-relative
|
||||||
// verification/settings).
|
// return_to (the gate's deep link) or a chosen language goes through /auth/complete, which mints the
|
||||||
function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] {
|
// session JWT first and carries the language across Kratos; any other target (the OAuth2 login
|
||||||
return async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult> => {
|
// challenge) is passed as-is, since Kratos allow-lists it.
|
||||||
const pathname = ctx.url.pathname;
|
function flowReturnTo(ctx: RequestContext, secureCookies: boolean): string | undefined {
|
||||||
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app
|
|
||||||
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
|
|
||||||
if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
|
|
||||||
const cookie = ctx.req.headers.cookie;
|
|
||||||
const flowId = ctx.url.searchParams.get("flow");
|
|
||||||
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
|
|
||||||
// the catch-all 500 (with a stack), not the "Ory unreachable" 503.
|
|
||||||
let flow: Flow;
|
|
||||||
try {
|
|
||||||
if (!flowId) {
|
|
||||||
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
|
|
||||||
// A `return_to` is baked into the flow so Kratos lands there after login instead of the
|
|
||||||
// default completion route. A first-party deep link (host-relative, from the gate's
|
|
||||||
// return_to) is wrapped through /auth/complete so the session JWT is minted before the
|
|
||||||
// user reaches the page; an absolute target (the OAuth2 login challenge) is passed
|
|
||||||
// as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com".
|
|
||||||
const raw = ctx.url.searchParams.get("return_to");
|
const raw = ctx.url.searchParams.get("return_to");
|
||||||
const local = localPath(raw);
|
const local = localPath(raw);
|
||||||
const chosen = chosenLocale(ctx);
|
const chosen = chosenLocale(ctx);
|
||||||
let returnTo: string | undefined;
|
if (raw && !local) return raw; // beside a chosen language too: the target wins, the language is lost
|
||||||
if (local || chosen) {
|
if (!local && !chosen) return undefined;
|
||||||
// The flow's return target is the host's, not Kratos' — so the language the visitor picked
|
|
||||||
// on the sign-in page survives the round-trip through Kratos and lands on the page after
|
|
||||||
// it. Without this the most-travelled path in the product (pick Swedish → sign in) drops
|
|
||||||
// straight back to Accept-Language.
|
|
||||||
const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`);
|
const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`);
|
||||||
if (local) complete.searchParams.set("return_to", ctx.localeHref(local));
|
if (local) complete.searchParams.set("return_to", ctx.localeHref(local));
|
||||||
if (chosen) complete.searchParams.set("locale", chosen);
|
if (chosen) complete.searchParams.set("locale", chosen);
|
||||||
returnTo = complete.toString();
|
return complete.toString();
|
||||||
} else if (raw) returnTo = raw;
|
}
|
||||||
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
|
|
||||||
if (setCookie.length) ctx.res.appendHeader("set-cookie", setCookie);
|
// What a failed Kratos call returns instead of the flow; rethrows what is genuinely unexpected.
|
||||||
return { redirect: `${pathname}?flow=${initiated.id}` };
|
function flowFailure(err: unknown, ctx: RequestContext, flowType: FlowType): RouteResult {
|
||||||
}
|
const pathname = ctx.url.pathname;
|
||||||
flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {});
|
|
||||||
} catch (err) {
|
|
||||||
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
|
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
|
||||||
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname };
|
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname };
|
||||||
|
// No Kratos session on a page that needs one. /settings is public, since recovery lands there with
|
||||||
|
// a Kratos session but no app JWT, so the gate never sends an anonymous visitor to sign in.
|
||||||
|
// Not for /login itself, which would redirect to itself.
|
||||||
|
if (err instanceof KratosError && err.status === 401 && flowType !== "login") {
|
||||||
|
return { redirect: `/login?return_to=${encodeURIComponent(pathname)}` };
|
||||||
|
}
|
||||||
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
|
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
|
||||||
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
|
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
|
||||||
// is null and the "already signed in" short-circuit above can't fire). Initialising a login/
|
// is null and the "already signed in" short-circuit can't fire). Initialising a login/
|
||||||
// registration flow then returns Kratos 400 `session_already_available`. Recover by completing
|
// registration flow then returns Kratos 400 `session_already_available`. Recover by completing
|
||||||
// login (mint the JWT from the live session), honouring return_to — never a 500.
|
// login (mint the JWT from the live session), honouring return_to — never a 500.
|
||||||
if (err instanceof KratosError && err.status === 400 && err.body.includes("session_already_available")) {
|
if (err instanceof KratosError && err.status === 400 && err.body.includes("session_already_available")) {
|
||||||
@@ -97,6 +81,31 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
|
|||||||
return { status: 503, view: "503" };
|
return { status: 503, view: "503" };
|
||||||
}
|
}
|
||||||
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
|
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Themed Kratos self-service page (one route per AUTH_FLOWS entry: login/registration/recovery/
|
||||||
|
// verification/settings).
|
||||||
|
function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] {
|
||||||
|
return async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult> => {
|
||||||
|
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app
|
||||||
|
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
|
||||||
|
if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
|
||||||
|
const cookie = ctx.req.headers.cookie;
|
||||||
|
const flowId = ctx.url.searchParams.get("flow");
|
||||||
|
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
|
||||||
|
// the catch-all 500 (with a stack), not the "Ory unreachable" 503.
|
||||||
|
let flow: Flow;
|
||||||
|
try {
|
||||||
|
if (!flowId) {
|
||||||
|
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
|
||||||
|
const returnTo = flowReturnTo(ctx, secureCookies);
|
||||||
|
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
|
||||||
|
if (setCookie.length) ctx.res.appendHeader("set-cookie", setCookie);
|
||||||
|
return { redirect: `${ctx.url.pathname}?flow=${initiated.id}` };
|
||||||
|
}
|
||||||
|
flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {});
|
||||||
|
} catch (err) {
|
||||||
|
return flowFailure(err, ctx, flowType);
|
||||||
}
|
}
|
||||||
// Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
|
// Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
|
||||||
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
|
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
|
||||||
|
|||||||
+7
-2
@@ -29,14 +29,19 @@ test("the commit-hash image is pushed even when the gate no-ops", () => {
|
|||||||
assert.doesNotMatch(step("docker push"), /^\s*if:/m);
|
assert.doesNotMatch(step("docker push"), /^\s*if:/m);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("every E2E suite the gate runs writes its artifacts as the invoking user", () => {
|
test("every E2E runner container the gate starts runs as the invoking user", () => {
|
||||||
// The documented hand-run commands carry the same flag (src/compose.test.ts).
|
// The documented hand-run commands carry the same flag (src/compose.test.ts).
|
||||||
const runs = gate.split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
|
const runs = gate.split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
|
||||||
assert.equal(runs.length, 2, "the suite helper and the devstack run");
|
assert.equal(runs.length, 3, "the e2e-tests typecheck, the suite helper and the devstack run");
|
||||||
for (const line of runs)
|
for (const line of runs)
|
||||||
assert.match(line, /--user "\$\(id -u\):\$\(id -g\)"/, `runs as the caller: ${line.trim()}`);
|
assert.match(line, /--user "\$\(id -u\):\$\(id -g\)"/, `runs as the caller: ${line.trim()}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the gate typechecks e2e-tests in the runner image, where @playwright/test resolves", () => {
|
||||||
|
assert.match(gate, /docker compose .*\brun\b.*--no-deps\b.*\be2e\b npm run typecheck/);
|
||||||
|
assert.match(read("e2e-tests/package.json"), /"typecheck": "tsc --noEmit"/);
|
||||||
|
});
|
||||||
|
|
||||||
test("only *.md counts as docs; a dirty tree and a rename both count as changed", () => {
|
test("only *.md counts as docs; a dirty tree and a rename both count as changed", () => {
|
||||||
assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test");
|
assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test");
|
||||||
assert.match(gate, /git status --porcelain --no-renames/, "uncommitted code and a staged rename can never be skipped over");
|
assert.match(gate, /git status --porcelain --no-renames/, "uncommitted code and a staged rename can never be skipped over");
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ test("the E2E runner writes its artifacts as the invoking user, never as root",
|
|||||||
// filter would otherwise leave that command silently unguarded.
|
// filter would otherwise leave that command silently unguarded.
|
||||||
const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)]
|
const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)]
|
||||||
.join("\n").split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
|
.join("\n").split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
|
||||||
assert.equal(documented.length, 6, "5 compose headers + 1 README block");
|
assert.equal(documented.length, 7, "5 compose headers + 2 README lines");
|
||||||
for (const l of documented)
|
for (const l of documented)
|
||||||
assert.match(l, /--user "\$\(id -u\):\$\(id -g\)"/, `passes the uid: ${l.trim()}`);
|
assert.match(l, /--user "\$\(id -u\):\$\(id -g\)"/, `passes the uid: ${l.trim()}`);
|
||||||
// An absent mount source is daemon-created as root, and then that uid can't write it at all.
|
// An absent mount source is daemon-created as root, and then that uid can't write it at all.
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// The limits and the counting are ESLint's defaults: `max-lines-per-function` (blank lines and
|
||||||
|
// comments count, an IIFE is not held to it) and `complexity`. Not ESLint itself: parsing TypeScript
|
||||||
|
// takes typescript-eslint, which needs the compiler API TypeScript 7 no longer ships. So acorn parses
|
||||||
|
// what Node's own type stripping leaves, which is the code Node runs.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { globSync, readFileSync } from "node:fs";
|
||||||
|
import { stripTypeScriptTypes } from "node:module";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { parse } from "acorn";
|
||||||
|
|
||||||
|
const LIMITS = { lines: 50, complexity: 20 };
|
||||||
|
|
||||||
|
// Each function over a limit, at its measurement. A ceiling follows its function down, and a metric
|
||||||
|
// leaves the entry once it fits, so a function that shrank can't grow back.
|
||||||
|
const grandfathered: Record<string, { lines?: number; complexity?: number }> = {
|
||||||
|
"src/http/app.ts createApp": { lines: 318, complexity: 28 },
|
||||||
|
"src/http/app.ts createApp > handleRequest": { lines: 168, complexity: 43 },
|
||||||
|
"src/auth/flow-view.ts buildFlowView": { complexity: 24 },
|
||||||
|
"src/auth/hydra-admin.ts createHydraAdmin": { lines: 87 },
|
||||||
|
"src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 },
|
||||||
|
"src/auth/kratos-public.ts createKratosPublic": { lines: 60 },
|
||||||
|
"src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 },
|
||||||
|
"src/plugin-host/discovery.ts shapeError": { complexity: 23 },
|
||||||
|
"src/config.ts loadConfig": { lines: 52 },
|
||||||
|
"release-tooling/dockerhub-overview.ts main": { lines: 57 },
|
||||||
|
"examples/plugins/admin/admin-users.ts buildUserFormModel": { complexity: 21 },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AstNode {
|
||||||
|
type: string;
|
||||||
|
loc: { start: { line: number }; end: { line: number } };
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Measured {
|
||||||
|
name: string;
|
||||||
|
lines: number;
|
||||||
|
complexity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNode = (value: unknown): value is AstNode =>
|
||||||
|
typeof value === "object" && value !== null && typeof (value as { type?: unknown }).type === "string";
|
||||||
|
|
||||||
|
const FUNCTIONS = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
|
||||||
|
const BRANCHES = new Set([
|
||||||
|
"IfStatement", "ConditionalExpression", "LogicalExpression", "ForStatement", "ForInStatement",
|
||||||
|
"ForOfStatement", "WhileStatement", "DoWhileStatement", "CatchClause", "AssignmentPattern",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function branches(node: AstNode): number {
|
||||||
|
if (BRANCHES.has(node.type)) return 1;
|
||||||
|
if (node.type === "SwitchCase") return node.test ? 1 : 0;
|
||||||
|
if (node.type === "AssignmentExpression") return ["&&=", "||=", "??="].includes(String(node.operator)) ? 1 : 0;
|
||||||
|
if (node.type === "MemberExpression" || node.type === "CallExpression") return node.optional === true ? 1 : 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameOf(fn: AstNode, parent: AstNode | undefined): string {
|
||||||
|
let id = fn.id;
|
||||||
|
if (!id && parent?.type === "VariableDeclarator") id = parent.id;
|
||||||
|
if (!id && (parent?.type === "Property" || parent?.type === "MethodDefinition")) id = parent.key;
|
||||||
|
return isNode(id) && typeof id.name === "string" ? id.name : "anonymous";
|
||||||
|
}
|
||||||
|
|
||||||
|
// A function is named by the path of names from the outermost one, e.g. `createApp > handleRequest`,
|
||||||
|
// so an entry above doesn't depend on line numbers.
|
||||||
|
function measure(source: string): Measured[] {
|
||||||
|
const found: Measured[] = [];
|
||||||
|
const visit = (node: AstNode, parent: AstNode | undefined, enclosing: Measured | undefined): void => {
|
||||||
|
let current = enclosing;
|
||||||
|
if (FUNCTIONS.has(node.type)) {
|
||||||
|
const name = nameOf(node, parent);
|
||||||
|
const iife = parent?.type === "CallExpression" && parent.callee === node;
|
||||||
|
const lines = iife ? 0 : node.loc.end.line - node.loc.start.line + 1;
|
||||||
|
current = { name: enclosing ? `${enclosing.name} > ${name}` : name, lines, complexity: 1 };
|
||||||
|
found.push(current);
|
||||||
|
} else if (current) {
|
||||||
|
current.complexity += branches(node);
|
||||||
|
}
|
||||||
|
for (const value of Object.values(node)) {
|
||||||
|
for (const child of [value].flat()) if (isNode(child)) visit(child, node, current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const program: unknown = parse(stripTypeScriptTypes(source), { ecmaVersion: "latest", sourceType: "module", locations: true });
|
||||||
|
assert.ok(isNode(program));
|
||||||
|
visit(program, undefined, undefined);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = new URL("..", import.meta.url);
|
||||||
|
// The drop-in mounts hold an operator's code, not this repo's. Tests are left out: each is a flat
|
||||||
|
// script, and a `describe` block wraps many of them.
|
||||||
|
const files = globSync("**/*.ts", {
|
||||||
|
cwd: root,
|
||||||
|
exclude: ["**/node_modules/**", "plugins/**", "config/**", "locales/**", "**/*.d.ts", "**/*.test.ts", "**/*.spec.ts"],
|
||||||
|
});
|
||||||
|
// A list, not a map: two unnamed siblings share a key, and each must be checked.
|
||||||
|
const measured = files.flatMap((file) =>
|
||||||
|
measure(readFileSync(new URL(file, root), "utf8")).map((m) => ({ ...m, key: `${file} ${m.name}` })),
|
||||||
|
);
|
||||||
|
const METRICS = ["lines", "complexity"] as const;
|
||||||
|
|
||||||
|
test("measure counts lines and complexity as ESLint does", () => {
|
||||||
|
const source = [
|
||||||
|
"function outer(a = 1): void {",
|
||||||
|
" if (a && b?.c) {}",
|
||||||
|
" for (const x of y) {}",
|
||||||
|
" for (const k in y) {}",
|
||||||
|
" for (;;) break;",
|
||||||
|
" while (a) a--;",
|
||||||
|
" do {} while (a);",
|
||||||
|
" try {} catch {}",
|
||||||
|
" switch (a) { case 1: break; default: }",
|
||||||
|
" a ||= 2; a &&= 3; a ??= 4;",
|
||||||
|
" b?.();",
|
||||||
|
" y.map(() => {});",
|
||||||
|
" const inner = () => (a ? 1 : 2);",
|
||||||
|
" const o = { prop() {}, key: function () {} };",
|
||||||
|
" (function () { if (a) {} })();",
|
||||||
|
"}",
|
||||||
|
"class K { method() {} }",
|
||||||
|
].join("\n");
|
||||||
|
assert.deepEqual(measure(source), [
|
||||||
|
{ name: "outer", lines: 16, complexity: 16 },
|
||||||
|
{ name: "outer > anonymous", lines: 1, complexity: 1 },
|
||||||
|
{ name: "outer > inner", lines: 1, complexity: 2 },
|
||||||
|
{ name: "outer > prop", lines: 1, complexity: 1 },
|
||||||
|
{ name: "outer > key", lines: 1, complexity: 1 },
|
||||||
|
{ name: "outer > anonymous", lines: 0, complexity: 2 },
|
||||||
|
{ name: "method", lines: 1, complexity: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`every function fits ${LIMITS.lines} lines and complexity ${LIMITS.complexity}, or its grandfathered ceiling`, () => {
|
||||||
|
assert.ok(files.length > 50, "found too few files: is the glob broken?");
|
||||||
|
const over = measured.flatMap((m) =>
|
||||||
|
METRICS.filter((metric) => m[metric] > (grandfathered[m.key]?.[metric] ?? LIMITS[metric])).map(
|
||||||
|
(metric) => `${m.key}: ${metric} ${m[metric]}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.deepEqual(over, [], "split the function; never raise a ceiling");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every grandfathered ceiling equals its function's measurement, which is over the limit", () => {
|
||||||
|
const stale = Object.entries(grandfathered).flatMap(([key, ceiling]) => {
|
||||||
|
const found = measured.filter((m) => m.key === key);
|
||||||
|
if (found.length === 0) return [`${key}: no such function; rename the entry if it moved, else delete it`];
|
||||||
|
return METRICS.flatMap((metric) => {
|
||||||
|
const allowed = ceiling[metric];
|
||||||
|
if (allowed === undefined) return [];
|
||||||
|
const now = Math.max(...found.map((m) => m[metric]));
|
||||||
|
if (now <= LIMITS[metric]) return [`${key}: ${metric} ${now} fits; delete it`];
|
||||||
|
return now < allowed ? [`${key}: ${metric} ${now}; lower the ceiling to it`] : [];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
assert.deepEqual(stale, []);
|
||||||
|
});
|
||||||
+237
-7
@@ -65,6 +65,8 @@ test("the dashboard at /dashboard: the instructional starter in the unified shel
|
|||||||
assert.match(html, /<aside class="sidebar"/);
|
assert.match(html, /<aside class="sidebar"/);
|
||||||
// The default is a short instructional starter, not a mock-data list.
|
// The default is a short instructional starter, not a mock-data list.
|
||||||
assert.match(html, /Starter dashboard/);
|
assert.match(html, /Starter dashboard/);
|
||||||
|
assert.match(html, /<h1 class="page-title">Dashboard<\/h1>/); // the topbar heading …
|
||||||
|
assert.match(html, /<nav class="crumbs"[^>]*>\s*<span>Dashboard<\/span>/); // … and its breadcrumb
|
||||||
assert.match(html, /export default definePlugin/); // shows how to replace it from a plugin
|
assert.match(html, /export default definePlugin/); // shows how to replace it from a plugin
|
||||||
assert.doesNotMatch(html, /<form class="filters"/); // the old mock People list is gone
|
assert.doesNotMatch(html, /<form class="filters"/); // the old mock People list is gone
|
||||||
assert.doesNotMatch(html, /Avery Kline/);
|
assert.doesNotMatch(html, /Avery Kline/);
|
||||||
@@ -284,6 +286,39 @@ test("every response carries the security headers; HSTS follows SECURE_COOKIES",
|
|||||||
assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/);
|
assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Cache-Control: pages and JSON are private, no-store unless the route sets its own; assets are left alone", async (t) => {
|
||||||
|
const NO_STORE = "private, no-store";
|
||||||
|
const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? "";
|
||||||
|
assert.match(csrf, new RegExp(`^${CSRF_COOKIE}=`));
|
||||||
|
|
||||||
|
// The signed-out landing is no-store as well: it and the signed-in page share one URL.
|
||||||
|
assert.equal((await fetch(base + "/")).headers.get("cache-control"), NO_STORE);
|
||||||
|
assert.equal((await fetch(base + "/", { headers: { cookie: csrf } })).headers.get("cache-control"), NO_STORE);
|
||||||
|
assert.equal((await fetch(base + "/", { headers: { cookie: csrf }, method: "HEAD" })).headers.get("cache-control"), NO_STORE);
|
||||||
|
assert.equal((await fetch(base + "/", { headers: { cookie: `${csrf}; ${session()}` } })).headers.get("cache-control"), NO_STORE);
|
||||||
|
|
||||||
|
assert.equal((await fetch(base + "/dashboard", { headers: { cookie: session() } })).headers.get("cache-control"), NO_STORE);
|
||||||
|
assert.equal((await fetch(base + "/dashboard", { redirect: "manual" })).headers.get("cache-control"), NO_STORE); // sign-in bounce
|
||||||
|
assert.equal((await fetch(base + "/nope", { headers: { cookie: csrf } })).headers.get("cache-control"), NO_STORE); // 404
|
||||||
|
assert.equal((await fetch(base + "/public/css/styles.css")).headers.get("cache-control"), null);
|
||||||
|
|
||||||
|
const demo: Plugin = {
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
home: () => ({ html: "<p>plugin home</p>" }),
|
||||||
|
id: "demo",
|
||||||
|
routes: [
|
||||||
|
{ handler: () => ({ html: "open" }), method: "GET", path: "/open", public: true },
|
||||||
|
{ handler: () => ({ headers: { "Cache-Control": "public, max-age=600" }, html: "same" }), method: "GET", path: "/same", public: true },
|
||||||
|
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data", public: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const url = await startApp(t, [demo]);
|
||||||
|
for (const path of ["/demo/open", "/demo/data", "/"]) {
|
||||||
|
assert.equal((await fetch(url + path)).headers.get("cache-control"), NO_STORE, path);
|
||||||
|
}
|
||||||
|
assert.equal((await fetch(url + "/demo/same")).headers.get("cache-control"), "public, max-age=600");
|
||||||
|
});
|
||||||
|
|
||||||
// Production caches compiled templates; rendering must stay correct across repeated requests.
|
// Production caches compiled templates; rendering must stay correct across repeated requests.
|
||||||
test("renders correctly with template caching enabled", async () => {
|
test("renders correctly with template caching enabled", async () => {
|
||||||
const app = createApp({ cache: true });
|
const app = createApp({ cache: true });
|
||||||
@@ -757,6 +792,26 @@ test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via
|
|||||||
assert.equal((await fetch(url2 + "/login", { redirect: "manual" })).status, 500);
|
assert.equal((await fetch(url2 + "/login", { redirect: "manual" })).status, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("themed auth GET: an anonymous hit on /settings (Kratos 401) bounces to sign-in with the page as return_to, never 500", async (t) => {
|
||||||
|
let failure = new KratosError("Kratos init settings flow failed (401)", 401, "");
|
||||||
|
const throwing = async (): Promise<never> => { throw failure; };
|
||||||
|
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(throwing), initBrowserFlow: throwing } });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
const init = await fetch(url + "/settings", { redirect: "manual" });
|
||||||
|
assert.equal(init.status, 303);
|
||||||
|
assert.equal(init.headers.get("location"), "/login?return_to=%2Fsettings");
|
||||||
|
const existing = await fetch(url + "/settings?flow=f1", { redirect: "manual" });
|
||||||
|
assert.equal(existing.headers.get("location"), "/login?return_to=%2Fsettings");
|
||||||
|
// /login redirecting to itself would loop, so a 401 there stays the catch-all 500.
|
||||||
|
assert.equal((await fetch(url + "/login", { redirect: "manual" })).status, 500);
|
||||||
|
// Only a 401 means "sign in": an Ory outage on /settings is still the honest 503.
|
||||||
|
failure = new KratosError("Kratos init settings flow failed (500)", 500, "");
|
||||||
|
assert.equal((await fetch(url + "/settings", { redirect: "manual" })).status, 503);
|
||||||
|
});
|
||||||
|
|
||||||
// return_to: a deep-link login lands back on the requested page. The gate redirects to
|
// return_to: a deep-link login lands back on the requested page. The gate redirects to
|
||||||
// /login?return_to=<host-relative path>; /login bakes that into the Kratos flow so completion
|
// /login?return_to=<host-relative path>; /login bakes that into the Kratos flow so completion
|
||||||
// returns there — but a first-party path must route via /auth/complete first (to mint the JWT).
|
// returns there — but a first-party path must route via /auth/complete first (to mint the JWT).
|
||||||
@@ -785,6 +840,31 @@ test("login return_to: a first-party deep link is wrapped through /auth/complete
|
|||||||
assert.equal(lastReturnTo, "//evil.com");
|
assert.equal(lastReturnTo, "//evil.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("login return_to: a chosen language alone still routes through /auth/complete, so it survives the sign-in", async (t) => {
|
||||||
|
let lastReturnTo: string | undefined;
|
||||||
|
const kratos: KratosPublic = {
|
||||||
|
...mockKratos(async (_t, id) => loginFlow(id)),
|
||||||
|
initBrowserFlow: async (_t: FlowType, opts = {}) => { lastReturnTo = opts.returnTo; return { flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: [] }; },
|
||||||
|
};
|
||||||
|
const app = createApp({ i18n: createI18n(await loadI18n()), kratos });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
lastReturnTo = "unset";
|
||||||
|
await fetch(url + "/login", { redirect: "manual" });
|
||||||
|
assert.equal(lastReturnTo, undefined, "no target and no language: Kratos keeps its default");
|
||||||
|
await fetch(url + "/login?locale=sv-SE", { redirect: "manual" });
|
||||||
|
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?locale=sv-SE$/);
|
||||||
|
await fetch(url + "/login?locale=sv-SE&return_to=" + encodeURIComponent("/admin/users"), { redirect: "manual" });
|
||||||
|
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Flocale%3Dsv-SE&locale=sv-SE$/);
|
||||||
|
|
||||||
|
// An absolute target still reaches Kratos beside a chosen language; the language is lost instead.
|
||||||
|
const abs = "http://localhost/oauth2/login?login_challenge=abc";
|
||||||
|
await fetch(url + "/login?locale=sv-SE&return_to=" + encodeURIComponent(abs), { redirect: "manual" });
|
||||||
|
assert.equal(lastReturnTo, abs);
|
||||||
|
});
|
||||||
|
|
||||||
// "Ory down ⇒ no logins" is documented; the auth path should say so honestly (503), not the
|
// "Ory down ⇒ no logins" is documented; the auth path should say so honestly (503), not the
|
||||||
// generic "error on our end" 500 the catch-all renders.
|
// generic "error on our end" 500 the catch-all renders.
|
||||||
test("auth flow when Ory is unreachable → an honest 503, not the catch-all 500", async (t) => {
|
test("auth flow when Ory is unreachable → an honest 503, not the catch-all 500", async (t) => {
|
||||||
@@ -881,6 +961,8 @@ async function adminHarness(t: TestContext, opts: AppOptions = {}) {
|
|||||||
// What the plugin itself declares — the harness holds every screen's read and write, so a screen
|
// What the plugin itself declares — the harness holds every screen's read and write, so a screen
|
||||||
// test exercises the screen rather than the gate. assertAdminGate covers the refusals.
|
// test exercises the screen rather than the gate. assertAdminGate covers the refusals.
|
||||||
const ADMIN_ALL = (adminManifest.permissions ?? []).map((p) => p.name);
|
const ADMIN_ALL = (adminManifest.permissions ?? []).map((p) => p.name);
|
||||||
|
// The permission form's concurrency token, read off the page the way a browser would post it back.
|
||||||
|
const baselineOf = (html: string): string => /name="baseline" value="([0-9a-f]+)"/.exec(html)?.[1] ?? "";
|
||||||
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
|
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
|
||||||
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
|
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
|
||||||
const anon = await fetch(url + path, { redirect: "manual" });
|
const anon = await fetch(url + path, { redirect: "manual" });
|
||||||
@@ -1241,7 +1323,8 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
|||||||
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
||||||
|
|
||||||
// Give it a permission first, so the delete below has an orphan to avoid leaving behind.
|
// Give it a permission first, so the delete below has an orphan to avoid leaving behind.
|
||||||
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=users%3Aread`);
|
const permissionsBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
||||||
|
await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${permissionsBaseline}&permission=users%3Aread`);
|
||||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng"));
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng"));
|
||||||
|
|
||||||
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||||
@@ -1277,9 +1360,11 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
|
|||||||
}
|
}
|
||||||
assert.match(edit, /value="users:read"[^>]*checked/); // held → ticked
|
assert.match(edit, /value="users:read"[^>]*checked/); // held → ticked
|
||||||
assert.doesNotMatch(edit, /value="groups:write"[^>]*checked/); // not held → unticked
|
assert.doesNotMatch(edit, /value="groups:write"[^>]*checked/); // not held → unticked
|
||||||
|
assert.doesNotMatch(edit, /Permissions saved/); // no confirmation before a save
|
||||||
|
|
||||||
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
|
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
|
||||||
const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
|
const baseline = baselineOf(edit);
|
||||||
|
const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||||
assert.equal(saved.status, 303);
|
assert.equal(saved.status, 303);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
|
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
|
||||||
@@ -1287,16 +1372,160 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
|
|||||||
);
|
);
|
||||||
assert.equal(denylist.isRevoked(ada, 0), true); // a change to your own grants revokes live tokens
|
assert.equal(denylist.isRevoked(ada, 0), true); // a change to your own grants revokes live tokens
|
||||||
|
|
||||||
|
// The redirect lands on a page that confirms the save, and shows the new state.
|
||||||
|
const landing = saved.headers.get("location") ?? "";
|
||||||
|
assert.match(landing, new RegExp(`^/admin/users/${ada}\\?info-msg=permissions-saved$`));
|
||||||
|
const confirmed = await (await get(landing)).text();
|
||||||
|
assert.match(confirmed, /Permissions saved/);
|
||||||
|
assert.match(confirmed, /value="users:write"[^>]*checked/);
|
||||||
|
|
||||||
// A crafted POST can't grant something no plugin declares.
|
// A crafted POST can't grant something no plugin declares.
|
||||||
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=superuser%3Aall`);
|
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(confirmed)}&permission=users%3Awrite&permission=superuser%3Aall`);
|
||||||
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
|
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
|
||||||
|
|
||||||
// The same picker on a group writes the group's subject_set, which Keto resolves transitively.
|
// The same picker on a group writes the group's subject_set, which Keto resolves transitively.
|
||||||
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||||
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=groups%3Aread`);
|
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
||||||
|
const groupSaved = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`);
|
||||||
|
assert.equal(groupSaved.headers.get("location"), "/admin/groups/eng?info-msg=permissions-saved");
|
||||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Two admins open the same page; the second save would silently undo the first.
|
||||||
|
test("admin permission grants: a save from a stale page is refused, and the page re-renders the current state", async (t) => {
|
||||||
|
const ada = randomUUID();
|
||||||
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
|
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||||
|
const keto = fakeKeto(tuples);
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
|
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
||||||
|
|
||||||
|
// Another admin grants users:write while this page is open.
|
||||||
|
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
||||||
|
const before = JSON.stringify(tuples);
|
||||||
|
const refused = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=groups%3Aread`);
|
||||||
|
assert.equal(refused.status, 409);
|
||||||
|
const page = await refused.text();
|
||||||
|
assert.match(page, /changed while you had the page open/);
|
||||||
|
assert.equal(JSON.stringify(tuples), before, "nothing was applied");
|
||||||
|
assert.match(page, /value="users:write"[^>]*checked/); // the re-render shows the other admin's grant
|
||||||
|
assert.notEqual(baselineOf(page), stale);
|
||||||
|
|
||||||
|
// Saving from the re-rendered page goes through.
|
||||||
|
const retried = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(page)}&permission=users%3Aread&permission=users%3Awrite&permission=groups%3Aread`);
|
||||||
|
assert.equal(retried.status, 303);
|
||||||
|
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === `user:${ada}`));
|
||||||
|
|
||||||
|
// A POST with no token at all (a crafted request, or a form rendered before this check existed) is stale too.
|
||||||
|
assert.equal((await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Aread`)).status, 409);
|
||||||
|
|
||||||
|
// Groups are checked the same way.
|
||||||
|
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||||
|
const groupStale = baselineOf(await (await get("/admin/groups/eng")).text());
|
||||||
|
tuples.push({ namespace: "Permission", object: "groups:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||||
|
const groupRefused = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupStale}&permission=users%3Aread`);
|
||||||
|
assert.equal(groupRefused.status, 409);
|
||||||
|
assert.match(await groupRefused.text(), /changed while you had the page open/);
|
||||||
|
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the group keeps its grant");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A Keto error midway leaves some changes applied; the save says so and still revokes the user's live tokens.
|
||||||
|
test("admin permission grants: a Keto failure midway reports a partial save and keeps what was applied", async (t) => {
|
||||||
|
const ada = randomUUID();
|
||||||
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
|
const tuples: RelationTuple[] = [];
|
||||||
|
let writes = 0;
|
||||||
|
const keto = fakeKeto(tuples, {
|
||||||
|
writeTuple: async (tp) => {
|
||||||
|
if (++writes === 2) throw new Error("keto unreachable");
|
||||||
|
tuples.push(tp);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
const denylist = createDenylist();
|
||||||
|
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||||
|
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
||||||
|
|
||||||
|
const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread&permission=users%3Awrite`);
|
||||||
|
assert.equal(res.status, 502);
|
||||||
|
const page = await res.text();
|
||||||
|
assert.match(page, /stopped part-way/);
|
||||||
|
assert.match(page, /value="groups:read"[^>]*checked/); // the first write landed, and the page shows what is stored
|
||||||
|
assert.doesNotMatch(page, /value="users:write"[^>]*checked/);
|
||||||
|
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
|
||||||
|
assert.equal(denylist.isRevoked(ada, 0), true); // the half that was applied still revokes live tokens
|
||||||
|
});
|
||||||
|
|
||||||
|
// A double-clicked Save posts twice from one page; the second post finds the first one's result.
|
||||||
|
// A save that changes nothing has nothing to refuse and nothing to revoke.
|
||||||
|
test("admin permission grants: a save that changes nothing is not refused for a stale page and leaves live tokens alone", async (t) => {
|
||||||
|
const ada = randomUUID();
|
||||||
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
|
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
const denylist = createDenylist();
|
||||||
|
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
|
||||||
|
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
||||||
|
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
||||||
|
const before = JSON.stringify(tuples);
|
||||||
|
|
||||||
|
const repeated = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=users%3Awrite`);
|
||||||
|
assert.equal(repeated.status, 303);
|
||||||
|
assert.equal(JSON.stringify(tuples), before);
|
||||||
|
assert.equal(denylist.isRevoked(ada, 0), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keto answered the writes but not the re-read: the report must still reach the admin.
|
||||||
|
test("admin permission grants: a partial save is still reported when Keto cannot be read again", async (t) => {
|
||||||
|
const ada = randomUUID();
|
||||||
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
|
const tuples: RelationTuple[] = [];
|
||||||
|
let writes = 0;
|
||||||
|
let down = false;
|
||||||
|
let failureTakesKetoDown = true;
|
||||||
|
const keto = fakeKeto(tuples, {
|
||||||
|
listRelations: async (q = {}) => {
|
||||||
|
if (down) throw new Error("keto unreachable");
|
||||||
|
return { nextPageToken: null, tuples: tuples.filter((tp) => matchesTuple(tp, q)) };
|
||||||
|
},
|
||||||
|
writeTuple: async (tp) => {
|
||||||
|
if (++writes === 2) { down = failureTakesKetoDown; throw new Error("keto unreachable"); }
|
||||||
|
tuples.push(tp);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
|
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
||||||
|
|
||||||
|
const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread`);
|
||||||
|
assert.equal(res.status, 502);
|
||||||
|
assert.match(await res.text(), /stopped part-way/);
|
||||||
|
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
|
||||||
|
|
||||||
|
// The same on a group, whose page needs Keto for its members as well.
|
||||||
|
down = false;
|
||||||
|
writes = 0;
|
||||||
|
tuples.length = 0;
|
||||||
|
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||||
|
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
||||||
|
const groupRes = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread&permission=users%3Aread`);
|
||||||
|
assert.equal(groupRes.status, 502);
|
||||||
|
assert.match(await groupRes.text(), /stopped part-way/);
|
||||||
|
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the first write landed");
|
||||||
|
|
||||||
|
// With Keto still answering, the group page itself reports it and shows what is stored.
|
||||||
|
failureTakesKetoDown = false;
|
||||||
|
down = false;
|
||||||
|
writes = 0;
|
||||||
|
tuples.length = 1;
|
||||||
|
const again = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baselineOf(await (await get("/admin/groups/eng")).text())}&permission=groups%3Aread&permission=users%3Aread`);
|
||||||
|
assert.equal(again.status, 502);
|
||||||
|
const page = await again.text();
|
||||||
|
assert.match(page, /stopped part-way/);
|
||||||
|
assert.match(page, /value="groups:read"[^>]*checked/);
|
||||||
|
});
|
||||||
|
|
||||||
// Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
|
// Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
|
||||||
// hook lands it on the next request — recovery would be a curl against Keto. Guarded like
|
// hook lands it on the next request — recovery would be a curl against Keto. Guarded like
|
||||||
// self-deactivate and self-delete are. (`admin1` is the harness's own sub.)
|
// self-deactivate and self-delete are. (`admin1` is the harness's own sub.)
|
||||||
@@ -1305,15 +1534,16 @@ test("admin permission grants: you can't revoke your own permissions, but you ca
|
|||||||
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:write", relation: "granted", subject_id: "user:admin1" }];
|
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:write", relation: "granted", subject_id: "user:admin1" }];
|
||||||
const keto = fakeKeto(tuples);
|
const keto = fakeKeto(tuples);
|
||||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const { post, token } = await adminHarness(t, { keto, kratosAdmin });
|
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
|
const baseline = baselineOf(await (await get("/admin/users/admin1")).text());
|
||||||
|
|
||||||
const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}`); // every box cleared
|
const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}`); // every box cleared
|
||||||
assert.equal(refused.status, 400);
|
assert.equal(refused.status, 400);
|
||||||
assert.match(await refused.text(), /lock yourself out/);
|
assert.match(await refused.text(), /lock yourself out/);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
|
assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
|
||||||
|
|
||||||
// Granting yourself more is not a lockout, so it goes through.
|
// Granting yourself more is not a lockout, so it goes through.
|
||||||
const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
|
const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||||
assert.equal(granted.status, 303);
|
assert.equal(granted.status, 303);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
|
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-2
@@ -8,7 +8,6 @@ import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
|||||||
import { buildContext, type RequestContext, type User } from "./context.ts";
|
import { buildContext, type RequestContext, type User } from "./context.ts";
|
||||||
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
|
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
|
||||||
import type { Denylist } from "../auth/denylist.ts";
|
import type { Denylist } from "../auth/denylist.ts";
|
||||||
import { buildDashboardModel } from "../ui/dashboard.ts";
|
|
||||||
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
|
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
|
||||||
import { GuardError, loginRedirect } from "../auth/guards.ts";
|
import { GuardError, loginRedirect } from "../auth/guards.ts";
|
||||||
import { ENGLISH_I18N } from "../i18n/english.ts";
|
import { ENGLISH_I18N } from "../i18n/english.ts";
|
||||||
@@ -36,6 +35,8 @@ import { localPath } from "./safe-url.ts";
|
|||||||
import { routePublic, serveStatic } from "./static.ts";
|
import { routePublic, serveStatic } from "./static.ts";
|
||||||
import { renderPluginView } from "../plugin-host/view-resolver.ts";
|
import { renderPluginView } from "../plugin-host/view-resolver.ts";
|
||||||
|
|
||||||
|
const NO_STORE = "private, no-store";
|
||||||
|
|
||||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
|
||||||
export interface AppOptions {
|
export interface AppOptions {
|
||||||
@@ -166,7 +167,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
|
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav, t: ctx.t }) }, view: "index" };
|
return { data: { chrome: ctx.chrome }, view: "index" };
|
||||||
};
|
};
|
||||||
|
|
||||||
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
|
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
|
||||||
@@ -203,6 +204,11 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// A cache in front of us must key on the language. Set after the static branch: an asset is
|
// A cache in front of us must key on the language. Set after the static branch: an asset is
|
||||||
// the same bytes in every language, and a Vary there fragments its entry per raw header.
|
// the same bytes in every language, and a Vary there fragments its entry per raw header.
|
||||||
res.setHeader("vary", "accept-language");
|
res.setHeader("vary", "accept-language");
|
||||||
|
// Fail closed: a cache in front of us must not keep a page that may carry a user's data. Not
|
||||||
|
// even the signed-out `/` is shared: it and the signed-in page have one URL, and a cache keyed
|
||||||
|
// on URL and Accept-Language would hand the signed-out one to a signed-in visitor. A route that
|
||||||
|
// is the same for everyone sets its own value in RouteResult.headers; the host doesn't police it.
|
||||||
|
res.setHeader("cache-control", NO_STORE);
|
||||||
|
|
||||||
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
|
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
|
||||||
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
|
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
|
||||||
|
|||||||
+14
-1
@@ -1,6 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { buildPluginChrome } from "./chrome.ts";
|
import { buildPluginChrome, shellUser } from "./chrome.ts";
|
||||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||||
import type { NavNode } from "./nav.ts";
|
import type { NavNode } from "./nav.ts";
|
||||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
@@ -74,3 +74,16 @@ test("branding logo + default theme flow through when set", () => {
|
|||||||
assert.equal(chrome.brand.name, "Acme");
|
assert.equal(chrome.brand.name, "Acme");
|
||||||
assert.equal(chrome.theme, "dark");
|
assert.equal(chrome.theme, "dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("branding name and sub are translated; an unknown key renders as itself", () => {
|
||||||
|
const t = (key: string): string => (key === "brand.sub" ? "Undertitel" : key);
|
||||||
|
const chrome = buildPluginChrome({ menu: { branding: { name: "Acme", sub: "brand.sub" }, override: {} }, t });
|
||||||
|
assert.equal(chrome.brand.name, "Acme");
|
||||||
|
assert.equal(chrome.brand.sub, "Undertitel");
|
||||||
|
assert.equal(buildPluginChrome({ menu: { branding: { name: "Acme" }, override: {} } }).brand.sub, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shellUser derives the profile from the real user; anonymous → Guest", () => {
|
||||||
|
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
|
||||||
|
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
|
||||||
|
});
|
||||||
|
|||||||
+26
-1
@@ -8,10 +8,15 @@ import type { Translate } from "../i18n/translate.ts";
|
|||||||
import { type MenuConfig } from "./menu-config.ts";
|
import { type MenuConfig } from "./menu-config.ts";
|
||||||
import { composeNav, type NavNode } from "./nav.ts";
|
import { composeNav, type NavNode } from "./nav.ts";
|
||||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
|
|
||||||
|
|
||||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard", session: true };
|
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard", session: true };
|
||||||
|
|
||||||
|
export interface ShellUser {
|
||||||
|
email: string;
|
||||||
|
initials: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageChrome {
|
export interface PageChrome {
|
||||||
brand: { logo?: string; name: string; sub?: string };
|
brand: { logo?: string; name: string; sub?: string };
|
||||||
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
|
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
|
||||||
@@ -65,6 +70,26 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The JWT holds only id/email/permissions, so the profile name is the email's local part.
|
||||||
|
export function shellUser(user: User | null | undefined, t: Translate = ENGLISH): ShellUser {
|
||||||
|
if (!user) {
|
||||||
|
const guest = t("shell.guest");
|
||||||
|
return { email: "", initials: ([...guest][0] ?? "?").toUpperCase(), name: guest }; // by character: the word is translated
|
||||||
|
}
|
||||||
|
const local = user.email.split("@")[0] || user.email;
|
||||||
|
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
|
||||||
|
}
|
||||||
|
|
||||||
|
function branding(menu: MenuConfig, t: Translate): { logo?: string; name: string; sub?: string; theme?: string } {
|
||||||
|
const b = menu.branding;
|
||||||
|
return {
|
||||||
|
...(b.logo != null ? { logo: b.logo } : {}),
|
||||||
|
name: t(b.name),
|
||||||
|
...(b.sub != null ? { sub: t(b.sub) } : {}),
|
||||||
|
...(b.theme != null ? { theme: b.theme } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function translateNav(nodes: NavNode[], t: Translate): NavNode[] {
|
function translateNav(nodes: NavNode[], t: Translate): NavNode[] {
|
||||||
return nodes.map((node) => ({
|
return nodes.map((node) => ({
|
||||||
...node,
|
...node,
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import { buildDashboardModel } from "./dashboard.ts";
|
|
||||||
import type { NavNode } from "./nav.ts";
|
|
||||||
|
|
||||||
// The default /dashboard is an instructional starter: no mock data, just the unified
|
|
||||||
// menu + shell. The host passes the one global menu (ctx.chrome.nav); the model passes it through.
|
|
||||||
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
|
|
||||||
|
|
||||||
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
|
|
||||||
const m = buildDashboardModel({ csrfToken: "tok.sig", user: { email: "ada@x.io", id: "u1", permissions: ["admin"] }, nav: NAV });
|
|
||||||
assert.equal(m.shell.title, "Dashboard");
|
|
||||||
assert.equal(m.shell.csrfToken, "tok.sig");
|
|
||||||
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
|
|
||||||
assert.deepEqual(m.nav, NAV); // the host's menu is used verbatim — the dashboard builds no nav of its own
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dashboard model: sensible defaults (empty nav, no token, Guest) and branding from menu", () => {
|
|
||||||
const m = buildDashboardModel();
|
|
||||||
assert.deepEqual(m.nav, []);
|
|
||||||
assert.equal(m.shell.csrfToken, "");
|
|
||||||
assert.equal(m.shell.user.name, "Guest");
|
|
||||||
assert.equal(m.shell.brand.name, "Plainpages");
|
|
||||||
|
|
||||||
const branded = buildDashboardModel({ menu: { branding: { name: "Acme Ops", sub: "Admin", theme: "dark" }, override: {} } });
|
|
||||||
assert.equal(branded.shell.brand.name, "Acme Ops");
|
|
||||||
assert.equal(branded.shell.theme, "dark");
|
|
||||||
});
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// Dashboard view model: the gated "/dashboard" app home. By default a short instructional
|
|
||||||
// starter — what the dashboard is and how to replace it by exporting a `dashboard` handler from a
|
|
||||||
// plugin (views/index.ejs holds the prose). A plugin owns the real one (PluginManifest.dashboard);
|
|
||||||
// this placeholder renders until then. Pure: `nav` is the one global menu (ctx.chrome.nav), built
|
|
||||||
// once per request by the host, so the dashboard shows the exact same menu as every other page.
|
|
||||||
|
|
||||||
import type { User } from "../http/context.ts";
|
|
||||||
import { ENGLISH } from "../i18n/english.ts";
|
|
||||||
import type { Translate } from "../i18n/translate.ts";
|
|
||||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
|
||||||
import type { NavNode } from "./nav.ts";
|
|
||||||
import { buildShellContext } from "./shell-context.ts";
|
|
||||||
|
|
||||||
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; t?: Translate; user?: User | null } = {}) {
|
|
||||||
const t = opts.t ?? ENGLISH;
|
|
||||||
return {
|
|
||||||
nav: opts.nav ?? [],
|
|
||||||
shell: buildShellContext({
|
|
||||||
breadcrumbs: [{ label: t("dashboard.title") }],
|
|
||||||
csrfToken: opts.csrfToken ?? "",
|
|
||||||
menu: opts.menu ?? DEFAULT_MENU,
|
|
||||||
t,
|
|
||||||
title: t("dashboard.title"),
|
|
||||||
user: opts.user ?? null,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DashboardModel = ReturnType<typeof buildDashboardModel>;
|
|
||||||
+1
-3
@@ -9,13 +9,11 @@ export const ICON_NAMES: Record<string, string> = {
|
|||||||
"i-bell": "bell",
|
"i-bell": "bell",
|
||||||
"i-box": "box",
|
"i-box": "box",
|
||||||
"i-cal": "calendar",
|
"i-cal": "calendar",
|
||||||
"i-chart": "chart-no-axes-column",
|
|
||||||
"i-check-circle": "circle-check",
|
"i-check-circle": "circle-check",
|
||||||
"i-chev": "chevron-right",
|
"i-chev": "chevron-right",
|
||||||
"i-cols": "columns-3",
|
"i-cols": "columns-3",
|
||||||
"i-copy": "copy",
|
|
||||||
"i-download": "download",
|
|
||||||
"i-edit": "pencil",
|
"i-edit": "pencil",
|
||||||
|
"i-eye": "eye",
|
||||||
"i-globe": "globe",
|
"i-globe": "globe",
|
||||||
"i-grid": "layout-grid",
|
"i-grid": "layout-grid",
|
||||||
"i-kebab": "ellipsis-vertical",
|
"i-kebab": "ellipsis-vertical",
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import { buildShellContext, shellUser } from "./shell-context.ts";
|
|
||||||
|
|
||||||
test("shellUser derives the profile from the real user; anonymous → Guest", () => {
|
|
||||||
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
|
|
||||||
// Real user: name = email local part, email kept, initials = first two letters of the local part.
|
|
||||||
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => {
|
|
||||||
const bare = buildShellContext({ menu: { branding: { name: "Plainpages" }, override: {} }, title: "Users" });
|
|
||||||
assert.deepEqual(bare.brand, { name: "Plainpages" }); // no logo/sub when unset
|
|
||||||
assert.equal(bare.theme, undefined);
|
|
||||||
assert.equal(bare.csrfToken, "");
|
|
||||||
assert.equal(bare.user.name, "Guest");
|
|
||||||
assert.equal(bare.signInHref, undefined); // omitted unless supplied (a public built-in screen would set it)
|
|
||||||
|
|
||||||
const full = buildShellContext({
|
|
||||||
breadcrumbs: [{ href: "/", label: "Home" }, { label: "Users" }],
|
|
||||||
csrfToken: "tok.sig",
|
|
||||||
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
|
|
||||||
signInHref: "/login?return_to=%2Fx",
|
|
||||||
title: "Users",
|
|
||||||
user: { email: "a@b.c", id: "u1", permissions: ["admin"] },
|
|
||||||
});
|
|
||||||
assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" });
|
|
||||||
assert.equal(full.theme, "dark");
|
|
||||||
assert.equal(full.csrfToken, "tok.sig");
|
|
||||||
assert.equal(full.breadcrumbs?.length, 2);
|
|
||||||
assert.equal(full.signInHref, "/login?return_to=%2Fx");
|
|
||||||
});
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// Shell view-model builder: the brand/theme/user/title block every app-shell page
|
|
||||||
// (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the
|
|
||||||
// shell user is the *real* signed-in identity — no hardcoded demo profile — and branding is
|
|
||||||
// read from one place. The User carries no display name (the JWT holds only id/email/permissions), so
|
|
||||||
// the profile shows the email's local part as the name with the full email beneath, initials from
|
|
||||||
// the local part; anonymous ⇒ "Guest".
|
|
||||||
|
|
||||||
import type { User } from "../http/context.ts";
|
|
||||||
import { ENGLISH } from "../i18n/english.ts";
|
|
||||||
import type { Translate } from "../i18n/translate.ts";
|
|
||||||
import { type MenuConfig } from "./menu-config.ts";
|
|
||||||
|
|
||||||
// The brand block both the chrome and the shell model carry. `name`/`sub` pass through `t`, so a
|
|
||||||
// catalog key is translated and an operator's own wording renders as written.
|
|
||||||
export function branding(menu: MenuConfig, t: Translate): { logo?: string; name: string; sub?: string; theme?: string } {
|
|
||||||
const b = menu.branding;
|
|
||||||
return {
|
|
||||||
...(b.logo != null ? { logo: b.logo } : {}),
|
|
||||||
name: t(b.name),
|
|
||||||
...(b.sub != null ? { sub: t(b.sub) } : {}),
|
|
||||||
...(b.theme != null ? { theme: b.theme } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShellUser {
|
|
||||||
email: string;
|
|
||||||
initials: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShellModel {
|
|
||||||
brand: { logo?: string; name: string; sub?: string };
|
|
||||||
breadcrumbs?: { href?: string; label: string }[];
|
|
||||||
csrfToken: string;
|
|
||||||
signInHref?: string; // anonymous "Sign in" target (mirrors PageChrome.signInHref); a gated screen omits it
|
|
||||||
theme?: string;
|
|
||||||
title: string;
|
|
||||||
user: ShellUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shellUser(user: User | null | undefined, t: Translate = ENGLISH): ShellUser {
|
|
||||||
if (!user) {
|
|
||||||
const guest = t("shell.guest");
|
|
||||||
return { email: "", initials: ([...guest][0] ?? "?").toUpperCase(), name: guest }; // by character: the word is translated
|
|
||||||
}
|
|
||||||
const local = user.email.split("@")[0] || user.email;
|
|
||||||
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildShellContext(opts: {
|
|
||||||
breadcrumbs?: { href?: string; label: string }[];
|
|
||||||
csrfToken?: string;
|
|
||||||
menu: MenuConfig;
|
|
||||||
signInHref?: string;
|
|
||||||
t?: Translate;
|
|
||||||
title: string;
|
|
||||||
user?: User | null;
|
|
||||||
}): ShellModel {
|
|
||||||
const t = opts.t ?? ENGLISH;
|
|
||||||
const { theme, ...brand } = branding(opts.menu, t);
|
|
||||||
return {
|
|
||||||
brand,
|
|
||||||
...(opts.breadcrumbs ? { breadcrumbs: opts.breadcrumbs } : {}),
|
|
||||||
csrfToken: opts.csrfToken ?? "",
|
|
||||||
...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}),
|
|
||||||
...(theme != null ? { theme } : {}),
|
|
||||||
title: opts.title,
|
|
||||||
user: shellUser(opts.user, t),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public", "css", "styles.css"), "utf8");
|
||||||
|
|
||||||
|
const token = (name: string): string => {
|
||||||
|
const hex = new RegExp(`--${name}:\\s*(#[0-9a-f]{6})\\b`, "i").exec(css)?.[1];
|
||||||
|
assert.ok(hex, `--${name} is not a six-digit hex colour`);
|
||||||
|
return hex;
|
||||||
|
};
|
||||||
|
|
||||||
|
const luminance = (hex: string): number => {
|
||||||
|
const [r = 0, g = 0, b = 0] = [1, 3, 5].map((i) => {
|
||||||
|
const c = Number.parseInt(hex.slice(i, i + 2), 16) / 255;
|
||||||
|
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||||
|
});
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
};
|
||||||
|
|
||||||
|
const contrast = (a: string, b: string): number => {
|
||||||
|
const [hi = 0, lo = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||||
|
return (hi + 0.05) / (lo + 0.05);
|
||||||
|
};
|
||||||
|
|
||||||
|
// WCAG AA for normal text.
|
||||||
|
for (const theme of ["l", "d"]) {
|
||||||
|
for (const tone of ["pos", "neg", "warn", "info"]) {
|
||||||
|
test(`${tone} badge text reaches 4.5:1 in ${theme === "l" ? "light" : "dark"} mode`, () => {
|
||||||
|
assert.ok(contrast(token(`${theme}-${tone}`), token(`${theme}-${tone}-bg`)) >= 4.5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test(`text on the accent reaches 4.5:1 in ${theme === "l" ? "light" : "dark"} mode`, () => {
|
||||||
|
assert.ok(contrast(token(`${theme}-on-accent`), token(`${theme}-accent`)) >= 4.5);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const cssDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public", "css");
|
||||||
|
|
||||||
|
// A custom property holding oklch() passes unchecked on an engine below Chromium 111, so the
|
||||||
|
// var() that reads it is invalid at computed-value time: white text on no background.
|
||||||
|
for (const file of readdirSync(cssDir).filter((f) => f.endsWith(".css"))) {
|
||||||
|
test(`${file} declares no oklch() colour`, () => {
|
||||||
|
assert.ok(!/oklch\(/i.test(readFileSync(join(cssDir, file), "utf8")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stylesheet = readFileSync(join(cssDir, "styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||||
|
|
||||||
|
// The index of the `}` closing the block whose `{` is the first one at or after `start`.
|
||||||
|
function blockEnd(start: number): number {
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = stylesheet.indexOf("{", start); i < stylesheet.length; i++) {
|
||||||
|
if (stylesheet[i] === "{") depth++;
|
||||||
|
if (stylesheet[i] === "}" && --depth === 0) return i;
|
||||||
|
}
|
||||||
|
throw new Error("unbalanced braces in styles.css");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The text is all there is to check: Playwright's engines all support `:has()`, so no E2E sees the
|
||||||
|
// stylesheet an older engine gets.
|
||||||
|
// Chromium below 105 and Firefox below 121 have no `:has()`, and nothing else reveals a folded
|
||||||
|
// branch there, so the fold may only exist where `:has()` works.
|
||||||
|
test("styles.css folds .nav-children only inside @supports selector(:has(a))", () => {
|
||||||
|
const open = stylesheet.indexOf("@supports selector(:has(a)) {");
|
||||||
|
assert.ok(open >= 0, "no @supports selector(:has(a)) block");
|
||||||
|
const close = blockEnd(open);
|
||||||
|
const outside = stylesheet.slice(0, open) + stylesheet.slice(close + 1);
|
||||||
|
assert.ok(!/\.nav-children\s*\{[^}]*display:\s*none/.test(outside), "a .nav-children rule hides it outside the guard");
|
||||||
|
assert.match(stylesheet.slice(open, close), /\.nav-children\s*\{\s*display:\s*none/, "the fold rule is gone");
|
||||||
|
});
|
||||||
|
|
||||||
|
// `@supports` adds no specificity, so the hiding rule has to come after the base `.theme-switch` rule.
|
||||||
|
test("styles.css hides the theme switch and the nav toggles where :has() is missing, after their base rules", () => {
|
||||||
|
const guard = stylesheet.indexOf("@supports not selector(:has(a)) {");
|
||||||
|
assert.ok(guard >= 0, "no @supports not selector(:has(a)) block");
|
||||||
|
const base = stylesheet.search(/^\.theme-switch\s*\{/m);
|
||||||
|
assert.ok(base >= 0 && base < guard, "the guard precedes the base .theme-switch rule");
|
||||||
|
const block = stylesheet.slice(guard, stylesheet.indexOf("\n}", guard));
|
||||||
|
assert.match(block, /\.theme-switch\s*\{\s*display:\s*none/);
|
||||||
|
assert.match(block, /\.nav-disc\s*\{\s*visibility:\s*hidden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Without `:has()` nothing reads #nav-toggle, so an off-canvas sidebar there is unreachable and the
|
||||||
|
// hamburger does nothing: the sidebar has to stay in the flow, with the hamburger hidden.
|
||||||
|
test("styles.css moves the sidebar off-canvas at 860px only inside @supports selector(:has(a))", () => {
|
||||||
|
const open = stylesheet.indexOf("@media (max-width: 860px) {");
|
||||||
|
assert.ok(open >= 0, "no @media (max-width: 860px) block");
|
||||||
|
const close = blockEnd(open);
|
||||||
|
const media = stylesheet.slice(open, close);
|
||||||
|
const guard = media.indexOf("@supports selector(:has(a)) {");
|
||||||
|
assert.ok(guard >= 0, "no @supports selector(:has(a)) block inside the 860px media block");
|
||||||
|
const guardEnd = blockEnd(open + guard) - open;
|
||||||
|
const outside = media.slice(0, guard) + media.slice(guardEnd + 1);
|
||||||
|
const inside = media.slice(guard, guardEnd);
|
||||||
|
for (const offCanvas of [/translateX\(-100%\)/, /position:\s*fixed/, /#nav-toggle/, /\.hamburger/]) {
|
||||||
|
assert.doesNotMatch(outside, offCanvas, `${offCanvas} applies outside the guard`);
|
||||||
|
assert.match(inside, offCanvas, `${offCanvas} is gone from the guard`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,74 +1,158 @@
|
|||||||
# Primary todo
|
# todo
|
||||||
|
|
||||||
## Unfinnished work
|
## Scoring
|
||||||
|
|
||||||
- [ ] **Publish every release to Docker Hub, and give the Hub page its short description.** Today Hub's newest tag is 0.4.0 (2026-09-10), while git has v0.4.1–v0.4.12, and the overview still says 0.4.0. No `release.yml` run exists for the tags auto-release pushes as `renovate-bot`, so neither the images nor the overview reach Hub; find out why Gitea doesn't fire `push: tags` for them, fix it, and backfill the missing tags. The short description is empty. fejkdata already did this (`../fejkdata`, commits `51d924a` and `34c8234`), and two parts port directly: a `dockerhub/description.txt`-style one-liner, which here can ride the existing PATCH as its `description` field; and a unit test that the description opens a README sentence and every command the overview shows appears in the README. fejkdata also syncs the page on any push to `main` that changes it, with its own Read/Write/Delete token in an environment only `main` may use, so the image-push token needs no Delete scope. Decide whether that fits here, where `{{VERSION}}` must render a released tag.
|
`Score = 100 * V / (2**S + 8)`
|
||||||
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
|
|
||||||
- [ ] Guard the group paths against self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query.
|
|
||||||
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change. Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying.
|
|
||||||
- [ ] A grant whose plugin was uninstalled is invisible and unremovable in the GUI. `grantDiff` deliberately never revokes an undeclared name, but nothing *shows* it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action.
|
|
||||||
- [ ] A plugin may gate a route on a permission it never declares — declaring stays optional on purpose. The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error and a permanent 403 as the operator's only clue. Sketch: a discovery *warning* (not an error) naming the gated-but-undeclared permission.
|
|
||||||
- [ ] Saving permissions gives no confirmation, and a partial failure is silent. `applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a successful save is indistinguishable from "nothing changed". The `alert alert-pos` pattern the recovery-code banner uses is already available.
|
|
||||||
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
|
|
||||||
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks its most logic-bearing file (`console-guard.ts`). Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither.
|
|
||||||
- [ ] Decide whether Playwright's `workers` should be pinned. Unset, it sizes the pool from `os.cpus()`, which reports the host's cores regardless of a container CPU quota — and with `retries: 0` a starved runner turns a slow test straight into a red gate. Fine on the current act_runner; revisit if CI ever runs constrained.
|
|
||||||
- [ ] Record the browser floor Plainpages requires, and whether the fallback is the contract or a courtesy. The stylesheet needs `:has()` (Dec 2023); the menus need the popover API (Safari 17) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet, capped at Safari 16, therefore gets panels flowing inline rather than working menus. Either state a supported floor or accept the fallback for those devices; nobody has rendered that path on real hardware.
|
|
||||||
- [ ] Decide whether the profile dropdown still earns a dropdown. It holds one item, Sign out, behind a click, and its "Signed in as X" head repeats what the trigger already shows.
|
|
||||||
- [ ] Trim whitespace around the verification code — a copy+pasted code from the email fails, today as a browser `pattern` refusal rather than a Kratos rejection, now carrying a digits-only hint so that refusal isn't bare. The paste still fails. **Own session.** No zero-JS fix exists: `pattern` is rejective and cannot transform, so loosening it only lets the untrimmed value through. The real fix is a host-side proxy of the flow POST, which is feasible and half-built — `submitFlow` (`src/auth/kratos-public.ts:132`) already relays cookies and normalises a 422 `redirect_browser_to`, and has no callers outside tests. CSRF improves rather than breaks: the host already writes Kratos' cookie onto its own origin (`src/auth/routes.ts:77`), so the POST becomes same-origin, and `form-action 'self'` could finally join the CSP. Two risks: one `flow.ui.action` serves all five flows, so this rewrites the sign-in path, not just code entry; and Go's nosurf checks Referer only over https, which no test here covers. **Check first:** `kratos-admin.ts:75` returns a `recovery_link` alongside the code — if the stock courier mail carries one (unverified), a template change fixes the UX with no host code. Code entry has no E2E coverage today either way.
|
|
||||||
- [ ] Guard against the double-clicked submit, without client-side JavaScript. A non-technical user clicks a button twice when nothing happens fast enough, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only, and it must not break an action that is *legitimately* repeatable. Sketch: a CSS-only affordance so the second click has nothing to hit, paired with the host recognising a duplicate on the server — same session, route and payload within a short window — then logging and dropping it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form beats hashing the payload, and the CSRF plumbing already mints per-request tokens), the window length, where the record lives given the app is stateless, and how a plugin declares a route repeatable.
|
|
||||||
- [ ] Decide the caching contract for rendered pages. Responses carry `Vary: Accept-Language` but nothing sets `Cache-Control`, so a shared cache has no instruction and a signed-in page is not marked `private`. Either set the headers deliberately (public cacheable, gated `private, no-store`) or record in AGENTS.md that the reverse proxy owns this.
|
|
||||||
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule applied to namespaces. A design question, not a naming one.
|
|
||||||
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression.
|
|
||||||
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md and README → Security model; not accepted ⇒ bind the nonce to `sub`.
|
|
||||||
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host**, but nothing here has been run on one. Two suspects, both from the `--user "$(id -u):$(id -g)"` idiom: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer. The same question covers rootless Docker, where README already says to *drop* the flag.
|
|
||||||
- [ ] Map Kratos' 401 on a self-service flow init, so an anonymous `GET /settings` with no `?flow` renders instead of 500ing. `flowPage` (`src/auth/routes.ts`) maps 403/404/410 → restart the flow, 400 `session_already_available` → `/auth/complete`, and ≥500 → the themed 503, then rethrows everything else — and Kratos answers the settings-flow init with 401 when there is no session. `/settings` is correctly `public` (the recovery flow lands there with a live Kratos session but no app JWT), so the gate is not the fix: a 401 should redirect to `/login` with the page as `return_to`. No E2E covers an anonymous hit on a flow page that needs a session.
|
|
||||||
|
|
||||||
### Architectural review findings (2026-07-02)
|
`Bar = 1`
|
||||||
|
|
||||||
Prioritized. Overall verdict: architecture is sound; these are refinements.
|
`Next ID = 42`
|
||||||
|
|
||||||
- [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth.
|
## Items
|
||||||
- [ ] **LOW — The users list offers a pencil "Edit" row action to a `users:read` holder.** The link is harmless (it opens the read-only detail page), but the label contradicts what the reader can do. Needs `canWrite` threaded into `listTable` plus a `common.view` core catalog key and an `i-eye` entry in `ICON_NAMES`.
|
|
||||||
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
|
|
||||||
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
|
|
||||||
- [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`.
|
|
||||||
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `@plainpages/plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear.
|
|
||||||
- [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population and plugin count — per-plugin dependency isolation prices N dependency trees on disk and in RSS, and that number is what says whether the trade needs revisiting (build-time dedupe for baked images stays open).
|
|
||||||
|
|
||||||
## Finnished work
|
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 34 | 0.4.21 | | | **Serve every stylesheet and script under a content-versioned URL with a year-long immutable `Cache-Control`, so a page view downloads none of them again.** | 4 | 2 | 3 | 7 | 6 | 8 by 6, 2 by 2 | 1.8 |
|
||||||
|
| 3 | 0.4.21 | | | **Guard the group paths against self-lockout, or accept the gap explicitly.** | 3 | 2 | 2 | 5 | 6 | 2 by 6 | 1.4 |
|
||||||
|
| 40 | 0.4.21 | | | **Show a `users:read` holder "View user" as the user page's heading and breadcrumb, not "Edit user".** | 1 | 2 | 1 | 2 | 2 | 2 by 1 | 1.4 |
|
||||||
|
| 1 | 0.4.21 | | owner | **Publish every release to Docker Hub, and give the Hub page its short description.** | 3 | 3 | 2 | 6 | 5 | 1 by 3 | 1.3 |
|
||||||
|
| 32 | 0.4.21 | | | **Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.** | 4 | 2 | 2 | 6 | 7 | code health by 2 | 1.1 |
|
||||||
|
| 13 | 0.4.21 | | | **Accept a verification code pasted with surrounding whitespace.** | 4 | 2 | 3 | 7 | 7 | 2 by 8 | 1.0 |
|
||||||
|
| 19 | 0.5.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
|
||||||
|
| 16 | 0.5.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
|
||||||
|
| 6 | 0.5.0 | | | **Warn at discovery when a route or nav node gates on a permission no plugin declares.** | 2 | 1 | 1 | 2 | 2 | 7 by 3, 9 by 3 | 8.0 |
|
||||||
|
| 23 | 0.5.0 | | | **Give list pages one view-model helper from query to pagination, instead of ~40 hand-written lines each.** | 3 | 1 | 2 | 4 | 5 | 1 by 4, 9 by 2, code health by 3 | 7.9 |
|
||||||
|
| 28 | 0.5.0 | | | **Let a plugin add entries to the profile menu through the plugin API.** | 2 | 2 | 2 | 4 | 4 | 7 by 4, 1 by 2 | 4.2 |
|
||||||
|
| 2 | 0.5.0 | | | **Rename the admin example plugin to a name that says what it administers.** | 3 | 1 | 2 | 4 | 6 | 9 by 3, code health by 2 | 2.6 |
|
||||||
|
| 5 | 0.5.0 | | | **List grants that no installed plugin declares, with a remove action.** | 2 | 2 | 2 | 4 | 4 | 2 by 3 | 2.1 |
|
||||||
|
| 41 | 0.5.0 | | | **Apply a permission save as one Keto call, so it cannot stop part-way.** | 2 | 2 | 2 | 4 | 3 | 2 by 2 | 2.1 |
|
||||||
|
| 14 | 0.5.0 | | | **Drop a double-clicked duplicate submit, with no client-side JavaScript.** | 4 | 2 | 3 | 7 | 7 | 2 by 7, 1 by 4 | 1.4 |
|
||||||
|
|
||||||
- [x] Configure a plugin at install time: the manifest declares `settings`, the operator sets one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, and the resolved values arrive on `onBoot` typed from the declaration. Read-only admin screen at `/admin/plugin-settings`.
|
## Details
|
||||||
- [x] Give a plugin persistent storage: `storage: true` provisions a Postgres database + login role named `plugin_<id>`, credentials arrive on `onBoot`, passwords are derived from `PLUGIN_DB_SECRET` rather than stored.
|
|
||||||
- [x] Refuse a stray `package.json`/`node_modules` in `config/` by name, as plugin folders already are.
|
### 34. Serve every stylesheet and script under a content-versioned URL with a year-long immutable `Cache-Control`, so a page view downloads none of them again.
|
||||||
- [x] Let Renovate reach the example plugins' manifests (`ignorePaths` overrides `config:recommended`).
|
|
||||||
- [x] The seeded admin is granted each permission once — `seedPermissions` dedupes and the grant PUT is idempotent.
|
`serveStatic` (`src/http/static.ts`) sends only `content-length` and `content-type`, so every page
|
||||||
- [x] Run the E2E runner as the invoking user so its artifacts aren't root-owned.
|
view downloads `styles.css`, `auth.css` and each plugin's stylesheet again: about 50 kB on a plugin's
|
||||||
- [x] Install node_modules above `WORKDIR /app` so no mount leaves a root-owned dir in the checkout.
|
issue page, seconds per page over GPRS. Firefox can paint the page unstyled meanwhile, when an
|
||||||
- [x] Enforce `<resource>:<action>` permission names at discovery; split `admin` per screen.
|
extension forces layout early.
|
||||||
- [x] Make the declared-permission catalog the fixed list; delete the Permissions screen, move granting onto Users and Groups.
|
|
||||||
- [x] Fail a Playwright test on any browser console warning, error or uncaught exception, in every engine.
|
- A versioned URL carries a hash of the file's content, as `?v=<hash>` or in the path, and answers
|
||||||
- [x] Skip the CI gate when only markdown changed.
|
`Cache-Control: public, max-age=31536000, immutable`. A release changes the hash, so it still
|
||||||
- [x] Replace the `<details>` popup menus with the Popover API so an outside click dismisses them.
|
reaches every browser at once.
|
||||||
- [x] Organize the files in src into folders.
|
- An unversioned `/public/` URL answers a short `max-age` with revalidation: `ETag` or
|
||||||
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
|
`Last-Modified`, and 304 on `If-None-Match` or `If-Modified-Since`, for GET and HEAD.
|
||||||
- [x] Move the scheduling example out of `plugins/` into `examples/`.
|
- Every `/public/` href the host renders comes out versioned: the shell's `<link>`s and favicon,
|
||||||
- [x] Make `config/` an empty drop-in mount with the defaults as fallback.
|
the error pages (`views/403.ejs`, `404`, `500`, `503`, `error`), `views/oauth-consent.ejs`, and
|
||||||
- [x] Turn the built-in admin pages into a drop-in example plugin.
|
the `styles` hrefs a plugin passes to `views/partials/shell.ejs`.
|
||||||
- [x] CI/CD — test on push to any branch except main.
|
- **Recommended: the host versions a plugin's hrefs itself.** A plugin names its asset once, as
|
||||||
- [x] CI/CD — require a PR to main, gated on a green build, fast-forward-only.
|
today, and the shell adds the version to any `/public/` href. A helper would make every plugin
|
||||||
- [x] CI/CD — force-push mirror to GitHub after every merge to main.
|
author remember to call it, and adds to the barrel.
|
||||||
- [x] CI/CD — build and push the app image, tagged with the commit hash, as part of the gate.
|
- **Contract:** `views/partials/*.ejs` are contract surface (AGENTS.md → Rules). Host-side
|
||||||
- [x] CI/CD — re-tag the hash image to semver on a `vX.Y.Z` tag.
|
versioning keeps the `styles` option and its meaning, and an unversioned href keeps working, so
|
||||||
- [x] CI/CD — sync released tags to Docker Hub.
|
no minor bump of `HOST_API_VERSION` is needed and 0.4.21 holds. A helper exported through
|
||||||
- [x] Write README-dockerhub.md for the Docker Hub overview.
|
`@plainpages/plugin-api` would add to the contract and need 0.5.0.
|
||||||
- [x] CI/CD — set up the Renovate bot.
|
- Pages stay `private, no-store` (`src/http/app.ts`); the static branch returns before that header
|
||||||
- [x] CI/CD — give Renovate a read-only `GITHUB_COM_TOKEN` so github.com lookups aren't rate-limited.
|
is set.
|
||||||
- [x] CI/CD — auto-release on Renovate updates, versioned from the `Release-Bump:` trailer.
|
|
||||||
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen.
|
Tests first, with expected values from this item: `src/http` unit tests for both header sets and the
|
||||||
- [x] Document the auth security model in the README.
|
304s, and an E2E test that a repeat page view fetches no stylesheet again. State the choice once in a
|
||||||
- [x] Add i18n support.
|
comment in `static.ts`, and in a sentence where the README tells plugin authors and operators about
|
||||||
- [x] Settle the identity-vs-user vocabulary.
|
assets. Requested by plainpages-plugin-fastjira, whose own item waits on the release that ships this.
|
||||||
- [x] Use one uniform verb per action in the English UI (sign in / sign out / create account).
|
|
||||||
- [x] Remove the dead "Profile" link from the sidebar profile menu.
|
### 3. Guard the group paths against self-lockout, or accept the gap explicitly.
|
||||||
- [x] Remove the unspecified "Settings"/"Preferences" cog from the sidebar footer.
|
|
||||||
- [x] **HIGH — Split `handleRequest` in `src/http/app.ts`** — extract the built-in endpoints into named handlers on an internal route table.
|
The self-revoke guard covers only your own direct grants on the Users screen. Unticking a permission
|
||||||
|
on a group you belong to, leaving that group, or deleting it can still strip your own access with no
|
||||||
|
warning. AGENTS.md records this as a known gap. The robust fix is a "last effective holder" check,
|
||||||
|
which needs a reverse Keto query.
|
||||||
|
|
||||||
|
### 40. Show a `users:read` holder "View user" as the user page's heading and breadcrumb, not "Edit user".
|
||||||
|
|
||||||
|
The users list now says View for a read-only holder, but the page it opens still titles itself
|
||||||
|
`admin.users.edit` ("Edit user") with an "Edit" breadcrumb. `buildUserFormModel` in
|
||||||
|
`examples/plugins/admin/admin-users.ts` already receives `canWrite`; the two admin catalog keys it
|
||||||
|
needs are new.
|
||||||
|
|
||||||
|
### 1. Publish every release to Docker Hub, and give the Hub page its short description.
|
||||||
|
|
||||||
|
Hub's newest tag is 0.4.0, while git has v0.4.1 onwards, and the overview still says 0.4.0. No
|
||||||
|
`release.yml` run exists for the tags auto-release pushes as `renovate-bot`; find out why Gitea
|
||||||
|
doesn't fire `push: tags` for them, fix it, and backfill the missing tags. fejkdata did this
|
||||||
|
(`../fejkdata`, commits `51d924a` and `34c8234`), and two parts port directly: a one-line
|
||||||
|
description riding the existing PATCH as its `description` field, and a unit test that the
|
||||||
|
description opens a README sentence and every command the overview shows appears in the README.
|
||||||
|
fejkdata also syncs the page on any push to `main`, with its own token in an environment only `main`
|
||||||
|
may use; decide whether that fits here, where `{{VERSION}}` must render a released tag.
|
||||||
|
|
||||||
|
### 32. Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.
|
||||||
|
|
||||||
|
Nothing checks README → "Two browser floors", and no E2E engine is old enough to show a break.
|
||||||
|
Lint every stylesheet, the examples' included, against Chromium 87, Safari 15 and Firefox 115, and
|
||||||
|
allow a feature only inside an `@supports` that guards it.
|
||||||
|
|
||||||
|
### 13. Accept a verification code pasted with surrounding whitespace.
|
||||||
|
|
||||||
|
A pasted code fails the browser's `pattern` check. No zero-JS fix exists: `pattern`
|
||||||
|
cannot transform, so loosening it only lets the untrimmed value through to Kratos. **Check first:**
|
||||||
|
`kratos-admin.ts` returns a `recovery_link` alongside the code; if the stock courier mail can carry
|
||||||
|
one, a template change fixes this with no host code. Otherwise proxy the flow POST through the host:
|
||||||
|
`submitFlow` (`src/auth/kratos-public.ts`) already relays cookies and normalises a 422
|
||||||
|
`redirect_browser_to`, and has no callers outside tests. CSRF improves, since the POST becomes
|
||||||
|
same-origin and `form-action 'self'` can join the CSP. Risks: one `flow.ui.action` serves all five
|
||||||
|
flows, so this rewrites the sign-in path; and Go's nosurf checks Referer only over https, which no
|
||||||
|
test covers. Code entry has no E2E coverage today.
|
||||||
|
|
||||||
|
### 19. Run the documented Docker commands on macOS and fix whatever misbehaves.
|
||||||
|
|
||||||
|
Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is
|
||||||
|
`dialout` inside the noble image, and Docker Desktop remaps bind-mount ownership in its VM.
|
||||||
|
|
||||||
|
### 16. Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.
|
||||||
|
|
||||||
|
Ory's own examples model `Shift`, `Document` and so on. One global `Resource` bucket is the
|
||||||
|
project's "no catch-all names" rule applied to namespaces.
|
||||||
|
|
||||||
|
### 6. Warn at discovery when a route or nav node gates on a permission no plugin declares.
|
||||||
|
|
||||||
|
Declaring stays optional on purpose, but the picker is built from declarations only, so such a route
|
||||||
|
is ungrantable from the GUI and answers a permanent 403. A warning, not an error.
|
||||||
|
|
||||||
|
### 23. Give list pages one view-model helper from query to pagination, instead of ~40 hand-written lines each.
|
||||||
|
|
||||||
|
Every list screen bridges `parseListQuery`/`paginate` to the partials by hand. At minimum a
|
||||||
|
`buildPaginationModel(page, hrefFor)`. Exported through the barrel, so it is contract surface.
|
||||||
|
|
||||||
|
### 28. Let a plugin add entries to the profile menu through the plugin API.
|
||||||
|
|
||||||
|
The profile dropdown stays a dropdown: Sign out is its only entry today, and it will gain more.
|
||||||
|
`views/partials/shell.ejs` hand-rolls it (AGENTS.md → UI, the dropdown rule), so the entries need a
|
||||||
|
manifest field that discovery checks, gated like a nav node.
|
||||||
|
|
||||||
|
### 2. Rename the admin example plugin to a name that says what it administers.
|
||||||
|
|
||||||
|
Candidates: `auth-admin`, `users-groups-admin`. The id is also the mount path, so the compose files,
|
||||||
|
E2E stacks and README move with it.
|
||||||
|
|
||||||
|
### 5. List grants that no installed plugin declares, with a remove action.
|
||||||
|
|
||||||
|
`grantDiff` never revokes an undeclared name, and nothing shows one either, so it can't be audited or
|
||||||
|
cleaned, and reinstalling its plugin silently reactivates access nobody remembers granting.
|
||||||
|
|
||||||
|
### 41. Apply a permission save as one Keto call, so it cannot stop part-way.
|
||||||
|
|
||||||
|
`applyGrants` writes and deletes one tuple per request and reports a partial save when one fails.
|
||||||
|
**Check first:** Keto documents `PATCH /admin/relation-tuples`, which should apply a list of inserts
|
||||||
|
and deletes in one transaction; confirm that against the Keto version `ory/` pins. If so, add the
|
||||||
|
call to `KetoClient`, which is contract surface (`@plainpages/plugin-api`) and so needs 0.5.0, and the
|
||||||
|
partial-failure path in the admin example can go.
|
||||||
|
|
||||||
|
### 14. Drop a double-clicked duplicate submit, with no client-side JavaScript.
|
||||||
|
|
||||||
|
A second identical POST is expected from a non-technical user, and today creates two
|
||||||
|
users, mints two recovery codes, or registers two OAuth2 clients. It must not break a legitimately
|
||||||
|
repeatable action. Sketch: a CSS-only affordance so the second click has nothing to hit, plus the
|
||||||
|
host recognising a duplicate (same session, route and payload within a short window), logging and
|
||||||
|
dropping it. Open: what identifies "the same submission" (a one-time token minted into each form
|
||||||
|
beats hashing the payload), the window, where the record lives given the host is stateless, and how
|
||||||
|
a plugin declares a route repeatable.
|
||||||
|
|||||||
+9
-12
@@ -1,10 +1,8 @@
|
|||||||
<%#
|
<%#
|
||||||
Dashboard: the gated post-login app home. The built-in default is a short instructional
|
The built-in /dashboard starter, shown until a plugin's `dashboard` handler replaces it.
|
||||||
starter — what /dashboard is and how to replace it from a plugin. Composes the app shell (the one
|
Data: chrome (PageChrome).
|
||||||
global menu via model.nav) around the prose. A plugin owns the real dashboard via a `dashboard`
|
|
||||||
handler; this renders until then. Data: model { nav, shell }.
|
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: model.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
// These four messages carry markup (<code> spans), so they render raw — the documented
|
// These four messages carry markup (<code> spans), so they render raw — the documented
|
||||||
// markup-carrying case. They interpolate nothing, so there is no untrusted data to escape.
|
// markup-carrying case. They interpolate nothing, so there is no untrusted data to escape.
|
||||||
const body = `
|
const body = `
|
||||||
@@ -27,12 +25,11 @@
|
|||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
body,
|
body,
|
||||||
brand: model.shell.brand,
|
brand: chrome.brand,
|
||||||
breadcrumbs: model.shell.breadcrumbs,
|
breadcrumbs: [{ label: t("dashboard.title") }],
|
||||||
csrfToken: model.shell.csrfToken,
|
csrfToken: chrome.csrfToken,
|
||||||
nav,
|
nav,
|
||||||
signInHref: model.shell.signInHref,
|
theme: chrome.theme,
|
||||||
theme: model.shell.theme,
|
title: t("dashboard.title"),
|
||||||
title: model.shell.title,
|
user: chrome.user,
|
||||||
user: model.shell.user,
|
|
||||||
}) %>
|
}) %>
|
||||||
|
|||||||
@@ -5,13 +5,11 @@
|
|||||||
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0" /><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
|
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0" /><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
|
||||||
<symbol id="i-box" viewBox="0 0 24 24"><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-box" viewBox="0 0 24 24"><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-cal" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol>
|
<symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol>
|
||||||
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M5 21v-6" /><path d="M12 21V3" /><path d="M19 21V9" /></symbol>
|
|
||||||
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m16 9-5.5 5.5L8 12" /></symbol>
|
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m16 9-5.5 5.5L8 12" /></symbol>
|
||||||
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
|
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></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-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-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-download" viewBox="0 0 24 24"><path d="M12 15V3" /><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="m7 10 5 5 5-5" /></symbol>
|
|
||||||
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /><path d="m15 5 4 4" /></symbol>
|
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /><path d="m15 5 4 4" /></symbol>
|
||||||
|
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" /><circle cx="12" cy="12" r="3" /></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>
|
<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>
|
||||||
<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-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-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-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>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<%#
|
<%#
|
||||||
Recursive nav tree. Each node: { label, href?, icon?, count?, current?, open?, children? }.
|
Recursive nav tree. Each node: { label, href?, icon?, count?, current?, open?, children? }.
|
||||||
Shape is orthogonal: header (children → disclosure toggle + nested <ul>) vs leaf (spacer),
|
Shape is orthogonal: header (children → disclosure toggle + nested <ul>) vs leaf (spacer),
|
||||||
and clickable (href → <a>) vs static (<span>). The CSS reveals children only when the
|
and clickable (href → <a>) vs static (<span>). Where :has() works, the CSS reveals children
|
||||||
sibling .nav-disc is [open]. `root` picks the outer class.
|
only when the sibling .nav-disc is [open]; elsewhere it shows them all. `root` picks the outer class.
|
||||||
%><%
|
%><%
|
||||||
const nodes = locals.nodes || [];
|
const nodes = locals.nodes || [];
|
||||||
const root = locals.root !== false;
|
const root = locals.root !== false;
|
||||||
|
|||||||
Reference in New Issue
Block a user