Files
dashboards/.agents/phases/complete/11_admin_login/06_settings_page.md
T
ducoterra 1734d81e48 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`.
2026-10-19 07:45:44 -04:00

6.0 KiB

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/as
  • 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