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
6.0 KiB
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
src/dashboards/app.py— add"/settings": "settings.html"toPAGES. 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.src/dashboards/web/pages/settings.html— same<head>/masthead as the other pages,.tabsnav withoutaria-current(Settings is not one of the four dashboards), and inmain:- Session panel:
<dl>with the signed-in name, the expiry as a local timestamp and "in 29d 14h" viaDash.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 anaria-describedbyhint 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
.subparagraph stating the precedence rule in the app's own voice: the password was seeded fromDASH_ADMIN_PASSWORDat first start, changing it here takes precedence, the env var never overwrites a password set here, and resetting from the environment means deleting theauth.verifierrow 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.
- Session panel:
src/dashboards/web/static/app.js:resetAuth()— clears the memoisedauthState/promise added in task 03, exported in thereturn { … }list. Needed twice: after a password change (the secret rotated, so the cachedcsrfis 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
401bounces to/login(the flag from task 03 already stops the redirect storm).
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; thenPOST /api/auth/passwordwith{current, new}; on success write "Password changed. Other browsers are signed out." into#settings-result,Dash.resetAuth()(so the next write re-reads the freshcsrf), clear all three fields (form.reset()plus explicit blanking — password managers autofill them), and keep the session cookie the response set; on403print "current password is wrong", on422print the server'sdetail, on429the lockout text — every message viatextContent; - after a successful change, verify once by calling
/api/auth/statusagain so the panel cannot show a session the server no longer honours.
- on load
- 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>. Noid(the orphan-idtest intests/test_api.pyflags ids nothing references — an<a href>needs none), and it must sit outside.tabsso the pinned tab structure of the four dashboards is untouched. Add.ghoststyling foraalongside the existingbutton.ghostrule (extend its selector toa.ghostrather than duplicating; existing tokens only). src/dashboards/web/static/page-login.js— a sign-in that carries?next=/settingsis a normal path once the session dies on the settings page; task 05'snexthandling covers it, just verify it in the browser.
Testing & Quality
tests/test_api.py(served-bytes section, signed-in client) —GET /settingsis200and containsid="new-password",autocomplete="new-password",id="sign-out",role="status"; anonymousGET /settingsis401/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_settingsover the four page paths;/settingscarries no external asset; the orphan-idtest still passes with the four new ids.node --checkforpage-settings.jsandapp.js(invalidate.sh).- No new Python except the one
PAGESline, so coverage moves only throughapp.py's existing route tests; keep the overall gate (94%) where it is.
Completion Criteria
- in a browser: the
⚙ Accountlink appears on all four dashboards and opens/settings - signing out from
/settingsreturns to/login, and/systemafterwards bounces to/loginrather than rendering an empty page ofn/as - changing the password on
/settingsshows 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 isresetAuth()doing its job - the same change in a second browser tab signed in earlier shows the 401 →
/loginpath /settingsrenders correctly in dark and light; screenshotsdata/logs/auth-settings.pnganddata/logs/auth-account-link.pnguv run pytestgreen;ruff+pyrightclean;bash .agents/validate.shgreen