phase: 11_admin_login

**Verified (final pass, phase 11 — all 7 tasks already in `complete/`)**
- Gate from the wire on a restarted dev server: `/system`→`303 /login?next=/system` (`/llm?range=1h`→`/login?next=/llm%3Frange%3D1h`), `/api/summary`+`/api/config`→`401 {"ok":false,…}`, `/api/health`→`200`; signed-in `GET` of all six pages `200`, `/login`→`303 /`.
- Auth flows on a throwaway server (`:8199`, `/tmp` DB — dev `auth.verifier` untouched): wrong pw `401`, `testpass` `200`, password change `200`, **old cookie `401` / new cookie `200`**, old pw `401` / new pw `200`, short new `422`, wrong current `403`, cookie write without CSRF `403`, logout `200`; 5 failures then `429 retry-after: 60`; `login-denied`/`password-changed`/`password-denied` events; `meta` = the two auth keys.
- Fail-closed boot (no seed, empty DB): **exit 3**, message names `DASH_ADMIN_PASSWORD`, `auth.verifier`, and the `DELETE FROM meta …` reset. `dev-server.sh status` + the Containerfile `HEALTHCHECK` python one-liner both `200`/exit 0 unauthenticated.
- Live payloads unchanged (phases 01–10 walk): `/api/summary` keys + 8 alerts + hosts 3/2 live; UPS `simulated:false` with charge/load/runtime severity; deskwork `nvme0n1` 51 °C/19 159 h/11 % wear; gateway `smartctl_available:false`; driveripper `unreachable` stub.

**Tests/lint (all green)**: `bash .agents/validate.sh` → exit 0 — `1140 passed, 11 skipped`, coverage `98.62%` (floor 94), ruff `All checks passed!`, pyright `0 errors`, `node --check` clean, repo hygiene clean. `uv run pytest -m live --live` → `8 passed, 3 failed`, all `driveripper: 10.1.0.126:9120 … ConnectError` (pre-existing, auth-free; probe confirms nothing listening).

**Completion criteria**: all met except the live-suite caveat above — installing the driveripper agent needs root on that host (no `ops` grant), so per the live-infra rule I made **no live change**; task 07's five screenshots (`auth-login/-rejected/-locked-out/-signed-in-overview/-settings.png`) remain the browser evidence, and the login/rotation/lockout flows were re-proved over HTTP this pass.

**Docs**: README — fixed "only place the CSRF token is published" (login/password responses mint one), added the undocumented `DASH_RELOAD` row, stated that Sign out ends only this browser's cookie. `AGENTS.md` — new known gap 8 (stateless logout cannot revoke a copied token), gap-4 dev-store sizes dated, fleet section notes `data/` is gitignored. `api.py` — same `status` docstring imprecision fixed. Verified the 4 `DASH_*` rows, reset command, seed-wins rule and invariant 12 against `app.py`/`config.py`.

**Deviations**: no dependency bump (`uv pip list --outdated` advisory: certifi/fastapi/starlette — phase added none); verification notes appended to `data/logs/NOTES-auth.md` §10.

**Next pending phase**: `12_nut_self_test_verdict`.
This commit is contained in:
2026-10-19 07:45:44 -04:00
parent eb4788cba1
commit 1734d81e48
75 changed files with 6941 additions and 121 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