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:
@@ -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