Files
dashboards/.agents/phases/todo/11_admin_login/06_settings_page.md
T
ducoterra 046dc57b26 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
2026-10-17 12:27:40 -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