Files
dashboards/.agents/phases/todo/11_admin_login/05_login_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

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

  1. src/dashboards/web/pages/login.html — modelled on the other four pages' <head> (/static/dash.css, the same inline SVG favicon, data-theme="dark" so app.js can flip it from localStorage before paint):
    • title Homelab · sign in; the .masthead brand link, but no .tabs nav — 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 .card in main: <h1>Sign in</h1>, a .sub line 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.js then /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_USER is the server's business).
  2. src/dashboards/web/static/page-login.js — IIFE in the house style (see page-index.js), no framework:
    • submit handler → POST /api/auth/login with Dash.fetchJSON (it adds the JSON accept header and turns a non-2xx into a thrown Error with status/body); body {password};
    • on success location.assign(body.next || '/');
    • on 401 show Wrong password. and select the field's contents (fixing a typo without retyping is the whole UX of a login form);
    • on 429 show the detail plus the Retry-After seconds 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 detail verbatim through the escape path the app already uses for text (textContent, never innerHTML);
    • 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/status succeeds → redirect to / immediately (task 03 also does this server-side; this is what stops a flash of the form).
  3. src/dashboards/web/static/dash.css:
    • extend the existing input[type="text"] rule (line ~642) to input[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 :root tokens (--fail, --text-faint, --accent, …) — tests/test_api.py fails if a rule reaches for a token :root does not declare, and if a .sev-* family appears.
  4. Keep the invariant: nothing external. tests/test_api.py already 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) — /login is served anonymously with HTTP/1.1 200, contains id="admin-password", type="password", autocomplete="current-password", role="alert", and no /system-style .tabs; no http:// or https:// asset reference in the served bytes.
  • Orphan-id pin: every new id (admin-password, sign-in, login-error, login-note) must be referenced by page-login.js (or the CSS) — that test fails on a dead id, so name what you use and use what you name.
  • node --check src/dashboards/web/static/page-login.js (part of validate.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; anonymous GET / → 303 to /login?next=/
  • in the dev server: wrong password shows Wrong password. inline with no page reload; testpass lands on / and the header status dots go green
  • five wrong passwords then a correct one shows the lockout with a counting-down Retry-After and a disabled button; it clears and the next attempt works
  • /login?next=/ups after a successful sign-in lands on /ups
  • dark and light both readable (screenshot both, or one plus a note in NOTES-*.md)
  • uv run pytest green; ruff + pyright clean; bash .agents/validate.sh green