chore(agent): add phase 11 — single-admin login for the dashboards

Eight tasks to put a session gate in front of every page and API route: the auth primitives
(PBKDF2 verifier, HMAC-signed stateless session, session-bound CSRF token, login throttle,
origin check) in a new `auth.py`, the verifier in the store plus fail-closed startup, the
default-deny middleware with an explicit public list, `POST /api/auth/password` with session
rotation, a login page, a `/settings` page, and the dev-config/docs/browser verification pass.

Stdlib hashlib/hmac only — no new dependency, so no PLAN.md anchor moves. `/api/health`
stays public on purpose (the Containerfile HEALTHCHECK and dev-server.sh depend on it), and
one test enumerates every route and demands a session.

LLM-Generated: true
This commit is contained in:
2026-10-17 12:27:40 -04:00
parent 71900d903a
commit 046dc57b26
8 changed files with 888 additions and 0 deletions
@@ -0,0 +1,89 @@
# Task 06 — `/settings`: change the password, sign out, see when the session ends
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "the password should be changeable once logged in"
**Story:** n/a
## Objective
A fifth page the operator reaches from the masthead: the session facts, a working
change-password form, and a sign-out. It has to survive the secret rotation from task 04
without leaving the browser holding a stale CSRF token.
## Work
1. `src/dashboards/app.py` — add `"/settings": "settings.html"` to `PAGES`. It lands behind the
gate with no extra code (task 03's route-enumeration test proves it); do not add it to the
public list.
2. `src/dashboards/web/pages/settings.html` — same `<head>`/masthead as the other pages, `.tabs`
nav **without** `aria-current` (Settings is not one of the four dashboards), and in `main`:
- **Session** panel: `<dl>` with the signed-in name, the expiry as a local timestamp *and*
"in 29d 14h" via `Dash.duration(seconds_left)`, and `<button class="action"
id="sign-out" type="button">Sign out</button>`.
- **Change password** panel: three fields — current (`autocomplete="current-password"`),
new (`autocomplete="new-password"`, `id="new-password"`), confirm
(`autocomplete="new-password"`, `id="confirm-password"`) — each with a real `<label
for=…>` and an `aria-describedby` hint naming the 8-character floor, a `<button
class="action" id="change-password">Change password</button>`, and one
`<p id="settings-result" role="status" aria-live="polite">` for the outcome.
- A static `.sub` paragraph stating the precedence rule in the app's own voice: the password
was seeded from `DASH_ADMIN_PASSWORD` at first start, changing it here takes precedence,
the env var never overwrites a password set here, and resetting from the environment means
deleting the `auth.verifier` row and restarting. (This is the sentence that stops the
"I edited the env file and nothing happened" support call.)
- No `#nav-status` — `Dash.initChrome()` already guards its absence.
3. `src/dashboards/web/static/app.js`:
- `resetAuth()` — clears the memoised `authState`/promise added in task 03, exported in the
`return { … }` list. Needed twice: after a password change (the secret rotated, so the
cached `csrf` is now invalid) and after logout. Forgetting this is the bug where the
operator changes the password and then every UPS button 403s until they reload.
- also call it when a `401` bounces to `/login` (the flag from task 03 already stops the
redirect storm).
4. `src/dashboards/web/static/page-settings.js`:
- on load `Dash.fetchJSON('/api/auth/status')` → fill the session panel;
- sign-out → `POST /api/auth/logout` → `Dash.resetAuth()` → `location.assign('/login')`;
- change password → local checks first (new ≥ 8 chars, `new === confirm`, current non-empty),
reported inline without a round-trip; then
`POST /api/auth/password` with `{current, new}`; on success write
"Password changed. Other browsers are signed out." into `#settings-result`,
`Dash.resetAuth()` (so the next write re-reads the fresh `csrf`), clear all three fields
(`form.reset()` plus explicit blanking — password managers autofill them), and keep the
session cookie the response set; on `403` print "current password is wrong", on `422`
print the server's `detail`, on `429` the lockout text — every message via `textContent`;
- after a successful change, verify once by calling `/api/auth/status` again so the panel
cannot show a session the server no longer honours.
5. Masthead on all four dashboard pages (`index.html`, `system.html`, `llm.html`, `ups.html`) —
one link in `.masthead-right`, before the theme toggle:
`<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>`.
**No `id`** (the orphan-`id` test in `tests/test_api.py` flags ids nothing references — an
`<a href>` needs none), and it must sit outside `.tabs` so the pinned tab structure of the
four dashboards is untouched. Add `.ghost` styling for `a` alongside the existing
`button.ghost` rule (extend its selector to `a.ghost` rather than duplicating; existing
tokens only).
6. `src/dashboards/web/static/page-login.js` — a sign-in that carries `?next=/settings` is a
normal path once the session dies on the settings page; task 05's `next` handling covers it,
just verify it in the browser.
## Testing & Quality
- `tests/test_api.py` (served-bytes section, signed-in client) —
`GET /settings` is `200` and contains `id="new-password"`, `autocomplete="new-password"`,
`id="sign-out"`, `role="status"`; anonymous `GET /settings` is `401`/`303` (already covered by
the enumeration test — do not duplicate it there, but assert it once here for the page);
`test_every_dashboard_page_links_to_settings` over the four page paths; `/settings` carries no
external asset; the orphan-`id` test still passes with the four new ids.
- `node --check` for `page-settings.js` and `app.js` (in `validate.sh`).
- No new Python except the one `PAGES` line, so coverage moves only through `app.py`'s existing
route tests; keep the overall gate (94%) where it is.
## Completion Criteria
- [ ] in a browser: the `⚙ Account` link appears on all four dashboards and opens `/settings`
- [ ] signing out from `/settings` returns to `/login`, and `/system` afterwards bounces to
`/login` rather than rendering an empty page of `n/a`s
- [ ] changing the password on `/settings` shows the success line, keeps the operator signed
in, and a UPS button on `/ups` (e.g. beeper disable → confirm) still works **without a
page reload** — that is `resetAuth()` doing its job
- [ ] the same change in a second browser tab signed in earlier shows the 401 → `/login` path
- [ ] `/settings` renders correctly in dark and light; screenshots
`data/logs/auth-settings.png` and `data/logs/auth-account-link.png`
- [ ] `uv run pytest` green; `ruff` + `pyright` clean; `bash .agents/validate.sh` green