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:
@@ -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
|
||||
Reference in New Issue
Block a user