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
5.2 KiB
5.2 KiB
Task 05 — The login page
Phase: 11_admin_login · Source: operator request 2026-10-17 — "password protect this dashboard"
Story: n/a
Objective
A single-purpose page at /login that asks for the admin password, says honestly why it
refused, and lands the operator on / (or the page they were heading to). Hand-rolled HTML +
one small script, no CDN, dark/light parity with the dashboards.
Work
src/dashboards/web/pages/login.html— modelled on the other four pages'<head>(/static/dash.css, the same inline SVG favicon,data-theme="dark"soapp.jscan flip it fromlocalStoragebefore paint):- title
Homelab · sign in; the.mastheadbrand link, but no.tabsnav — the tabs lead to pages that would bounce straight back here, and a nav of links that all redirect is a lie about what is available. - one
.cardinmain:<h1>Sign in</h1>, a.subline naming what this protects ("the fleet's system, engine and battery dashboards — including the UPS controls"), then a form:<label for="admin-password">Admin password</label>+<input id="admin-password" type="password" autocomplete="current-password" required>+<button class="action" id="sign-in" type="submit">Sign in</button>. - a
<p id="login-error" class="login-error" role="alert" hidden>for refusals and<p id="login-note" class="faint mono" hidden>for the lockout countdown. - scripts:
/static/app.jsthen/static/page-login.js,defer. - no username field: one admin, and a field that is always the same word teaches the
operator that half the form is theatre (
auth.ADMIN_USERis the server's business).
- title
src/dashboards/web/static/page-login.js— IIFE in the house style (seepage-index.js), no framework:submithandler →POST /api/auth/loginwithDash.fetchJSON(it adds the JSON accept header and turns a non-2xx into a thrownErrorwithstatus/body); body{password};- on success
location.assign(body.next || '/'); - on
401showWrong password.and select the field's contents (fixing a typo without retyping is the whole UX of a login form); - on
429show thedetailplus theRetry-Afterseconds ticking down, and disable the button until it reaches 0 — a submit that will be refused should not look pressable; - on any other failure print the server's
detailverbatim through the escape path the app already uses for text (textContent, neverinnerHTML); - honour
?next=only if it starts with/and not//(the same rule the server enforces; a client-side copy is defence in depth, and the server's check is the one that counts); - if the browser already holds a valid session,
GET /api/auth/statussucceeds → redirect to/immediately (task 03 also does this server-side; this is what stops a flash of the form).
src/dashboards/web/static/dash.css:- extend the existing
input[type="text"]rule (line ~642) toinput[type="text"], input[type="password"]— do not duplicate it; - add
.login-card { max-width: 26rem; margin: 3rem auto; }and.login-error { color: var(--fail); },.login-card form { display: grid; gap: .6rem; }, plus.login-card label { font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); }(the same caption vocabulary as.hero .label); - only existing
:roottokens (--fail,--text-faint,--accent, …) —tests/test_api.pyfails if a rule reaches for a token:rootdoes not declare, and if a.sev-*family appears.
- extend the existing
- Keep the invariant: nothing external.
tests/test_api.pyalready asserts the pages load no foreign asset — extend that assertion to/login(same test, one more path).
Testing & Quality
tests/test_api.py(the served-HTML section) —/loginis served anonymously withHTTP/1.1 200, containsid="admin-password",type="password",autocomplete="current-password",role="alert", and no/system-style.tabs; nohttp://orhttps://asset reference in the served bytes.- Orphan-
idpin: every newid(admin-password,sign-in,login-error,login-note) must be referenced bypage-login.js(or the CSS) — that test fails on a deadid, so name what you use and use what you name. node --check src/dashboards/web/static/page-login.js(part ofvalidate.sh).- Coverage: no new Python; the served-bytes assertions are the tests. Do not add a JS test runner (a locked anchor).
Completion Criteria
- anonymous
GET /login→ 200 with the form; anonymousGET /→ 303 to/login?next=/ - in the dev server: wrong password shows
Wrong password.inline with no page reload;testpasslands on/and the header status dots go green - five wrong passwords then a correct one shows the lockout with a counting-down
Retry-Afterand a disabled button; it clears and the next attempt works /login?next=/upsafter a successful sign-in lands on/ups- dark and light both readable (screenshot both, or one plus a note in
NOTES-*.md) uv run pytestgreen;ruff+pyrightclean;bash .agents/validate.shgreen