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
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user