From 59c19291ca07f8fc005065c825fdb4281178b0b4 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 17:39:49 +0000 Subject: [PATCH 01/31] docs: operator-auth design spec (v1 password login + roles + easy reset; 2FA/operator-role deferred) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-17-operator-auth-design.md | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-17-operator-auth-design.md diff --git a/docs/superpowers/specs/2026-06-17-operator-auth-design.md b/docs/superpowers/specs/2026-06-17-operator-auth-design.md new file mode 100644 index 0000000..dc5485f --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-operator-auth-design.md @@ -0,0 +1,266 @@ +# Operator Authentication — Design & Build Plan + +**Status:** in development (`feat/operator-auth`) · **Targets:** 0.15.x · **Date:** 2026-06-17 + +Adds a login + roles to the **internal** Terra-View app — the operator-facing +surface that today has **zero auth**. This is the prerequisite that makes the app +safe to expose to the internet (the office-deployment sequencing: operator auth → +expose). Expands the "Deferred A" section of +[2026-06-15-portal-auth-design.md](2026-06-15-portal-auth-design.md) into a +standalone spec. + +## Goal + +Anyone reaching the internal app must log in. Three known users to start (you + +two parents), two effective roles, and a **dead-simple password-reset story** for a +family-run shop. Reuses the building blocks the client portal already shipped: the +argon2 hasher (`backend/auth_passwords.py`) and the HMAC signed-cookie pattern +(`backend/portal_auth.py`). + +## Scope + +**v1 (this spec):** email + password login (argon2) · long-lived "remember this +device" session · brute-force lockout · a **deny-by-default gate** over the whole +internal app · `superadmin`/`admin` roles · **superadmin-only user management** · +**password reset** (superadmin-resets-anyone + self-service change + forced change) +· a **seed CLI** to bootstrap · the `OPERATOR_AUTH_ENABLED` **feature-flag rollout**. + +**Deferred (designed-not-built):** TOTP 2FA (near-term follow-up, `superadmin` +account first) · the `operator` restricted role · email-based self-service +password reset (needs the email infra coming with the report work). + +## Principles + +1. **Deny by default.** Every route requires a login *except* an explicit allow-list. + A route added next year is protected automatically — you can't forget to gate it. +2. **Can't lock yourself out.** Ship dark behind a feature flag; seed + verify before + enforcing; the flag is an instant escape hatch; a CLI is the break-glass. +3. **Reuse, don't reinvent.** argon2 + the signed-cookie HMAC already exist and are + tested. Operator auth is a thin new layer, not a parallel crypto stack. +4. **Easy recovery.** For a 3-person shop, "forgot my password" must be a 10-second + fix — the superadmin resets it, no email round-trip required. + +## Architecture + +``` + OPERATOR_AUTH_ENABLED=false ──▶ pass everything (app as today) + request ──▶ gate middleware ─┤ + └ enabled ─▶ path exempt? ──yes──▶ serve (no login) + │ exempt: /login /logout /health + │ /static/* /portal/* + 3 machine endpoints + └no─▶ valid operator session? + ├ no ─▶ HTML: 303 → /login?next=… + │ /api/*: 401 JSON + ├ must_change_password ─▶ 303 → /change-password + └ yes ─▶ request.state.operator = user + ─▶ route runs; require_role() may 403 +``` + +One **Starlette HTTP middleware** is the gate (not per-route dependencies — a +middleware can't miss a route). It resolves the operator from the cookie using its +own `SessionLocal()` (same pattern the portal WS handler uses), stashes the user on +`request.state.operator`, and a `require_role(...)` dependency reads it for the few +routes that need more than "logged in." + +## Data model + +New table **`operator_users`** (brand-new → `create_all` builds it on startup, **no +migration needed**, same as the portal's `clients` table): + +| Column | Type | Notes | +|---|---|---| +| `id` | str UUID | caller-supplied `str(uuid.uuid4())` (codebase convention) | +| `email` | str, unique, indexed | login handle, stored lowercased | +| `display_name` | str | "Brian", "Dad" — shown in UI + history | +| `password_hash` | str | argon2id via `auth_passwords.hash_password` | +| `role` | str | `"superadmin"` \| `"admin"` (`"operator"` reserved, deferred) | +| `active` | bool, default True | disable a login without deleting | +| `must_change_password` | bool, default False | set on create/reset → forces a change on next login | +| `sessions_valid_from` | datetime, default `utcnow` | bump to invalidate ALL of a user's sessions | +| `failed_login_count` | int, default 0 | lockout counter | +| `locked_until` | datetime, nullable | set after too many bad tries | +| `created_at` | datetime, default `utcnow` | | +| `last_login_at` | datetime, nullable | | + +(Deferred columns, not in v1: `totp_secret`, `totp_enabled`.) + +**Role ladder** — a rank map so checks read naturally and `operator` slots in later: +```python +_ROLE_RANK = {"operator": 10, "admin": 20, "superadmin": 30} +``` +`require_role("admin")` = admin or above; `require_role("superadmin")` for account mgmt. + +## Sessions + +**New shared module `backend/auth_cookies.py`** — lift the generic signer out so both +auth systems share one implementation: +```python +def sign(payload: dict) -> str # f"{b64url(json)}.{hmac_sha256(b64, SECRET_KEY)}" +def read(raw: str, max_age: int) -> dict | None # verify sig (compare_digest) + iat expiry; None on tamper/expiry +SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me") # same env the portal reads +COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false") in truthy +``` +Operator auth uses it now. (Portal's existing cookie helpers keep working untouched; +migrating them onto `auth_cookies` is an optional later dedupe, gated on the portal +tests staying green — don't destabilize the shipped portal for it.) + +**Operator session cookie:** name **`tv_session`** (distinct from the portal's +`portal_session`), payload `{"uid": , "iat": }`, `max_age` 30 days +(= the "remember this device" — a small trusted set re-logs in rarely), `httponly`, +`samesite=lax`, `secure=COOKIE_SECURE`. + +**Validation each request** (`current_operator(request, db)`): read+verify cookie → +load `OperatorUser` by `uid` → require `active`, `iat >= sessions_valid_from` +(epoch), and not `locked_until > now`. Any failure → no session. Bumping +`sessions_valid_from` (on password change / "log out everywhere") instantly kills all +live cookies with no session table. + +## Authorization + +**The gate (middleware) exempt list:** +- `/login`, `/logout`, `/health`, `/static/*`, plus PWA assets + (`/manifest.json`, `/sw.js`, `/favicon.ico`) +- `/portal/*` — the client portal keeps its own (separate) auth +- **machine endpoints (LAN-only, automated, no human):** `/emitters/report`, + `/api/series3/heartbeat`, `/api/series4/heartbeat` + +`/change-password` is **not** exempt — it requires a logged-in session (you change +*your own* password). It's only *excluded from the `must_change_password` redirect*, +so a forced-change user can actually reach it (no redirect loop). + +**Permission split — minimal by design.** Because the `operator` role is deferred, +every real v1 user is `admin` or `superadmin`, so "logged in" already means "full +app." The *only* thing gated above plain-admin is **account management** → +`require_role("superadmin")` on the user-management routes. Everything else just +requires a valid session (the middleware). One extra guard, not a sprawling matrix. + +**The flag governs everything.** Both the middleware *and* `require_role` respect +`OPERATOR_AUTH_ENABLED`: when it's off, neither enforces anything (no session is set, +and `require_role` passes through) — the app behaves exactly as it does today. When +it's on, the middleware guarantees `request.state.operator` is set before any +`require_role` check runs. + +## Password management & reset *(the emphasized requirement)* + +Three paths, no email infra required: + +1. **Superadmin resets anyone** — from the user-management UI, "Reset password" → + generates a strong password (`auth_passwords.generate_password`), stores its hash, + sets `must_change_password=True`, **shows the temp password once** for you to hand + off. Covers "easy for *me* to reset *their* password." +2. **Self-service change** — `/change-password` (any logged-in user): current + new. + Used for routine changes **and** the forced post-reset change. On success, bump + `sessions_valid_from` (logs out other devices) and clear `must_change_password`. +3. **Forced change** — after a reset/first login, `must_change_password=True` → the + gate routes them to `/change-password` until they set their own. + +**Forgot it entirely (can't log in):** v1 has **no email reset** — `/login` shows +"Forgot your password? Contact your administrator," and you (superadmin) reset it via +the UI or CLI. For a 3-person shop that's a text message, not a feature. (Email-based +self-service is the deferred follow-up once email infra lands.) + +## Bootstrapping — seed CLI + +`backend/operator_admin.py` (modeled on the existing `portal_admin.py`), run inside +the container against the live DB: +``` +create-superadmin --email you@x.com --name "Brian" # prompts for a password (or --generate) +create-user --email dad@x.com --name "Dad" --role admin # generates a temp password, must_change=True +reset-password --email dad@x.com # generates a temp, must_change=True +list # users + roles + active/locked state +disable --email dad@x.com / enable --email dad@x.com +``` +The CLI is the bootstrap (first superadmin, before any UI is reachable) **and** the +break-glass (locked out / forgot everything). + +## Account-management UI (superadmin-only) + +`GET /admin/users` (page, `require_role("superadmin")`) + JSON endpoints: +- list operators (name, email, role, active, locked, last login) +- add operator (email, name, role) → temp password shown once +- reset password → temp shown once +- enable / disable, change role +Template `templates/admin/users.html`. Admins (parents) don't see this; superadmin only. + +## Login / logout / change-password + +- `GET /login` → `templates/login.html` (email + password, optional `?next=`). +- `POST /login` → lowercase email, lockout check, argon2 verify; on success set + `tv_session`, stamp `last_login_at`, clear `failed_login_count`, redirect to `next` + or `/`; on `must_change_password` → `/change-password`; on fail → increment + + generic "invalid email or password" (no user-enumeration), lock after 5 → 15 min. +- `GET /logout` → clear cookie → `/login`. +- `GET/POST /change-password` → `templates/change_password.html`. + +## Error handling + +- Wrong email/password → generic message, increment fail count. +- ≥5 fails → "too many attempts, try again in 15 minutes" (`locked_until`). +- No/expired/forged cookie → HTML routes 303→`/login?next=…`; `/api/*` → 401 JSON. +- Disabled / role-changed / password-changed-elsewhere → bounced on next request + (re-validated against the DB every request). +- Superadmin-only route hit by an admin → 403. + +## Rollout — the no-self-lockout sequence + +1. Ship with `OPERATOR_AUTH_ENABLED=false` (default) → the middleware short-circuits, + app behaves **exactly as today**. Deploying can't break or lock anything. +2. Seed your `superadmin` via `operator_admin.py`. +3. Hit `/login` and confirm you get a session **while the flag is still off** (the + login routes work regardless of the flag). +4. Flip `OPERATOR_AUTH_ENABLED=true` → the gate enforces. Your cookie is valid → you're + in. Anything wrong → flip it back off (instant escape hatch). +5. Create your parents' accounts from `/admin/users` (temp passwords, they change on + first login). +- **Break-glass:** `operator_admin.py reset-password` / `create-superadmin` in the + container; or flag off. + +## Testing + +Reuses the pytest harness from the portal work (`docker exec … python -m pytest`). +- **Middleware:** flag off → every path passes; flag on → exempt paths + the 3 machine + endpoints pass with no cookie, a gated HTML path 303s to `/login`, a gated `/api/*` + path 401s, `must_change_password` user is routed to `/change-password`. +- **Login:** success sets `tv_session`; wrong password rejected + counts; 5 wrong → + locked (even correct password refused). +- **Roles:** `require_role("superadmin")` route → admin gets 403, superadmin 200. +- **Sessions:** bumping `sessions_valid_from` invalidates an existing cookie. +- **Password:** self-change works + clears `must_change_password`; superadmin reset + sets a new hash + `must_change_password` + returns the raw once. +- **Machine endpoints:** `/api/series3/heartbeat` etc. still 200 with the gate ON and + no cookie (regression guard so we never silently break the watchers). + +## File structure + +| File | Responsibility | +|---|---| +| `backend/auth_cookies.py` *(new)* | generic `sign`/`read` + `SECRET_KEY`/`COOKIE_SECURE` | +| `backend/models.py` | add `OperatorUser` | +| `backend/operator_auth.py` *(new)* | `current_operator`, `require_role`, the gate middleware, login/lockout helpers | +| `backend/routers/operator_auth_routes.py` *(new)* | `/login`, `/logout`, `/change-password` | +| `backend/routers/operator_users.py` *(new)* | `/admin/users` page + CRUD (superadmin) | +| `backend/operator_admin.py` *(new)* | seed/break-glass CLI | +| `backend/main.py` | register the gate middleware + routers; `OPERATOR_AUTH_ENABLED` | +| `templates/login.html`, `templates/change_password.html`, `templates/admin/users.html` *(new)* | UI | + +## Going to prod + +- New table auto-creates; **no migration**. Just code + seeding. +- Set a real `SECRET_KEY` (shared with the portal cookie) and `COOKIE_SECURE=true` + once on HTTPS — same env knobs already wired in `docker-compose.yml`. +- Operator auth is what makes internet-exposing the internal app safe; pair with the + (deferred) office deployment + reverse-proxy/TLS work. + +## Security notes + +- Deny-by-default; client-supplied ids never trusted; every request re-validates the + session against the DB (instant revoke via `active` / `sessions_valid_from`). +- Passwords argon2-hashed; generic login errors (no user-enumeration); lockout on + brute force; raw temp passwords shown once, never stored or logged. +- Cookies `HttpOnly` + `SameSite=Lax` + `Secure` (on TLS), HMAC-signed with server-side + `iat` expiry. +- **Known residual until deploy:** without TLS the password crosses the wire in + cleartext — fix is the deployment-phase TLS (Synology Let's Encrypt / Cloudflare + Tunnel). The login is still a massive improvement over today's zero-auth exposure. +- TOTP 2FA is the near-term follow-up (superadmin first), especially without the UniFi + edge in front on the home network. From 37e6ca55c1294fa14d9a1026d69898bcbee869a8 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 18:57:26 +0000 Subject: [PATCH 02/31] docs: operator-auth implementation plan (10 TDD tasks) --- .../plans/2026-06-17-operator-auth.md | 1801 +++++++++++++++++ 1 file changed, 1801 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-17-operator-auth.md diff --git a/docs/superpowers/plans/2026-06-17-operator-auth.md b/docs/superpowers/plans/2026-06-17-operator-auth.md new file mode 100644 index 0000000..4282327 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-operator-auth.md @@ -0,0 +1,1801 @@ +# Operator Authentication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a login + roles to the internal Terra-View app (which today has zero auth), gated behind a `OPERATOR_AUTH_ENABLED` feature flag, with a dead-simple superadmin-driven password-reset story. + +**Architecture:** One deny-by-default Starlette HTTP middleware gates every route except an explicit allow-list (login/logout/health/static/portal + 3 machine endpoints). It resolves an `OperatorUser` from a signed `tv_session` cookie (re-validated against the DB every request) and stashes it on `request.state.operator`; a `require_role()` dependency reads it for the superadmin-only user-management routes. Reuses the portal's argon2 hasher and HMAC-signed-cookie pattern. The flag governs both the middleware and `require_role`, so shipping with it off behaves exactly like today. + +**Tech Stack:** FastAPI + Starlette middleware, SQLAlchemy/SQLite, argon2-cffi (already a dep), stdlib `hmac`/`hashlib`, Jinja2, pytest (existing harness). + +**Spec:** `docs/superpowers/specs/2026-06-17-operator-auth-design.md` + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `backend/auth_cookies.py` *(new)* | generic `sign(payload)` / `read(raw, max_age)` signer + `SECRET_KEY` / `COOKIE_SECURE` | +| `backend/models.py` *(modify)* | add `OperatorUser` table + `_utcnow_seconds` helper | +| `backend/operator_auth.py` *(new)* | flag, role ranks, cookie helpers, `current_operator`, `authenticate`, lockout, data helpers (create/reset/role/active/change), the gate middleware, `require_role` | +| `backend/routers/operator_auth_routes.py` *(new)* | `/login`, `/logout`, `/change-password` | +| `backend/routers/operator_users.py` *(new)* | `/admin/users` page + CRUD JSON (superadmin) | +| `backend/operator_admin.py` *(new)* | seed / break-glass CLI | +| `backend/main.py` *(modify)* | register the gate middleware + the two routers | +| `templates/login.html`, `templates/change_password.html` *(new)* | standalone (no nav) auth pages | +| `templates/admin/users.html` *(new)* | user-management page (extends base.html) | +| `tests/conftest.py` *(modify)* | add `wire_operator_auth()` helper | +| `tests/test_operator_*.py` *(new)* | per-task test files | + +**Conventions discovered (follow these exactly):** +- UUID PKs: `id = Column(String, primary_key=True, index=True)` set via `str(uuid.uuid4())`. +- argon2 helpers live in `backend/auth_passwords.py`: `hash_password(raw)`, `verify_password(raw, hashed)` (never raises), `generate_password(n_bytes=12)`. +- Tests run in the dev container: `docker exec terra-view-terra-view-1 python -m pytest tests/ -v`. Source is bind-mounted, so no rebuild between edits. +- The gate middleware reads the DB through a module-level `SessionLocal` (same pattern the portal WS handler uses) so tests can monkeypatch it. `db_session.get_bind()` returns the test engine — bind a `sessionmaker` to it. +- The flag and `SessionLocal` are read as **module globals at call time**, so `monkeypatch.setattr(backend.operator_auth, "...", ...)` works without re-importing. + +--- + +## Task 1: Generic signed-cookie module (`auth_cookies.py`) + +Lift the portal's HMAC signer into a shared, payload-agnostic module the operator auth uses now (the portal keeps its own helpers untouched). + +**Files:** +- Create: `backend/auth_cookies.py` +- Test: `tests/test_operator_cookies.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_operator_cookies.py +import time +from backend.auth_cookies import sign, read + + +def test_sign_then_read_round_trips(): + raw = sign({"uid": "abc", "iat": 1000}) + data = read(raw, max_age=3600) + assert data == {"uid": "abc", "iat": 1000} + + +def test_tampered_signature_is_rejected(): + raw = sign({"uid": "abc", "iat": int(time.time())}) + body, _sig = raw.rsplit(".", 1) + assert read(body + ".deadbeef", max_age=3600) is None + + +def test_tampered_body_is_rejected(): + raw = sign({"uid": "abc", "iat": int(time.time())}) + body, sig = raw.rsplit(".", 1) + import base64, json + forged = base64.urlsafe_b64encode(json.dumps({"uid": "evil", "iat": int(time.time())}).encode()).decode() + assert read(forged + "." + sig, max_age=3600) is None + + +def test_expired_by_iat_is_rejected(): + raw = sign({"uid": "abc", "iat": int(time.time()) - 10_000}) + assert read(raw, max_age=3600) is None + + +def test_garbage_input_is_none_not_raise(): + assert read("not-a-cookie", max_age=3600) is None + assert read("", max_age=3600) is None + assert read(None, max_age=3600) is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_cookies.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'backend.auth_cookies'` + +- [ ] **Step 3: Write the implementation** + +```python +# backend/auth_cookies.py +"""Generic HMAC-signed cookie payloads, shared by operator auth (and, optionally +later, the portal). A signed value is f"{b64url(json)}.{hmac_sha256(b64)}"; read() +verifies the signature in constant time and enforces a server-side iat expiry. + +The signing secret is the same SECRET_KEY the portal already reads, so a single +env var protects both cookies. Never store or log raw secrets.""" +import os +import hmac +import json +import time +import base64 +import hashlib +import logging + +logger = logging.getLogger(__name__) + +# Same env var the portal cookie uses — one secret protects both. The insecure +# default only exists so dev/test boots without config; set a real SECRET_KEY in prod. +SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me") +# Set COOKIE_SECURE=true once served over HTTPS; leave false on plain HTTP or the +# browser won't send the cookie. +COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes") + + +def _sign(body: str) -> str: + return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest() + + +def sign(payload: dict) -> str: + """Serialize + sign a payload dict into a cookie-safe string.""" + body = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode() + return f"{body}.{_sign(body)}" + + +def read(raw, max_age: int): + """Verify a signed value and return its payload dict, or None if missing, + tampered, or older than max_age seconds (by its own `iat`).""" + if not raw or not isinstance(raw, str): + return None + try: + body, sig = raw.rsplit(".", 1) + except (ValueError, AttributeError): + return None + if not hmac.compare_digest(sig, _sign(body)): + return None + try: + data = json.loads(base64.urlsafe_b64decode(body.encode())) + except Exception: + return None + if not isinstance(data, dict): + return None + iat = data.get("iat") + if not isinstance(iat, (int, float)) or (time.time() - iat) > max_age: + return None + return data +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_cookies.py -v` +Expected: PASS (5 passed) + +- [ ] **Step 5: Commit** + +```bash +git add backend/auth_cookies.py tests/test_operator_cookies.py +git commit -m "feat(auth): generic HMAC signed-cookie module for operator auth" +``` + +--- + +## Task 2: `OperatorUser` model + role-rank helpers + +The new table (auto-created by `Base.metadata.create_all`, no migration) and the role ladder. + +**Files:** +- Modify: `backend/models.py` (add `_utcnow_seconds` near the top imports, and `OperatorUser` at end of file) +- Create: `backend/operator_auth.py` (start it with constants + `role_at_least`) +- Test: `tests/test_operator_model.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_operator_model.py +import uuid +from backend.models import OperatorUser +from backend.operator_auth import role_at_least, _ROLE_RANK + + +def test_operator_user_defaults(db_session): + u = OperatorUser(id=str(uuid.uuid4()), email="a@x.com", display_name="A", + password_hash="h", role="admin") + db_session.add(u) + db_session.commit() + got = db_session.query(OperatorUser).filter_by(email="a@x.com").first() + assert got.active is True + assert got.must_change_password is False + assert got.failed_login_count == 0 + assert got.locked_until is None + assert got.sessions_valid_from is not None + assert got.sessions_valid_from.microsecond == 0 # truncated to whole seconds + + +def test_email_is_unique(db_session): + for i in range(2): + db_session.add(OperatorUser(id=str(uuid.uuid4()), email="dup@x.com", + display_name="d", password_hash="h", role="admin")) + import pytest + with pytest.raises(Exception): + db_session.commit() + + +def test_role_ladder(): + assert _ROLE_RANK == {"operator": 10, "admin": 20, "superadmin": 30} + assert role_at_least("superadmin", "admin") is True + assert role_at_least("admin", "admin") is True + assert role_at_least("admin", "superadmin") is False + assert role_at_least("operator", "admin") is False + assert role_at_least("nonsense", "admin") is False +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_model.py -v` +Expected: FAIL — `ImportError: cannot import name 'OperatorUser'` + +- [ ] **Step 3a: Add the `_utcnow_seconds` helper to `backend/models.py`** + +Insert directly after the existing `from datetime import datetime` line (line 2) and before `from backend.database import Base`: + +```python +def _utcnow_seconds(): + """utcnow truncated to whole seconds — used as the default for + sessions_valid_from so a freshly-issued cookie (whose iat is a whole-second + epoch) never falls a few microseconds before it and self-invalidates.""" + return datetime.utcnow().replace(microsecond=0) +``` + +- [ ] **Step 3b: Append the `OperatorUser` model at the end of `backend/models.py`** + +```python +class OperatorUser(Base): + """An internal operator login. Roles: 'superadmin' (Brian, + account mgmt) and + 'admin' (parents, full app). 'operator' is reserved (deferred). Brand-new table + → create_all builds it, no migration. Never store or log raw passwords.""" + __tablename__ = "operator_users" + + id = Column(String, primary_key=True, index=True) # UUID + email = Column(String, nullable=False, unique=True, index=True) # login handle, lowercased + display_name = Column(String, nullable=False) # "Brian", "Dad" + password_hash = Column(String, nullable=False) # argon2id + role = Column(String, nullable=False, default="admin") # superadmin | admin + active = Column(Boolean, default=True) # False = login disabled + must_change_password = Column(Boolean, default=False) # forces a change next login + sessions_valid_from = Column(DateTime, default=_utcnow_seconds) # bump = log out everywhere + failed_login_count = Column(Integer, default=0) # lockout counter + locked_until = Column(DateTime, nullable=True) # set after too many bad tries + created_at = Column(DateTime, default=datetime.utcnow) + last_login_at = Column(DateTime, nullable=True) +``` + +- [ ] **Step 3c: Create `backend/operator_auth.py` with constants + `role_at_least`** + +```python +# backend/operator_auth.py +"""Operator authentication: the deny-by-default gate, session cookie, login + +lockout, and the small data helpers shared by the routes and the CLI. Reuses the +argon2 hasher (auth_passwords) and the HMAC signer (auth_cookies). + +The flag and SessionLocal are read as module globals at call time so tests can +monkeypatch them.""" +import os +import time +import uuid +from datetime import datetime, timedelta + +from backend.models import OperatorUser +from backend.auth_passwords import hash_password, verify_password, generate_password + +# Feature flag — OFF by default. When off, the gate and require_role both pass +# everything through and the app behaves exactly as it does today. +OPERATOR_AUTH_ENABLED = os.getenv("OPERATOR_AUTH_ENABLED", "false").lower() in ("1", "true", "yes") + +COOKIE_NAME = "tv_session" +COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days ("remember this device") +MAX_LOGIN_FAILURES = 5 +LOCK_MINUTES = 15 + +# Role ladder — a rank map so checks read naturally and 'operator' slots in later. +_ROLE_RANK = {"operator": 10, "admin": 20, "superadmin": 30} + + +def role_at_least(role: str, minimum: str) -> bool: + """True iff `role` ranks at or above `minimum`. Unknown roles rank as 0.""" + return _ROLE_RANK.get(role, 0) >= _ROLE_RANK[minimum] + + +def _norm_email(email: str) -> str: + return (email or "").strip().lower() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_model.py -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Commit** + +```bash +git add backend/models.py backend/operator_auth.py tests/test_operator_model.py +git commit -m "feat(auth): OperatorUser model + role ladder" +``` + +--- + +## Task 3: Session cookie helpers + `current_operator` + +Mint/validate the `tv_session` cookie and resolve the operator from a request, re-validating against the DB every call. + +**Files:** +- Modify: `backend/operator_auth.py` (append helpers) +- Test: `tests/test_operator_session.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_operator_session.py +import time +import uuid +from datetime import datetime, timedelta +from types import SimpleNamespace + +from backend.models import OperatorUser +from backend.operator_auth import ( + make_operator_cookie, current_operator, COOKIE_NAME, +) + + +def _make_user(db, **kw): + u = OperatorUser(id=str(uuid.uuid4()), email=kw.pop("email", "u@x.com"), + display_name="U", password_hash="h", role=kw.pop("role", "admin"), **kw) + db.add(u) + db.commit() + return u + + +def _req(cookie_value): + # current_operator only reads request.cookies — a stub is enough. + return SimpleNamespace(cookies={COOKIE_NAME: cookie_value} if cookie_value else {}) + + +def test_valid_cookie_resolves_user(db_session): + u = _make_user(db_session) + cookie = make_operator_cookie(u.id) + assert current_operator(_req(cookie), db_session).id == u.id + + +def test_no_or_garbage_cookie_is_none(db_session): + assert current_operator(_req(None), db_session) is None + assert current_operator(_req("garbage"), db_session) is None + + +def test_inactive_user_is_none(db_session): + u = _make_user(db_session, active=False) + assert current_operator(_req(make_operator_cookie(u.id)), db_session) is None + + +def test_locked_user_is_none(db_session): + u = _make_user(db_session, locked_until=datetime.utcnow() + timedelta(minutes=5)) + assert current_operator(_req(make_operator_cookie(u.id)), db_session) is None + + +def test_cookie_older_than_sessions_valid_from_is_none(db_session): + u = _make_user(db_session) + old_iat = int(time.time()) - 1000 + cookie = make_operator_cookie(u.id, iat=old_iat) + u.sessions_valid_from = datetime.utcnow() + db_session.commit() + assert current_operator(_req(cookie), db_session) is None + + +def test_cookie_minted_with_matching_iat_after_bump_still_valid(db_session): + # Guards the change-password race: bump sessions_valid_from to the new cookie's + # exact iat → that fresh cookie must remain valid. + u = _make_user(db_session) + new_iat = int(time.time()) + u.sessions_valid_from = datetime.utcfromtimestamp(new_iat) + db_session.commit() + assert current_operator(_req(make_operator_cookie(u.id, iat=new_iat)), db_session).id == u.id +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_session.py -v` +Expected: FAIL — `ImportError: cannot import name 'make_operator_cookie'` + +- [ ] **Step 3: Append the helpers to `backend/operator_auth.py`** + +```python +from backend.auth_cookies import sign, read, COOKIE_SECURE # add to the import block at top + + +def make_operator_cookie(uid: str, iat: int = None) -> str: + """Sign a tv_session value for a user id. iat defaults to now; pass an explicit + iat when you bump sessions_valid_from to that same instant (change-password).""" + return sign({"uid": uid, "iat": int(iat if iat is not None else time.time())}) + + +def current_operator(request, db): + """Resolve the OperatorUser for a request's tv_session cookie, or None. + Re-validated against the DB every call: a disabled / locked / password-changed + user drops on the next request. Used by the gate middleware (with its own + session) — does not raise.""" + data = read(request.cookies.get(COOKIE_NAME), COOKIE_MAX_AGE) + if not data: + return None + uid, iat = data.get("uid"), data.get("iat") + if not uid or not isinstance(iat, (int, float)): + return None + user = db.query(OperatorUser).filter_by(id=uid).first() + if not user or not user.active: + return None + if user.locked_until and user.locked_until > datetime.utcnow(): + return None + if user.sessions_valid_from and datetime.utcfromtimestamp(int(iat)) < user.sessions_valid_from: + return None + return user +``` + +NOTE: move the `from backend.auth_cookies import sign, read, COOKIE_SECURE` line up into the import block at the top of the file (shown inline here only to mark the dependency). `COOKIE_SECURE` is imported now because later tasks (login route) set the cookie with it. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_session.py -v` +Expected: PASS (6 passed) + +- [ ] **Step 5: Commit** + +```bash +git add backend/operator_auth.py tests/test_operator_session.py +git commit -m "feat(auth): operator session cookie + current_operator DB re-validation" +``` + +--- + +## Task 4: `authenticate` + lockout + data helpers + +Login verification with brute-force lockout, plus the create/reset/role/active/change helpers shared by the routes and the CLI. + +**Files:** +- Modify: `backend/operator_auth.py` (append) +- Test: `tests/test_operator_authenticate.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_operator_authenticate.py +import time +from datetime import datetime +import pytest + +from backend.operator_auth import ( + authenticate, create_operator, reset_operator_password, + set_operator_active, set_operator_role, change_own_password, MAX_LOGIN_FAILURES, +) +from backend.auth_passwords import verify_password +from backend.models import OperatorUser + + +def test_create_operator_generates_temp_and_forces_change(db_session): + user, raw = create_operator(db_session, "Dad@X.com", "Dad", "admin") + assert user.email == "dad@x.com" # lowercased + assert user.must_change_password is True + assert verify_password(raw, user.password_hash) + + +def test_create_operator_with_explicit_password_no_forced_change(db_session): + user, raw = create_operator(db_session, "brian@x.com", "Brian", "superadmin", password="chosen-pw-123") + assert raw == "chosen-pw-123" + assert user.must_change_password is False + + +def test_create_operator_rejects_duplicate_and_bad_role(db_session): + create_operator(db_session, "a@x.com", "A", "admin") + with pytest.raises(ValueError): + create_operator(db_session, "A@x.com", "A2", "admin") # dup (case-insensitive) + with pytest.raises(ValueError): + create_operator(db_session, "b@x.com", "B", "wizard") # bad role + + +def test_authenticate_success(db_session): + user, raw = create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9") + got, status = authenticate(db_session, "OK@x.com", "rightpw-9") + assert status == "ok" and got.id == user.id + assert got.last_login_at is not None + assert got.failed_login_count == 0 + + +def test_authenticate_wrong_password_counts(db_session): + create_operator(db_session, "wp@x.com", "Wp", "admin", password="rightpw-9") + got, status = authenticate(db_session, "wp@x.com", "nope") + assert got is None and status == "bad" + assert db_session.query(OperatorUser).filter_by(email="wp@x.com").first().failed_login_count == 1 + + +def test_lockout_after_five_then_correct_password_refused(db_session): + create_operator(db_session, "lk@x.com", "Lk", "admin", password="rightpw-9") + for _ in range(MAX_LOGIN_FAILURES): + authenticate(db_session, "lk@x.com", "nope") + got, status = authenticate(db_session, "lk@x.com", "rightpw-9") # correct, but locked + assert got is None and status == "locked" + + +def test_authenticate_unknown_email_is_bad_not_error(db_session): + got, status = authenticate(db_session, "ghost@x.com", "whatever") + assert got is None and status == "bad" + + +def test_reset_password_sets_new_hash_forces_change_and_bumps_sessions(db_session): + user, _ = create_operator(db_session, "r@x.com", "R", "admin", password="orig-pw-1") + before = user.sessions_valid_from + raw = reset_operator_password(db_session, user) + assert verify_password(raw, user.password_hash) + assert user.must_change_password is True + assert user.sessions_valid_from >= before + + +def test_change_own_password_clears_flag_and_bumps(db_session): + user, _ = create_operator(db_session, "c@x.com", "C", "admin", password="orig-pw-1") + user.must_change_password = True + db_session.commit() + new_iat = change_own_password(db_session, user, "brand-new-pw-2") + assert verify_password("brand-new-pw-2", user.password_hash) + assert user.must_change_password is False + assert user.sessions_valid_from == datetime.utcfromtimestamp(new_iat) + + +def test_set_active_and_role(db_session): + user, _ = create_operator(db_session, "s@x.com", "S", "admin", password="orig-pw-1") + set_operator_active(db_session, user, False) + assert user.active is False + set_operator_role(db_session, user, "superadmin") + assert user.role == "superadmin" + with pytest.raises(ValueError): + set_operator_role(db_session, user, "wizard") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_authenticate.py -v` +Expected: FAIL — `ImportError: cannot import name 'authenticate'` + +- [ ] **Step 3: Append the helpers to `backend/operator_auth.py`** + +```python +def register_login_failure(db, user) -> None: + """Increment a user's failure counter and lock them out past the threshold.""" + user.failed_login_count = (user.failed_login_count or 0) + 1 + if user.failed_login_count >= MAX_LOGIN_FAILURES: + user.locked_until = datetime.utcnow() + timedelta(minutes=LOCK_MINUTES) + db.commit() + + +def authenticate(db, email, password): + """Return (user, "ok") on success, (None, "locked") if locked out, else + (None, "bad"). Never reveals whether the email exists (generic 'bad').""" + user = db.query(OperatorUser).filter_by(email=_norm_email(email)).first() + if user and user.locked_until and user.locked_until > datetime.utcnow(): + return None, "locked" + if not user or not user.active or not verify_password(password, user.password_hash): + if user: + register_login_failure(db, user) + return None, "bad" + user.failed_login_count = 0 + user.locked_until = None + user.last_login_at = datetime.utcnow() + db.commit() + return user, "ok" + + +def create_operator(db, email, name, role, password=None, must_change=None): + """Create an operator. With no password, generate a temp one and force a change + (must_change defaults True). With a password, must_change defaults False. + Returns (user, raw_password_to_show_once). Raises ValueError on dup/bad role.""" + email = _norm_email(email) + if role not in _ROLE_RANK: + raise ValueError(f"unknown role {role!r}") + if db.query(OperatorUser).filter_by(email=email).first(): + raise ValueError(f"operator {email} already exists") + if password is None: + password = generate_password() + if must_change is None: + must_change = True + elif must_change is None: + must_change = False + user = OperatorUser(id=str(uuid.uuid4()), email=email, display_name=name, + password_hash=hash_password(password), role=role, + active=True, must_change_password=must_change) + db.add(user) + db.commit() + return user, password + + +def reset_operator_password(db, user) -> str: + """Generate a fresh temp password, force a change, log the user out everywhere. + Returns the raw password to show once.""" + raw = generate_password() + user.password_hash = hash_password(raw) + user.must_change_password = True + user.failed_login_count = 0 + user.locked_until = None + user.sessions_valid_from = datetime.utcnow().replace(microsecond=0) + db.commit() + return raw + + +def change_own_password(db, user, new_password) -> int: + """Set a user's own new password, clear the forced-change flag, and bump + sessions_valid_from to the returned iat — the caller mints the replacement + cookie with that exact iat so it stays valid while older cookies die.""" + new_iat = int(time.time()) + user.password_hash = hash_password(new_password) + user.must_change_password = False + user.sessions_valid_from = datetime.utcfromtimestamp(new_iat) + db.commit() + return new_iat + + +def set_operator_active(db, user, active: bool): + user.active = bool(active) + db.commit() + return user + + +def set_operator_role(db, user, role: str): + if role not in _ROLE_RANK: + raise ValueError(f"unknown role {role!r}") + user.role = role + db.commit() + return user +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_authenticate.py -v` +Expected: PASS (10 passed) + +- [ ] **Step 5: Commit** + +```bash +git add backend/operator_auth.py tests/test_operator_authenticate.py +git commit -m "feat(auth): authenticate + lockout + operator data helpers" +``` + +--- + +## Task 5: Gate middleware + `require_role` + wire into the app + +The deny-by-default Starlette middleware and the role dependency. Registering the middleware with the flag defaulting OFF keeps every existing test green. + +**Files:** +- Modify: `backend/operator_auth.py` (append `operator_gate` + `require_role`) +- Modify: `backend/main.py` (register the middleware) +- Modify: `tests/conftest.py` (add `wire_operator_auth` helper) +- Test: `tests/test_operator_gate.py` + +- [ ] **Step 1: Add the `wire_operator_auth` helper to `tests/conftest.py`** + +Append at the end of `tests/conftest.py`: + +```python +def wire_operator_auth(monkeypatch, db_session, enabled=True): + """Point the gate middleware's SessionLocal at the test engine and flip the + flag. The middleware opens its OWN session (it can't use the get_db override), + so it must read the same engine the test writes to.""" + import backend.operator_auth as oa + from sqlalchemy.orm import sessionmaker + maker = sessionmaker(bind=db_session.get_bind(), autocommit=False, autoflush=False) + monkeypatch.setattr(oa, "SessionLocal", maker, raising=False) + monkeypatch.setattr(oa, "OPERATOR_AUTH_ENABLED", enabled, raising=False) + return oa +``` + +- [ ] **Step 2: Write the failing test** + +```python +# tests/test_operator_gate.py +import uuid +from tests.conftest import wire_operator_auth +from backend.models import OperatorUser +from backend.operator_auth import make_operator_cookie, COOKIE_NAME +from backend.auth_passwords import hash_password + + +def _make_user(db, role="admin", **kw): + u = OperatorUser(id=str(uuid.uuid4()), email=kw.pop("email", "u@x.com"), + display_name="U", password_hash=hash_password("pw"), role=role, **kw) + db.add(u) + db.commit() + return u + + +def test_flag_off_passes_everything(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=False) + assert client.get("/", follow_redirects=False).status_code == 200 + + +def test_gated_html_redirects_to_login_when_unauth(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.get("/", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"].startswith("/login?next=") + + +def test_gated_api_returns_401_json_when_unauth(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.get("/api/status-snapshot", follow_redirects=False) + assert r.status_code == 401 + + +def test_valid_session_passes(client, db_session, monkeypatch): + u = _make_user(db_session) + wire_operator_auth(monkeypatch, db_session, enabled=True) + client.cookies.set(COOKIE_NAME, make_operator_cookie(u.id)) + assert client.get("/", follow_redirects=False).status_code == 200 + + +def test_must_change_password_user_routed_to_change_password(client, db_session, monkeypatch): + u = _make_user(db_session, must_change_password=True) + wire_operator_auth(monkeypatch, db_session, enabled=True) + client.cookies.set(COOKIE_NAME, make_operator_cookie(u.id)) + r = client.get("/", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/change-password" + + +def test_exempt_paths_pass_without_cookie(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + assert client.get("/health", follow_redirects=False).status_code == 200 + assert client.get("/login", follow_redirects=False).status_code == 200 + + +def test_portal_paths_are_exempt(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + # /portal/p/ hits the portal's own gate (403/404), never the operator login. + r = client.get("/portal/p/nope", follow_redirects=False) + assert r.status_code in (403, 404) +``` + +- [ ] **Step 3: Append `operator_gate` + `require_role` to `backend/operator_auth.py`** + +Add these imports to the top of the file: + +```python +from urllib.parse import quote +from fastapi import Request, HTTPException +from fastapi.responses import JSONResponse, RedirectResponse +from backend.database import SessionLocal +``` + +Then append: + +```python +# Routes reachable with no login. A new route added next year is gated by default. +_EXEMPT_EXACT = { + "/login", "/logout", "/health", + "/manifest.json", "/sw.js", "/favicon.ico", "/offline-db.js", + "/portal", # portal home (its own auth) + # machine endpoints — LAN-only, automated, no human (watchers/heartbeats): + "/emitters/report", "/api/series3/heartbeat", "/api/series4/heartbeat", +} +_EXEMPT_PREFIX = ("/static/", "/portal/") + + +def _is_exempt(path: str) -> bool: + return path in _EXEMPT_EXACT or path.startswith(_EXEMPT_PREFIX) + + +async def operator_gate(request: Request, call_next): + """Deny-by-default gate. Flag off → pass through (app as today). Flag on → + exempt paths pass; otherwise require a valid operator session, stash it on + request.state.operator, and force a password change when pending.""" + if not OPERATOR_AUTH_ENABLED: + return await call_next(request) + + path = request.url.path + if _is_exempt(path): + return await call_next(request) + + db = SessionLocal() + try: + user = current_operator(request, db) + if user is not None: + db.expunge(user) # detach a fully-loaded row so we can close now + finally: + db.close() + + if user is None: + if path.startswith("/api/"): + return JSONResponse({"detail": "Not authenticated"}, status_code=401) + return RedirectResponse(f"/login?next={quote(path)}", status_code=303) + + if user.must_change_password and path not in ("/change-password", "/logout"): + return RedirectResponse("/change-password", status_code=303) + + request.state.operator = user + return await call_next(request) + + +def require_role(minimum: str): + """Dependency factory: require a logged-in operator ranked >= `minimum`. + Respects the flag (off → pass through). When on, the middleware has already + set request.state.operator before this runs.""" + def _dep(request: Request): + if not OPERATOR_AUTH_ENABLED: + return None + user = getattr(request.state, "operator", None) + if user is None: + raise HTTPException(status_code=401, detail="Not authenticated") + if not role_at_least(user.role, minimum): + raise HTTPException(status_code=403, detail="Insufficient permissions") + return user + return _dep +``` + +- [ ] **Step 4: Register the middleware in `backend/main.py`** + +After the existing `add_environment_to_context` middleware block (ends line 90), add: + +```python +# Operator auth — deny-by-default gate over the whole internal app. Governed by +# OPERATOR_AUTH_ENABLED (default off → behaves exactly as today). See +# docs/superpowers/specs/2026-06-17-operator-auth-design.md. +from backend.operator_auth import operator_gate +app.middleware("http")(operator_gate) +``` + +- [ ] **Step 5: Run tests to verify the gate works AND nothing regressed** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_gate.py tests/test_portal_gate.py -v` +Expected: PASS (gate tests pass; the 6 existing portal-gate tests still pass — flag defaults off) + +- [ ] **Step 6: Commit** + +```bash +git add backend/operator_auth.py backend/main.py tests/conftest.py tests/test_operator_gate.py +git commit -m "feat(auth): deny-by-default gate middleware + require_role" +``` + +--- + +## Task 6: Login / logout / change-password routes + templates + +The auth pages. These routes work regardless of the flag (you log in while the flag is still off during rollout) and are on the gate's exempt list (login/logout) — except `/change-password`, which requires a session. + +**Files:** +- Create: `backend/routers/operator_auth_routes.py` +- Create: `templates/login.html`, `templates/change_password.html` +- Modify: `backend/main.py` (include the router) +- Test: `tests/test_operator_login.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_operator_login.py +import uuid +from tests.conftest import wire_operator_auth +from backend.operator_auth import ( + create_operator, make_operator_cookie, COOKIE_NAME, MAX_LOGIN_FAILURES, +) + + +def test_login_page_renders(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.get("/login") + assert r.status_code == 200 + assert "password" in r.text.lower() + + +def test_login_success_sets_cookie_and_redirects(client, db_session, monkeypatch): + create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9") + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.post("/login", data={"email": "ok@x.com", "password": "rightpw-9"}, + follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/" + assert f"{COOKIE_NAME}=" in r.headers.get("set-cookie", "") + + +def test_login_honors_next(client, db_session, monkeypatch): + create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9") + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.post("/login?next=/settings", data={"email": "ok@x.com", "password": "rightpw-9"}, + follow_redirects=False) + assert r.headers["location"] == "/settings" + + +def test_login_wrong_password_no_cookie_generic_error(client, db_session, monkeypatch): + create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9") + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.post("/login", data={"email": "ok@x.com", "password": "nope"}, + follow_redirects=False) + assert r.status_code == 200 + assert f"{COOKIE_NAME}=" not in r.headers.get("set-cookie", "") + assert "invalid" in r.text.lower() + + +def test_login_must_change_redirects_to_change_password(client, db_session, monkeypatch): + create_operator(db_session, "new@x.com", "New", "admin") # generated temp → must_change + # fetch the raw temp by resetting to a known one + user = None + from backend.models import OperatorUser + user = db_session.query(OperatorUser).filter_by(email="new@x.com").first() + from backend.auth_passwords import hash_password + user.password_hash = hash_password("temp-pw-1") + db_session.commit() + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.post("/login", data={"email": "new@x.com", "password": "temp-pw-1"}, + follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/change-password" + + +def test_login_lockout_message_after_five(client, db_session, monkeypatch): + create_operator(db_session, "lk@x.com", "Lk", "admin", password="rightpw-9") + wire_operator_auth(monkeypatch, db_session, enabled=True) + for _ in range(MAX_LOGIN_FAILURES): + client.post("/login", data={"email": "lk@x.com", "password": "nope"}, follow_redirects=False) + r = client.post("/login", data={"email": "lk@x.com", "password": "rightpw-9"}, follow_redirects=False) + assert r.status_code == 200 + assert "too many" in r.text.lower() + + +def test_logout_clears_cookie(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.get("/logout", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/login" + # cookie cleared (deletion appears as an expired/empty set-cookie) + assert COOKIE_NAME in r.headers.get("set-cookie", "") + + +def test_change_password_self_service(client, db_session, monkeypatch): + user, _ = create_operator(db_session, "c@x.com", "C", "admin", password="orig-pw-1") + wire_operator_auth(monkeypatch, db_session, enabled=True) + client.cookies.set(COOKIE_NAME, make_operator_cookie(user.id)) + r = client.post("/change-password", + data={"current_password": "orig-pw-1", "new_password": "brand-new-2", + "confirm_password": "brand-new-2"}, follow_redirects=False) + assert r.status_code == 303 + from backend.auth_passwords import verify_password + db_session.refresh(user) + assert verify_password("brand-new-2", user.password_hash) + assert user.must_change_password is False +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_login.py -v` +Expected: FAIL — `/login` returns 404 / 303 (route doesn't exist yet) + +- [ ] **Step 3a: Create `templates/login.html`** + +```html + + + + + + Sign in · Terra-View + + + +
+

Terra-View

+ {% if error %} +
{{ error }}
+ {% endif %} +
+
+ + +
+
+ + +
+ +
+

Forgot your password? Contact your administrator.

+
+ + +``` + +- [ ] **Step 3b: Create `templates/change_password.html`** + +```html + + + + + + Change password · Terra-View + + + +
+

Change your password

+ {% if must_change %} +

Please set a new password to continue.

+ {% endif %} + {% if error %} +
{{ error }}
+ {% endif %} +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +``` + +- [ ] **Step 3c: Create `backend/routers/operator_auth_routes.py`** + +```python +"""Operator login / logout / change-password. These routes intentionally work +regardless of OPERATOR_AUTH_ENABLED (you log in while the flag is still off during +rollout). /login and /logout are on the gate's exempt list; /change-password +requires a session (the gate sets request.state.operator).""" +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.templates_config import templates +from backend.operator_auth import ( + authenticate, current_operator, change_own_password, make_operator_cookie, + COOKIE_NAME, COOKIE_MAX_AGE, +) +from backend.auth_cookies import COOKIE_SECURE +from backend.auth_passwords import verify_password + +router = APIRouter(tags=["operator-auth"]) + + +def _safe_next(next_url: str) -> str: + """Only allow same-site relative redirects (an open-redirect guard).""" + if next_url and next_url.startswith("/") and not next_url.startswith("//"): + return next_url + return "/" + + +@router.get("/login") +async def login_page(request: Request, next: str = "", error: str = ""): + return templates.TemplateResponse("login.html", + {"request": request, "next": next, "error": error}) + + +@router.post("/login") +async def login_submit(request: Request, next: str = "", + email: str = Form(...), password: str = Form(...), + db: Session = Depends(get_db)): + user, status = authenticate(db, email, password) + if status == "locked": + return templates.TemplateResponse( + "login.html", + {"request": request, "next": next, + "error": "Too many attempts — try again in 15 minutes."}, + status_code=200) + if user is None: + return templates.TemplateResponse( + "login.html", + {"request": request, "next": next, "error": "Invalid email or password."}, + status_code=200) + dest = "/change-password" if user.must_change_password else _safe_next(next) + resp = RedirectResponse(url=dest, status_code=303) + resp.set_cookie(COOKIE_NAME, make_operator_cookie(user.id), + max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE) + return resp + + +@router.get("/logout") +async def logout(request: Request): + resp = RedirectResponse(url="/login", status_code=303) + resp.delete_cookie(COOKIE_NAME) + return resp + + +@router.get("/change-password") +async def change_password_page(request: Request, db: Session = Depends(get_db)): + user = getattr(request.state, "operator", None) or current_operator(request, db) + if user is None: + return RedirectResponse(url="/login", status_code=303) + return templates.TemplateResponse( + "change_password.html", + {"request": request, "must_change": user.must_change_password, "error": ""}) + + +@router.post("/change-password") +async def change_password_submit(request: Request, + current_password: str = Form(...), + new_password: str = Form(...), + confirm_password: str = Form(...), + db: Session = Depends(get_db)): + user = getattr(request.state, "operator", None) or current_operator(request, db) + if user is None: + return RedirectResponse(url="/login", status_code=303) + + def _err(msg): + return templates.TemplateResponse( + "change_password.html", + {"request": request, "must_change": user.must_change_password, "error": msg}, + status_code=200) + + if not verify_password(current_password, user.password_hash): + return _err("Current password is incorrect.") + if len(new_password) < 8: + return _err("New password must be at least 8 characters.") + if new_password != confirm_password: + return _err("New passwords do not match.") + + new_iat = change_own_password(db, user, new_password) + resp = RedirectResponse(url="/", status_code=303) + resp.set_cookie(COOKIE_NAME, make_operator_cookie(user.id, iat=new_iat), + max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE) + return resp +``` + +- [ ] **Step 3d: Register the router in `backend/main.py`** + +After the middleware registration added in Task 5, add: + +```python +from backend.routers import operator_auth_routes +app.include_router(operator_auth_routes.router) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_login.py -v` +Expected: PASS (8 passed) + +- [ ] **Step 5: Commit** + +```bash +git add backend/routers/operator_auth_routes.py templates/login.html templates/change_password.html backend/main.py tests/test_operator_login.py +git commit -m "feat(auth): login/logout/change-password routes + pages" +``` + +--- + +## Task 7: User-management routes + template (superadmin-only) + +`/admin/users` page and JSON CRUD, all behind `require_role("superadmin")`. Temp passwords are returned once. + +**Files:** +- Create: `backend/routers/operator_users.py` +- Create: `templates/admin/users.html` +- Modify: `backend/main.py` (include the router) +- Test: `tests/test_operator_users.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_operator_users.py +import uuid +from tests.conftest import wire_operator_auth +from backend.operator_auth import create_operator, make_operator_cookie, COOKIE_NAME +from backend.models import OperatorUser + + +def _login_as(client, user): + client.cookies.set(COOKIE_NAME, make_operator_cookie(user.id)) + + +def test_admin_cannot_reach_user_management(client, db_session, monkeypatch): + admin, _ = create_operator(db_session, "admin@x.com", "Admin", "admin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, admin) + assert client.get("/admin/users", follow_redirects=False).status_code == 403 + + +def test_superadmin_sees_user_management(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, su) + assert client.get("/admin/users", follow_redirects=False).status_code == 200 + + +def test_superadmin_lists_users_json(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, su) + r = client.get("/api/admin/users") + assert r.status_code == 200 + emails = [u["email"] for u in r.json()["users"]] + assert "su@x.com" in emails + assert all("password_hash" not in u for u in r.json()["users"]) # never leak hashes + + +def test_create_user_returns_temp_once(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, su) + r = client.post("/api/admin/users", + json={"email": "dad@x.com", "name": "Dad", "role": "admin"}) + assert r.status_code == 200 + assert len(r.json()["password"]) >= 12 + made = db_session.query(OperatorUser).filter_by(email="dad@x.com").first() + assert made.must_change_password is True + + +def test_reset_password_returns_temp_once(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, su) + r = client.post(f"/api/admin/users/{target.id}/reset-password") + assert r.status_code == 200 and len(r.json()["password"]) >= 12 + db_session.refresh(target) + assert target.must_change_password is True + + +def test_disable_and_enable(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, su) + assert client.post(f"/api/admin/users/{target.id}/disable").status_code == 200 + db_session.refresh(target); assert target.active is False + assert client.post(f"/api/admin/users/{target.id}/enable").status_code == 200 + db_session.refresh(target); assert target.active is True + + +def test_change_role(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, su) + r = client.post(f"/api/admin/users/{target.id}/role", json={"role": "superadmin"}) + assert r.status_code == 200 + db_session.refresh(target); assert target.role == "superadmin" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_users.py -v` +Expected: FAIL — routes don't exist (404) + +- [ ] **Step 3a: Create `templates/admin/users.html`** + +```html +{% extends "base.html" %} +{% block title %}Operator Accounts{% endblock %} +{% block content %} +
+
+

Operator Accounts

+ +
+ + + + + + +
NameEmailRoleStatusLast login
+
+ +{% endblock %} +``` + +- [ ] **Step 3b: Create `backend/routers/operator_users.py`** + +```python +"""Operator account management — superadmin only. Temp passwords are returned in +the JSON response once (shown to the superadmin to hand off); only hashes persist.""" +from fastapi import APIRouter, Request, Depends, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.templates_config import templates +from backend.models import OperatorUser +from backend.operator_auth import ( + require_role, create_operator, reset_operator_password, + set_operator_active, set_operator_role, +) +from backend.utils.timezone import format_local_datetime + +router = APIRouter(tags=["operator-users"]) +_superadmin = require_role("superadmin") + + +class NewUser(BaseModel): + email: str + name: str + role: str = "admin" + + +class RoleChange(BaseModel): + role: str + + +def _serialize(u: OperatorUser) -> dict: + from datetime import datetime + return { + "id": u.id, "email": u.email, "display_name": u.display_name, "role": u.role, + "active": bool(u.active), "must_change_password": bool(u.must_change_password), + "locked": bool(u.locked_until and u.locked_until > datetime.utcnow()), + "last_login_at": format_local_datetime(u.last_login_at, "%Y-%m-%d %H:%M") if u.last_login_at else None, + } + + +@router.get("/admin/users") +async def users_page(request: Request, _=Depends(_superadmin)): + return templates.TemplateResponse("admin/users.html", {"request": request}) + + +@router.get("/api/admin/users") +async def list_users(_=Depends(_superadmin), db: Session = Depends(get_db)): + users = db.query(OperatorUser).order_by(OperatorUser.display_name).all() + return {"users": [_serialize(u) for u in users]} + + +@router.post("/api/admin/users") +async def add_user(body: NewUser, _=Depends(_superadmin), db: Session = Depends(get_db)): + try: + user, raw = create_operator(db, body.email, body.name, body.role) + except ValueError as e: + return JSONResponse(status_code=400, content={"detail": str(e)}) + return {"user": _serialize(user), "password": raw} + + +@router.post("/api/admin/users/{user_id}/reset-password") +async def reset_user_password(user_id: str, _=Depends(_superadmin), db: Session = Depends(get_db)): + user = db.query(OperatorUser).filter_by(id=user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + raw = reset_operator_password(db, user) + return {"password": raw} + + +@router.post("/api/admin/users/{user_id}/disable") +async def disable_user(user_id: str, _=Depends(_superadmin), db: Session = Depends(get_db)): + user = db.query(OperatorUser).filter_by(id=user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + set_operator_active(db, user, False) + return {"active": False} + + +@router.post("/api/admin/users/{user_id}/enable") +async def enable_user(user_id: str, _=Depends(_superadmin), db: Session = Depends(get_db)): + user = db.query(OperatorUser).filter_by(id=user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + set_operator_active(db, user, True) + return {"active": True} + + +@router.post("/api/admin/users/{user_id}/role") +async def change_user_role(user_id: str, body: RoleChange, + _=Depends(_superadmin), db: Session = Depends(get_db)): + user = db.query(OperatorUser).filter_by(id=user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + try: + set_operator_role(db, user, body.role) + except ValueError as e: + return JSONResponse(status_code=400, content={"detail": str(e)}) + return {"role": user.role} +``` + +- [ ] **Step 3c: Register the router in `backend/main.py`** + +After the `operator_auth_routes` include added in Task 6, add: + +```python +from backend.routers import operator_users +app.include_router(operator_users.router) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_users.py -v` +Expected: PASS (7 passed) + +- [ ] **Step 5: Commit** + +```bash +git add backend/routers/operator_users.py templates/admin/users.html backend/main.py tests/test_operator_users.py +git commit -m "feat(auth): superadmin user-management page + CRUD" +``` + +--- + +## Task 8: Seed / break-glass CLI (`operator_admin.py`) + +The bootstrap (first superadmin, before any UI is reachable) and the break-glass (locked out / forgot everything). Thin wrappers over the Task-4 data helpers. + +**Files:** +- Create: `backend/operator_admin.py` +- Test: `tests/test_operator_admin_cli.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_operator_admin_cli.py +from sqlalchemy.orm import sessionmaker +from backend.models import OperatorUser +from backend.auth_passwords import verify_password +import backend.operator_admin as cli + + +def _maker(db_session): + return sessionmaker(bind=db_session.get_bind(), autocommit=False, autoflush=False) + + +def test_seed_superadmin(db_session, monkeypatch): + monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False) + cli.cmd_create_superadmin(email="brian@x.com", name="Brian", password="chosen-pw-1") + u = db_session.query(OperatorUser).filter_by(email="brian@x.com").first() + assert u.role == "superadmin" + assert u.must_change_password is False + assert verify_password("chosen-pw-1", u.password_hash) + + +def test_create_user_generates_temp(db_session, monkeypatch, capsys): + monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False) + cli.cmd_create_user(email="dad@x.com", name="Dad", role="admin") + u = db_session.query(OperatorUser).filter_by(email="dad@x.com").first() + assert u.role == "admin" and u.must_change_password is True + assert "dad@x.com" in capsys.readouterr().out # prints the temp once + + +def test_reset_password_cli(db_session, monkeypatch): + monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False) + cli.cmd_create_user(email="r@x.com", name="R", role="admin") + before = db_session.query(OperatorUser).filter_by(email="r@x.com").first().password_hash + cli.cmd_reset_password(email="r@x.com") + after = db_session.query(OperatorUser).filter_by(email="r@x.com").first().password_hash + assert before != after + + +def test_disable_enable_cli(db_session, monkeypatch): + monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False) + cli.cmd_create_user(email="d@x.com", name="D", role="admin") + cli.cmd_set_active(email="d@x.com", active=False) + assert db_session.query(OperatorUser).filter_by(email="d@x.com").first().active is False + cli.cmd_set_active(email="d@x.com", active=True) + assert db_session.query(OperatorUser).filter_by(email="d@x.com").first().active is True +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_admin_cli.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'backend.operator_admin'` + +- [ ] **Step 3: Create `backend/operator_admin.py`** + +```python +#!/usr/bin/env python3 +"""Operator-account admin CLI — the bootstrap and the break-glass. Run inside the +terra-view container against the live DB. Temp/raw passwords are printed ONCE; only +hashes persist. + + # first superadmin (before any UI is reachable) — prompts for a password, or --generate + python3 backend/operator_admin.py create-superadmin --email you@x.com --name "Brian" + + # a parent's account — generates a temp password, must-change on first login + python3 backend/operator_admin.py create-user --email dad@x.com --name "Dad" --role admin + + python3 backend/operator_admin.py reset-password --email dad@x.com + python3 backend/operator_admin.py list + python3 backend/operator_admin.py disable --email dad@x.com + python3 backend/operator_admin.py enable --email dad@x.com +""" +import os +import sys +import getpass +import argparse +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend.database import SessionLocal +from backend.models import OperatorUser +from backend.operator_auth import ( + create_operator, reset_operator_password, set_operator_active, _norm_email, +) + + +def _get(db, email): + u = db.query(OperatorUser).filter_by(email=_norm_email(email)).first() + if not u: + sys.exit(f"No operator with email '{email}'.") + return u + + +def cmd_create_superadmin(email, name, password=None, generate=False): + db = SessionLocal() + try: + if password is None and not generate: + password = getpass.getpass("Password for new superadmin: ") + if not password or len(password) < 8: + sys.exit("Password must be at least 8 characters.") + user, raw = create_operator(db, email, name, "superadmin", + password=None if generate else password) + if generate: + print(f"✓ Superadmin {user.email} created. Temp password (shown once): {raw}") + else: + print(f"✓ Superadmin {user.email} created.") + except ValueError as e: + sys.exit(str(e)) + finally: + db.close() + + +def cmd_create_user(email, name, role="admin"): + db = SessionLocal() + try: + user, raw = create_operator(db, email, name, role) + print(f"✓ {role} {user.email} created. Temp password (shown once): {raw}") + print(" They'll be required to change it on first login.") + except ValueError as e: + sys.exit(str(e)) + finally: + db.close() + + +def cmd_reset_password(email): + db = SessionLocal() + try: + user = _get(db, email) + raw = reset_operator_password(db, user) + print(f"✓ Reset {user.email}. Temp password (shown once): {raw}") + finally: + db.close() + + +def cmd_set_active(email, active): + db = SessionLocal() + try: + user = _get(db, email) + set_operator_active(db, user, active) + print(f"✓ {user.email} {'enabled' if active else 'disabled'}.") + finally: + db.close() + + +def cmd_list(): + db = SessionLocal() + try: + users = db.query(OperatorUser).order_by(OperatorUser.display_name).all() + if not users: + print("No operators yet. Run create-superadmin first.") + return + for u in users: + locked = " [LOCKED]" if (u.locked_until and u.locked_until > datetime.utcnow()) else "" + state = "active" if u.active else "DISABLED" + last = u.last_login_at.strftime("%Y-%m-%d %H:%M") if u.last_login_at else "never" + print(f" {u.display_name:<12} {u.email:<28} {u.role:<11} {state}{locked} last: {last}") + finally: + db.close() + + +def main(): + ap = argparse.ArgumentParser(description="Operator-account admin") + sub = ap.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("create-superadmin") + p.add_argument("--email", required=True); p.add_argument("--name", required=True) + p.add_argument("--generate", action="store_true", help="generate a temp password instead of prompting") + p.set_defaults(fn=lambda a: cmd_create_superadmin(a.email, a.name, generate=a.generate)) + + p = sub.add_parser("create-user") + p.add_argument("--email", required=True); p.add_argument("--name", required=True) + p.add_argument("--role", default="admin", choices=["admin", "superadmin"]) + p.set_defaults(fn=lambda a: cmd_create_user(a.email, a.name, a.role)) + + p = sub.add_parser("reset-password") + p.add_argument("--email", required=True) + p.set_defaults(fn=lambda a: cmd_reset_password(a.email)) + + p = sub.add_parser("disable"); p.add_argument("--email", required=True) + p.set_defaults(fn=lambda a: cmd_set_active(a.email, False)) + + p = sub.add_parser("enable"); p.add_argument("--email", required=True) + p.set_defaults(fn=lambda a: cmd_set_active(a.email, True)) + + p = sub.add_parser("list"); p.set_defaults(fn=lambda a: cmd_list()) + + args = ap.parse_args() + args.fn(args) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_admin_cli.py -v` +Expected: PASS (4 passed) + +- [ ] **Step 5: Commit** + +```bash +git add backend/operator_admin.py tests/test_operator_admin_cli.py +git commit -m "feat(auth): operator admin/break-glass CLI" +``` + +--- + +## Task 9: Machine-endpoint regression guard + full-suite green + +A dedicated regression test that the gate, when ON, never blocks the watcher heartbeats — and a full-suite run to confirm nothing else broke. + +**Files:** +- Test: `tests/test_operator_machine_endpoints.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_operator_machine_endpoints.py +from tests.conftest import wire_operator_auth + + +def test_machine_endpoints_not_blocked_by_gate(client, db_session, monkeypatch): + """With the gate ON and no cookie, the LAN-only watcher endpoints must reach + their handlers (the gate must never silently break heartbeats). A handler may + return 422 for an empty body — that still proves the gate let it through.""" + wire_operator_auth(monkeypatch, db_session, enabled=True) + + r = client.post("/api/series3/heartbeat", json={}, follow_redirects=False) + assert r.status_code != 401 # gate would 401 an unauth /api/* route + assert r.status_code != 303 + + r = client.post("/api/series4/heartbeat", json={}, follow_redirects=False) + assert r.status_code not in (401, 303) + + r = client.post("/emitters/report", json={}, follow_redirects=False) + assert r.status_code != 303 # gate would 303 an unauth HTML route + + +def test_static_assets_exempt(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + # /sw.js and /manifest.json are PWA assets clients fetch pre-login. + assert client.get("/sw.js", follow_redirects=False).status_code in (200, 404) + assert client.get("/sw.js", follow_redirects=False).status_code != 303 +``` + +- [ ] **Step 2: Run the test to verify it passes (the exempt list from Task 5 already covers it)** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_operator_machine_endpoints.py -v` +Expected: PASS (2 passed). If any assertion fails, the exempt list in `backend/operator_auth.py` (`_EXEMPT_EXACT`) is missing that path — add it and re-run. + +- [ ] **Step 3: Run the FULL test suite to confirm no regressions** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/ -v` +Expected: PASS — all operator-auth tests plus the pre-existing portal/auth tests. The gate defaults OFF (env unset), so every pre-existing test is unaffected. + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_operator_machine_endpoints.py +git commit -m "test(auth): regression guard — gate never blocks machine endpoints" +``` + +--- + +## Task 10: Wire the flag into compose + CHANGELOG + rollout doc + +Make the flag operable in deployment and document the no-self-lockout rollout. + +**Files:** +- Modify: `docker-compose.yml` (pass `OPERATOR_AUTH_ENABLED` through, default off) +- Modify: `CHANGELOG.md` (Unreleased → Added) + +- [ ] **Step 1: Add the flag pass-through to `docker-compose.yml`** + +In the `web-app` service `environment:` block, after the `COOKIE_SECURE` line (line 20), add: + +```yaml + # Operator login gate. Leave false to ship dark; seed a superadmin via + # backend/operator_admin.py, confirm you can log in, THEN set true to enforce. + # Instant escape hatch: set back to false. See docs/superpowers/specs/2026-06-17-operator-auth-design.md + - OPERATOR_AUTH_ENABLED=${OPERATOR_AUTH_ENABLED:-false} +``` + +- [ ] **Step 2: Add the CHANGELOG entry** + +In `CHANGELOG.md`, under `## [Unreleased]` → `### Added`, add this bullet (keep it grouped with the other Added items): + +```markdown +- **Operator authentication (login + roles), shipped dark behind `OPERATOR_AUTH_ENABLED`.** The internal app gains a deny-by-default login gate (one Starlette middleware over every route except an allow-list: `/login` `/logout` `/health` `/static/*` `/portal/*` + the three machine heartbeat endpoints). Two roles — `superadmin` (account management) and `admin` (full app); `operator` reserved. Sessions are a 30-day HMAC-signed `tv_session` cookie re-validated against the DB each request (instant revoke via `active` / `sessions_valid_from`). Password reset is superadmin-driven: reset-anyone (temp shown once + forced change), self-service `/change-password`, and a `backend/operator_admin.py` seed/break-glass CLI. Brute-force lockout (5 tries / 15 min). New `operator_users` table auto-creates — no migration. Reuses the portal's argon2 hasher + a new shared `backend/auth_cookies.py` signer. Rollout: ship with the flag off (app unchanged), seed a superadmin, confirm login, then flip on — the flag is an instant escape hatch. Spec: `docs/superpowers/specs/2026-06-17-operator-auth-design.md`. +``` + +- [ ] **Step 3: Run the full suite once more (sanity — no code changed, but confirm compose/env didn't break import)** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/ -q` +Expected: PASS (all green) + +- [ ] **Step 4: Commit** + +```bash +git add docker-compose.yml CHANGELOG.md +git commit -m "chore(auth): wire OPERATOR_AUTH_ENABLED into compose + changelog" +``` + +--- + +## Manual verification (after all tasks — done by the human, not a step) + +These confirm the rollout sequence on a real container (the spec's "no self-lockout" path): + +1. **Flag off (default) — app unchanged:** `docker compose up -d` with no `OPERATOR_AUTH_ENABLED` set → the app behaves exactly as today (no login). +2. **Seed:** `docker exec terra-view-terra-view-1 python3 backend/operator_admin.py create-superadmin --email you@x.com --name "Brian"` (prompts for a password). +3. **Log in while still off:** visit `/login`, sign in → you get a `tv_session` cookie (login works regardless of the flag). +4. **Enforce:** set `OPERATOR_AUTH_ENABLED=true`, `docker compose up -d web-app` → the gate enforces; your cookie lets you in; anything wrong → set it back to `false` (instant escape hatch). +5. **Add parents:** from `/admin/users`, add `admin` accounts (temp passwords shown once; they change on first login). + +--- + +## Self-Review (run by the plan author after writing — recorded here) + +**Spec coverage:** OperatorUser table (T2) ✓ · auth_cookies shared signer (T1) ✓ · `tv_session` cookie + 30-day + DB re-validation + sessions_valid_from (T3) ✓ · deny-by-default middleware + exempt list incl. 3 machine endpoints + must_change redirect + HTML-303/api-401 split (T5) ✓ · role ladder + require_role superadmin-only (T2/T5/T7) ✓ · authenticate + lockout 5/15min (T4/T6) ✓ · password reset all three paths + forgot=contact-admin (T4/T6/T7) ✓ · seed/break-glass CLI (T8) ✓ · user-mgmt UI (T7) ✓ · login/logout/change-password + templates (T6) ✓ · flag governs middleware AND require_role (T5) ✓ · rollout sequence (T10 + manual) ✓ · no migration / new table auto-creates (T2) ✓ · CHANGELOG (T10) ✓. + +**Type/name consistency:** `make_operator_cookie(uid, iat=None)`, `current_operator(request, db)`, `authenticate(db, email, password) -> (user, status)`, `create_operator(db, email, name, role, password=None, must_change=None) -> (user, raw)`, `reset_operator_password(db, user) -> raw`, `change_own_password(db, user, new_password) -> new_iat`, `role_at_least(role, minimum)`, `require_role(minimum)`, `COOKIE_NAME="tv_session"` — all referenced consistently across T3–T8. The `sessions_valid_from`-vs-`iat` truncation race is handled at every write point (`_utcnow_seconds` default, `utcfromtimestamp(new_iat)` on change, `.replace(microsecond=0)` on reset) and covered by `test_cookie_minted_with_matching_iat_after_bump_still_valid`. + +**Placeholder scan:** none — every code step is complete. From 8e817ec48d62630b3d5a2d700f98f15d6f19d95c Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 19:01:16 +0000 Subject: [PATCH 03/31] feat(auth): generic HMAC signed-cookie module for operator auth Co-Authored-By: Claude Sonnet 4.6 --- backend/auth_cookies.py | 64 ++++++++++++++++++++++++++++++++++ tests/test_operator_cookies.py | 49 ++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 backend/auth_cookies.py create mode 100644 tests/test_operator_cookies.py diff --git a/backend/auth_cookies.py b/backend/auth_cookies.py new file mode 100644 index 0000000..f83c2a6 --- /dev/null +++ b/backend/auth_cookies.py @@ -0,0 +1,64 @@ +# backend/auth_cookies.py +"""Generic HMAC-signed cookie payloads, shared by operator auth (and, optionally +later, the portal). A signed value is f"{b64url(json)}.{hmac_sha256(b64)}"; read() +verifies the signature in constant time and enforces a server-side iat expiry. + +The signing secret is the same SECRET_KEY the portal already reads, so a single +env var protects both cookies. Never store or log raw secrets.""" +import os +import hmac +import json +import time +import base64 +import hashlib +import logging + +logger = logging.getLogger(__name__) + +# Same env var the portal cookie uses — one secret protects both. The insecure +# default only exists so dev/test boots without config; set a real SECRET_KEY in prod. +SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me") +# Set COOKIE_SECURE=true once served over HTTPS; leave false on plain HTTP or the +# browser won't send the cookie. +COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes") + + +def _sign(body: str) -> str: + return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest() + + +def sign(payload: dict) -> str: + """Serialize + sign a payload dict into a cookie-safe string.""" + body = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode() + return f"{body}.{_sign(body)}" + + +def read(raw, max_age: int): + """Verify a signed value and return its payload dict, or None if missing, + tampered, or older than max_age seconds (by its own `iat`).""" + if not raw or not isinstance(raw, str): + return None + try: + body, sig = raw.rsplit(".", 1) + except (ValueError, AttributeError): + return None + if not hmac.compare_digest(sig, _sign(body)): + return None + try: + data = json.loads(base64.urlsafe_b64decode(body.encode())) + except Exception: + return None + if not isinstance(data, dict): + return None + iat = data.get("iat") + if not isinstance(iat, (int, float)): + return None + now = time.time() + # Reject implausibly future-dated tokens: the same server signs and verifies, + # so there's no real clock skew — a far-future iat (e.g. to dodge max_age or + # outlive a sessions_valid_from bump) is bogus. 60s of slack is generous. + if iat - now > 60: + return None + if (now - iat) > max_age: + return None + return data diff --git a/tests/test_operator_cookies.py b/tests/test_operator_cookies.py new file mode 100644 index 0000000..95ab082 --- /dev/null +++ b/tests/test_operator_cookies.py @@ -0,0 +1,49 @@ +# tests/test_operator_cookies.py +import time +import base64 +import json +from backend.auth_cookies import sign, read + + +def test_sign_then_read_round_trips(): + now = int(time.time()) + raw = sign({"uid": "abc", "iat": now}) + data = read(raw, max_age=3600) + assert data == {"uid": "abc", "iat": now} + + +def test_tampered_signature_is_rejected(): + raw = sign({"uid": "abc", "iat": int(time.time())}) + body, _sig = raw.rsplit(".", 1) + assert read(body + ".deadbeef", max_age=3600) is None + + +def test_tampered_body_is_rejected(): + raw = sign({"uid": "abc", "iat": int(time.time())}) + body, sig = raw.rsplit(".", 1) + forged = base64.urlsafe_b64encode(json.dumps({"uid": "evil", "iat": int(time.time())}).encode()).decode() + assert read(forged + "." + sig, max_age=3600) is None + + +def test_expired_by_iat_is_rejected(): + raw = sign({"uid": "abc", "iat": int(time.time()) - 10_000}) + assert read(raw, max_age=3600) is None + + +def test_garbage_input_is_none_not_raise(): + assert read("not-a-cookie", max_age=3600) is None + assert read("", max_age=3600) is None + assert read(None, max_age=3600) is None + + +def test_wrong_secret_is_rejected(monkeypatch): + import backend.auth_cookies as ac + monkeypatch.setattr(ac, "SECRET_KEY", "secret-A") + raw = ac.sign({"uid": "x", "iat": int(time.time())}) + monkeypatch.setattr(ac, "SECRET_KEY", "secret-B") + assert ac.read(raw, max_age=3600) is None + + +def test_future_dated_iat_is_rejected(): + raw = sign({"uid": "x", "iat": int(time.time()) + 10_000}) + assert read(raw, max_age=3600) is None From 4abfcbc293cd436fc9563216510bc3242b1d5648 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 19:08:18 +0000 Subject: [PATCH 04/31] feat(auth): OperatorUser model + role ladder Add OperatorUser SQLAlchemy model (operator_users table, auto-created by create_all) with email uniqueness, default active/must_change_password/ failed_login_count, and sessions_valid_from truncated to whole seconds. Add backend/operator_auth.py with feature flag, cookie constants, _ROLE_RANK map, role_at_least(), and _norm_email() helpers. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/models.py | 31 +++++++++++++++++++++++++++++++ backend/operator_auth.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_operator_model.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 backend/operator_auth.py create mode 100644 tests/test_operator_model.py diff --git a/backend/models.py b/backend/models.py index 8de63e8..888ca24 100644 --- a/backend/models.py +++ b/backend/models.py @@ -3,6 +3,13 @@ from datetime import datetime from backend.database import Base +def _utcnow_seconds(): + """utcnow truncated to whole seconds — used as the default for + sessions_valid_from so a freshly-issued cookie (whose iat is a whole-second + epoch) never falls a few microseconds before it and self-invalidates.""" + return datetime.utcnow().replace(microsecond=0) + + class Emitter(Base): __tablename__ = "emitters" @@ -772,3 +779,27 @@ class ClientAccessToken(Base): created_at = Column(DateTime, default=datetime.utcnow) last_used_at = Column(DateTime, nullable=True) revoked_at = Column(DateTime, nullable=True) # set = link no longer works + + +# ============================================================================ +# OPERATOR AUTH — internal operator logins (see backend/operator_auth.py) +# ============================================================================ + +class OperatorUser(Base): + """An internal operator login. Roles: 'superadmin' (Brian, + account mgmt) and + 'admin' (parents, full app). 'operator' is reserved (deferred). Brand-new table + → create_all builds it, no migration. Never store or log raw passwords.""" + __tablename__ = "operator_users" + + id = Column(String, primary_key=True, index=True) # UUID + email = Column(String, nullable=False, unique=True, index=True) # login handle, lowercased + display_name = Column(String, nullable=False) # "Brian", "Dad" + password_hash = Column(String, nullable=False) # argon2id + role = Column(String, nullable=False, default="admin") # superadmin | admin + active = Column(Boolean, default=True) # False = login disabled + must_change_password = Column(Boolean, default=False) # forces a change next login + sessions_valid_from = Column(DateTime, default=_utcnow_seconds) # bump = log out everywhere + failed_login_count = Column(Integer, default=0) # lockout counter + locked_until = Column(DateTime, nullable=True) # set after too many bad tries + created_at = Column(DateTime, default=datetime.utcnow) + last_login_at = Column(DateTime, nullable=True) diff --git a/backend/operator_auth.py b/backend/operator_auth.py new file mode 100644 index 0000000..5927865 --- /dev/null +++ b/backend/operator_auth.py @@ -0,0 +1,35 @@ +# backend/operator_auth.py +"""Operator authentication: the deny-by-default gate, session cookie, login + +lockout, and the small data helpers shared by the routes and the CLI. Reuses the +argon2 hasher (auth_passwords) and the HMAC signer (auth_cookies). + +The flag and SessionLocal are read as module globals at call time so tests can +monkeypatch them.""" +import os +import time +import uuid +from datetime import datetime, timedelta + +from backend.models import OperatorUser +from backend.auth_passwords import hash_password, verify_password, generate_password + +# Feature flag — OFF by default. When off, the gate and require_role both pass +# everything through and the app behaves exactly as it does today. +OPERATOR_AUTH_ENABLED = os.getenv("OPERATOR_AUTH_ENABLED", "false").lower() in ("1", "true", "yes") + +COOKIE_NAME = "tv_session" +COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days ("remember this device") +MAX_LOGIN_FAILURES = 5 +LOCK_MINUTES = 15 + +# Role ladder — a rank map so checks read naturally and 'operator' slots in later. +_ROLE_RANK = {"operator": 10, "admin": 20, "superadmin": 30} + + +def role_at_least(role: str, minimum: str) -> bool: + """True iff `role` ranks at or above `minimum`. Unknown roles rank as 0.""" + return _ROLE_RANK.get(role, 0) >= _ROLE_RANK[minimum] + + +def _norm_email(email: str) -> str: + return (email or "").strip().lower() diff --git a/tests/test_operator_model.py b/tests/test_operator_model.py new file mode 100644 index 0000000..88a157a --- /dev/null +++ b/tests/test_operator_model.py @@ -0,0 +1,36 @@ +# tests/test_operator_model.py +import uuid +from backend.models import OperatorUser +from backend.operator_auth import role_at_least, _ROLE_RANK + + +def test_operator_user_defaults(db_session): + u = OperatorUser(id=str(uuid.uuid4()), email="a@x.com", display_name="A", + password_hash="h", role="admin") + db_session.add(u) + db_session.commit() + got = db_session.query(OperatorUser).filter_by(email="a@x.com").first() + assert got.active is True + assert got.must_change_password is False + assert got.failed_login_count == 0 + assert got.locked_until is None + assert got.sessions_valid_from is not None + assert got.sessions_valid_from.microsecond == 0 # truncated to whole seconds + + +def test_email_is_unique(db_session): + for i in range(2): + db_session.add(OperatorUser(id=str(uuid.uuid4()), email="dup@x.com", + display_name="d", password_hash="h", role="admin")) + import pytest + with pytest.raises(Exception): + db_session.commit() + + +def test_role_ladder(): + assert _ROLE_RANK == {"operator": 10, "admin": 20, "superadmin": 30} + assert role_at_least("superadmin", "admin") is True + assert role_at_least("admin", "admin") is True + assert role_at_least("admin", "superadmin") is False + assert role_at_least("operator", "admin") is False + assert role_at_least("nonsense", "admin") is False From a6e1cb4f87a1ab9cbe07f43bfdef62dd7a4aefcb Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 19:11:41 +0000 Subject: [PATCH 05/31] feat(auth): operator session cookie + current_operator DB re-validation Co-Authored-By: Claude Sonnet 4.6 --- backend/operator_auth.py | 28 +++++++++++++++ tests/test_operator_session.py | 63 ++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 tests/test_operator_session.py diff --git a/backend/operator_auth.py b/backend/operator_auth.py index 5927865..1ed0820 100644 --- a/backend/operator_auth.py +++ b/backend/operator_auth.py @@ -12,6 +12,7 @@ from datetime import datetime, timedelta from backend.models import OperatorUser from backend.auth_passwords import hash_password, verify_password, generate_password +from backend.auth_cookies import sign, read, COOKIE_SECURE # Feature flag — OFF by default. When off, the gate and require_role both pass # everything through and the app behaves exactly as it does today. @@ -33,3 +34,30 @@ def role_at_least(role: str, minimum: str) -> bool: def _norm_email(email: str) -> str: return (email or "").strip().lower() + + +def make_operator_cookie(uid: str, iat: int = None) -> str: + """Sign a tv_session value for a user id. iat defaults to now; pass an explicit + iat when you bump sessions_valid_from to that same instant (change-password).""" + return sign({"uid": uid, "iat": int(iat if iat is not None else time.time())}) + + +def current_operator(request, db): + """Resolve the OperatorUser for a request's tv_session cookie, or None. + Re-validated against the DB every call: a disabled / locked / password-changed + user drops on the next request. Used by the gate middleware (with its own + session) — does not raise.""" + data = read(request.cookies.get(COOKIE_NAME), COOKIE_MAX_AGE) + if not data: + return None + uid, iat = data.get("uid"), data.get("iat") + if not uid or not isinstance(iat, (int, float)): + return None + user = db.query(OperatorUser).filter_by(id=uid).first() + if not user or not user.active: + return None + if user.locked_until and user.locked_until > datetime.utcnow(): + return None + if user.sessions_valid_from and datetime.utcfromtimestamp(int(iat)) < user.sessions_valid_from: + return None + return user diff --git a/tests/test_operator_session.py b/tests/test_operator_session.py new file mode 100644 index 0000000..6f30531 --- /dev/null +++ b/tests/test_operator_session.py @@ -0,0 +1,63 @@ +# tests/test_operator_session.py +import time +import uuid +from datetime import datetime, timedelta +from types import SimpleNamespace + +from backend.models import OperatorUser +from backend.operator_auth import ( + make_operator_cookie, current_operator, COOKIE_NAME, +) + + +def _make_user(db, **kw): + u = OperatorUser(id=str(uuid.uuid4()), email=kw.pop("email", "u@x.com"), + display_name="U", password_hash="h", role=kw.pop("role", "admin"), **kw) + db.add(u) + db.commit() + return u + + +def _req(cookie_value): + # current_operator only reads request.cookies — a stub is enough. + return SimpleNamespace(cookies={COOKIE_NAME: cookie_value} if cookie_value else {}) + + +def test_valid_cookie_resolves_user(db_session): + u = _make_user(db_session) + cookie = make_operator_cookie(u.id) + assert current_operator(_req(cookie), db_session).id == u.id + + +def test_no_or_garbage_cookie_is_none(db_session): + assert current_operator(_req(None), db_session) is None + assert current_operator(_req("garbage"), db_session) is None + + +def test_inactive_user_is_none(db_session): + u = _make_user(db_session, active=False) + assert current_operator(_req(make_operator_cookie(u.id)), db_session) is None + + +def test_locked_user_is_none(db_session): + u = _make_user(db_session, locked_until=datetime.utcnow() + timedelta(minutes=5)) + assert current_operator(_req(make_operator_cookie(u.id)), db_session) is None + + +def test_cookie_older_than_sessions_valid_from_is_none(db_session): + u = _make_user(db_session) + old_iat = int(time.time()) - 1000 + cookie = make_operator_cookie(u.id, iat=old_iat) + u.sessions_valid_from = datetime.utcnow() + db_session.commit() + assert current_operator(_req(cookie), db_session) is None + + +def test_cookie_minted_with_matching_iat_after_bump_still_valid(db_session): + # Guards the change-password race: bump sessions_valid_from to the new cookie's + # exact iat → that fresh cookie must remain valid. + u = _make_user(db_session) + new_iat = int(time.time()) + u.sessions_valid_from = datetime.utcfromtimestamp(new_iat) + db_session.commit() + assert current_operator(_req(make_operator_cookie(u.id, iat=new_iat)), db_session).id == u.id From e8fe4845aaf56d82c7bc4bc784acaae355fdee1d Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 19:16:09 +0000 Subject: [PATCH 06/31] feat(auth): authenticate + lockout + operator data helpers Co-Authored-By: Claude Sonnet 4.6 --- backend/operator_auth.py | 95 +++++++++++++++++++++++++++++ tests/test_operator_authenticate.py | 89 +++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 tests/test_operator_authenticate.py diff --git a/backend/operator_auth.py b/backend/operator_auth.py index 1ed0820..4ec1288 100644 --- a/backend/operator_auth.py +++ b/backend/operator_auth.py @@ -26,6 +26,11 @@ LOCK_MINUTES = 15 # Role ladder — a rank map so checks read naturally and 'operator' slots in later. _ROLE_RANK = {"operator": 10, "admin": 20, "superadmin": 30} +# A throwaway hash used only to equalize verify time on the unknown-email path, +# so a missing account can't be distinguished from a wrong password by timing +# (no user-enumeration). The value never authenticates anything. +_DUMMY_PASSWORD_HASH = hash_password("operator-auth-timing-equalizer") + def role_at_least(role: str, minimum: str) -> bool: """True iff `role` ranks at or above `minimum`. Unknown roles rank as 0.""" @@ -61,3 +66,93 @@ def current_operator(request, db): if user.sessions_valid_from and datetime.utcfromtimestamp(int(iat)) < user.sessions_valid_from: return None return user + + +def register_login_failure(db, user) -> None: + """Increment a user's failure counter and lock them out past the threshold.""" + user.failed_login_count = (user.failed_login_count or 0) + 1 + if user.failed_login_count >= MAX_LOGIN_FAILURES: + user.locked_until = datetime.utcnow() + timedelta(minutes=LOCK_MINUTES) + db.commit() + + +def authenticate(db, email, password): + """Return (user, "ok") on success, (None, "locked") if locked out, else + (None, "bad"). Never reveals whether the email exists: an unknown email runs + the same argon2 verify (against a dummy hash) as a wrong password, so neither + the response text nor its timing distinguishes the two.""" + user = db.query(OperatorUser).filter_by(email=_norm_email(email)).first() + if user and user.locked_until and user.locked_until > datetime.utcnow(): + return None, "locked" + password_ok = verify_password(password, user.password_hash if user else _DUMMY_PASSWORD_HASH) + if not user or not user.active or not password_ok: + if user: + register_login_failure(db, user) + return None, "bad" + user.failed_login_count = 0 + user.locked_until = None + user.last_login_at = datetime.utcnow() + db.commit() + return user, "ok" + + +def create_operator(db, email, name, role, password=None, must_change=None): + """Create an operator. With no password, generate a temp one and force a change + (must_change defaults True). With a password, must_change defaults False. + Returns (user, raw_password_to_show_once). Raises ValueError on dup/bad role.""" + email = _norm_email(email) + if role not in _ROLE_RANK: + raise ValueError(f"unknown role {role!r}") + if db.query(OperatorUser).filter_by(email=email).first(): + raise ValueError(f"operator {email} already exists") + if password is None: + password = generate_password() + if must_change is None: + must_change = True + elif must_change is None: + must_change = False + user = OperatorUser(id=str(uuid.uuid4()), email=email, display_name=name, + password_hash=hash_password(password), role=role, + active=True, must_change_password=must_change) + db.add(user) + db.commit() + return user, password + + +def reset_operator_password(db, user) -> str: + """Generate a fresh temp password, force a change, log the user out everywhere. + Returns the raw password to show once.""" + raw = generate_password() + user.password_hash = hash_password(raw) + user.must_change_password = True + user.failed_login_count = 0 + user.locked_until = None + user.sessions_valid_from = datetime.utcnow().replace(microsecond=0) + db.commit() + return raw + + +def change_own_password(db, user, new_password) -> int: + """Set a user's own new password, clear the forced-change flag, and bump + sessions_valid_from to the returned iat — the caller mints the replacement + cookie with that exact iat so it stays valid while older cookies die.""" + new_iat = int(time.time()) + user.password_hash = hash_password(new_password) + user.must_change_password = False + user.sessions_valid_from = datetime.utcfromtimestamp(new_iat) + db.commit() + return new_iat + + +def set_operator_active(db, user, active: bool): + user.active = bool(active) + db.commit() + return user + + +def set_operator_role(db, user, role: str): + if role not in _ROLE_RANK: + raise ValueError(f"unknown role {role!r}") + user.role = role + db.commit() + return user diff --git a/tests/test_operator_authenticate.py b/tests/test_operator_authenticate.py new file mode 100644 index 0000000..5b9737f --- /dev/null +++ b/tests/test_operator_authenticate.py @@ -0,0 +1,89 @@ +# tests/test_operator_authenticate.py +import time +from datetime import datetime +import pytest + +from backend.operator_auth import ( + authenticate, create_operator, reset_operator_password, + set_operator_active, set_operator_role, change_own_password, MAX_LOGIN_FAILURES, +) +from backend.auth_passwords import verify_password +from backend.models import OperatorUser + + +def test_create_operator_generates_temp_and_forces_change(db_session): + user, raw = create_operator(db_session, "Dad@X.com", "Dad", "admin") + assert user.email == "dad@x.com" # lowercased + assert user.must_change_password is True + assert verify_password(raw, user.password_hash) + + +def test_create_operator_with_explicit_password_no_forced_change(db_session): + user, raw = create_operator(db_session, "brian@x.com", "Brian", "superadmin", password="chosen-pw-123") + assert raw == "chosen-pw-123" + assert user.must_change_password is False + + +def test_create_operator_rejects_duplicate_and_bad_role(db_session): + create_operator(db_session, "a@x.com", "A", "admin") + with pytest.raises(ValueError): + create_operator(db_session, "A@x.com", "A2", "admin") # dup (case-insensitive) + with pytest.raises(ValueError): + create_operator(db_session, "b@x.com", "B", "wizard") # bad role + + +def test_authenticate_success(db_session): + user, raw = create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9") + got, status = authenticate(db_session, "OK@x.com", "rightpw-9") + assert status == "ok" and got.id == user.id + assert got.last_login_at is not None + assert got.failed_login_count == 0 + + +def test_authenticate_wrong_password_counts(db_session): + create_operator(db_session, "wp@x.com", "Wp", "admin", password="rightpw-9") + got, status = authenticate(db_session, "wp@x.com", "nope") + assert got is None and status == "bad" + assert db_session.query(OperatorUser).filter_by(email="wp@x.com").first().failed_login_count == 1 + + +def test_lockout_after_five_then_correct_password_refused(db_session): + create_operator(db_session, "lk@x.com", "Lk", "admin", password="rightpw-9") + for _ in range(MAX_LOGIN_FAILURES): + authenticate(db_session, "lk@x.com", "nope") + got, status = authenticate(db_session, "lk@x.com", "rightpw-9") # correct, but locked + assert got is None and status == "locked" + + +def test_authenticate_unknown_email_is_bad_not_error(db_session): + got, status = authenticate(db_session, "ghost@x.com", "whatever") + assert got is None and status == "bad" + + +def test_reset_password_sets_new_hash_forces_change_and_bumps_sessions(db_session): + user, _ = create_operator(db_session, "r@x.com", "R", "admin", password="orig-pw-1") + before = user.sessions_valid_from + raw = reset_operator_password(db_session, user) + assert verify_password(raw, user.password_hash) + assert user.must_change_password is True + assert user.sessions_valid_from >= before + + +def test_change_own_password_clears_flag_and_bumps(db_session): + user, _ = create_operator(db_session, "c@x.com", "C", "admin", password="orig-pw-1") + user.must_change_password = True + db_session.commit() + new_iat = change_own_password(db_session, user, "brand-new-pw-2") + assert verify_password("brand-new-pw-2", user.password_hash) + assert user.must_change_password is False + assert user.sessions_valid_from == datetime.utcfromtimestamp(new_iat) + + +def test_set_active_and_role(db_session): + user, _ = create_operator(db_session, "s@x.com", "S", "admin", password="orig-pw-1") + set_operator_active(db_session, user, False) + assert user.active is False + set_operator_role(db_session, user, "superadmin") + assert user.role == "superadmin" + with pytest.raises(ValueError): + set_operator_role(db_session, user, "wizard") From 2879abb355428286a342928994b51934f8fb45f2 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 19:22:15 +0000 Subject: [PATCH 07/31] feat(auth): deny-by-default gate middleware + require_role Adds operator_gate Starlette HTTP middleware that gates every route except an explicit allow-list. Flag defaults OFF so all existing behaviour and tests are unchanged. wire_operator_auth helper in conftest lets tests monkeypatch the module-global SessionLocal and flag, keeping the gate's own DB session pointed at the test engine. Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 6 ++++ backend/operator_auth.py | 69 +++++++++++++++++++++++++++++++++++++ tests/conftest.py | 12 +++++++ tests/test_operator_gate.py | 69 +++++++++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 tests/test_operator_gate.py diff --git a/backend/main.py b/backend/main.py index 92f2470..6b82727 100644 --- a/backend/main.py +++ b/backend/main.py @@ -89,6 +89,12 @@ async def add_environment_to_context(request: Request, call_next): response = await call_next(request) return response +# Operator auth — deny-by-default gate over the whole internal app. Governed by +# OPERATOR_AUTH_ENABLED (default off → behaves exactly as today). See +# docs/superpowers/specs/2026-06-17-operator-auth-design.md. +from backend.operator_auth import operator_gate +app.middleware("http")(operator_gate) + # Override TemplateResponse to include environment and version in context original_template_response = templates.TemplateResponse def custom_template_response(name, context=None, *args, **kwargs): diff --git a/backend/operator_auth.py b/backend/operator_auth.py index 4ec1288..93b5cc6 100644 --- a/backend/operator_auth.py +++ b/backend/operator_auth.py @@ -9,10 +9,15 @@ import os import time import uuid from datetime import datetime, timedelta +from urllib.parse import quote + +from fastapi import Request, HTTPException +from fastapi.responses import JSONResponse, RedirectResponse from backend.models import OperatorUser from backend.auth_passwords import hash_password, verify_password, generate_password from backend.auth_cookies import sign, read, COOKIE_SECURE +from backend.database import SessionLocal # Feature flag — OFF by default. When off, the gate and require_role both pass # everything through and the app behaves exactly as it does today. @@ -156,3 +161,67 @@ def set_operator_role(db, user, role: str): user.role = role db.commit() return user + + +# Routes reachable with no login. A new route added next year is gated by default. +_EXEMPT_EXACT = { + "/login", "/logout", "/health", + "/manifest.json", "/sw.js", "/favicon.ico", "/offline-db.js", + "/portal", # portal home (its own auth) + # machine endpoints — LAN-only, automated, no human (watchers/heartbeats): + "/emitters/report", "/api/series3/heartbeat", "/api/series4/heartbeat", +} +_EXEMPT_PREFIX = ("/static/", "/portal/") + + +def _is_exempt(path: str) -> bool: + return path in _EXEMPT_EXACT or path.startswith(_EXEMPT_PREFIX) + + +async def operator_gate(request: Request, call_next): + """Deny-by-default gate. Flag off → pass through (app as today). Flag on → + exempt paths pass; otherwise require a valid operator session, stash it on + request.state.operator, and force a password change when pending.""" + if not OPERATOR_AUTH_ENABLED: + return await call_next(request) + + path = request.url.path + if _is_exempt(path): + return await call_next(request) + + db = SessionLocal() + try: + user = current_operator(request, db) + if user is not None: + db.expunge(user) # detach a fully-loaded row so we can close now + finally: + db.close() + + if user is None: + if path.startswith("/api/"): + return JSONResponse({"detail": "Not authenticated"}, status_code=401) + return RedirectResponse(f"/login?next={quote(path)}", status_code=303) + + if user.must_change_password and path not in ("/change-password", "/logout"): + if path.startswith("/api/"): + return JSONResponse({"detail": "Password change required"}, status_code=403) + return RedirectResponse("/change-password", status_code=303) + + request.state.operator = user + return await call_next(request) + + +def require_role(minimum: str): + """Dependency factory: require a logged-in operator ranked >= `minimum`. + Respects the flag (off → pass through). When on, the middleware has already + set request.state.operator before this runs.""" + def _dep(request: Request): + if not OPERATOR_AUTH_ENABLED: + return None + user = getattr(request.state, "operator", None) + if user is None: + raise HTTPException(status_code=401, detail="Not authenticated") + if not role_at_least(user.role, minimum): + raise HTTPException(status_code=403, detail="Insufficient permissions") + return user + return _dep diff --git a/tests/conftest.py b/tests/conftest.py index aa44991..031748c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,3 +62,15 @@ def make_project(db_session, name=None, **kwargs): db_session.add(p) db_session.commit() return p + + +def wire_operator_auth(monkeypatch, db_session, enabled=True): + """Point the gate middleware's SessionLocal at the test engine and flip the + flag. The middleware opens its OWN session (it can't use the get_db override), + so it must read the same engine the test writes to.""" + import backend.operator_auth as oa + from sqlalchemy.orm import sessionmaker + maker = sessionmaker(bind=db_session.get_bind(), autocommit=False, autoflush=False) + monkeypatch.setattr(oa, "SessionLocal", maker, raising=False) + monkeypatch.setattr(oa, "OPERATOR_AUTH_ENABLED", enabled, raising=False) + return oa diff --git a/tests/test_operator_gate.py b/tests/test_operator_gate.py new file mode 100644 index 0000000..bb0697d --- /dev/null +++ b/tests/test_operator_gate.py @@ -0,0 +1,69 @@ +# tests/test_operator_gate.py +import uuid +from tests.conftest import wire_operator_auth +from backend.models import OperatorUser +from backend.operator_auth import make_operator_cookie, COOKIE_NAME +from backend.auth_passwords import hash_password + + +def _make_user(db, role="admin", **kw): + u = OperatorUser(id=str(uuid.uuid4()), email=kw.pop("email", "u@x.com"), + display_name="U", password_hash=hash_password("pw"), role=role, **kw) + db.add(u) + db.commit() + return u + + +def test_flag_off_passes_everything(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=False) + assert client.get("/", follow_redirects=False).status_code == 200 + + +def test_gated_html_redirects_to_login_when_unauth(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.get("/", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"].startswith("/login?next=") + + +def test_gated_api_returns_401_json_when_unauth(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.get("/api/status-snapshot", follow_redirects=False) + assert r.status_code == 401 + + +def test_valid_session_passes(client, db_session, monkeypatch): + u = _make_user(db_session) + wire_operator_auth(monkeypatch, db_session, enabled=True) + client.cookies.set(COOKIE_NAME, make_operator_cookie(u.id)) + assert client.get("/", follow_redirects=False).status_code == 200 + + +def test_must_change_password_user_routed_to_change_password(client, db_session, monkeypatch): + u = _make_user(db_session, must_change_password=True) + wire_operator_auth(monkeypatch, db_session, enabled=True) + client.cookies.set(COOKIE_NAME, make_operator_cookie(u.id)) + r = client.get("/", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/change-password" + + +def test_exempt_paths_pass_without_cookie(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + assert client.get("/health", follow_redirects=False).status_code == 200 + + +def test_portal_paths_are_exempt(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + # /portal/p/ hits the portal's own gate (403/404), never the operator login. + r = client.get("/portal/p/nope", follow_redirects=False) + assert r.status_code in (403, 404) + + +def test_must_change_user_on_api_gets_403_json_not_redirect(client, db_session, monkeypatch): + u = _make_user(db_session, must_change_password=True) + wire_operator_auth(monkeypatch, db_session, enabled=True) + client.cookies.set(COOKIE_NAME, make_operator_cookie(u.id)) + r = client.get("/api/status-snapshot", follow_redirects=False) + assert r.status_code == 403 + assert r.json()["detail"] == "Password change required" From 41ab900c33d287fe4d8bd7fdcc36309c9871e539 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 19:31:31 +0000 Subject: [PATCH 08/31] feat(auth): login/logout/change-password routes + pages Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 3 + backend/routers/operator_auth_routes.py | 111 ++++++++++++++++++++++++ templates/change_password.html | 39 +++++++++ templates/login.html | 32 +++++++ tests/test_operator_login.py | 98 +++++++++++++++++++++ 5 files changed, 283 insertions(+) create mode 100644 backend/routers/operator_auth_routes.py create mode 100644 templates/change_password.html create mode 100644 templates/login.html create mode 100644 tests/test_operator_login.py diff --git a/backend/main.py b/backend/main.py index 6b82727..afa2a63 100644 --- a/backend/main.py +++ b/backend/main.py @@ -95,6 +95,9 @@ async def add_environment_to_context(request: Request, call_next): from backend.operator_auth import operator_gate app.middleware("http")(operator_gate) +from backend.routers import operator_auth_routes +app.include_router(operator_auth_routes.router) + # Override TemplateResponse to include environment and version in context original_template_response = templates.TemplateResponse def custom_template_response(name, context=None, *args, **kwargs): diff --git a/backend/routers/operator_auth_routes.py b/backend/routers/operator_auth_routes.py new file mode 100644 index 0000000..eca901c --- /dev/null +++ b/backend/routers/operator_auth_routes.py @@ -0,0 +1,111 @@ +"""Operator login / logout / change-password. These routes intentionally work +regardless of OPERATOR_AUTH_ENABLED (you log in while the flag is still off during +rollout). /login and /logout are on the gate's exempt list; /change-password +requires a session (the gate sets request.state.operator).""" +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.models import OperatorUser +from backend.templates_config import templates +from backend.operator_auth import ( + authenticate, current_operator, change_own_password, make_operator_cookie, + COOKIE_NAME, COOKIE_MAX_AGE, +) +from backend.auth_cookies import COOKIE_SECURE +from backend.auth_passwords import verify_password + +router = APIRouter(tags=["operator-auth"]) + + +def _safe_next(next_url: str) -> str: + """Only allow same-site relative redirects (an open-redirect guard). Rejects + `//host` and `/\\host` — browsers treat a backslash as `/` in the authority + position, so both escape to an external site.""" + if next_url and next_url.startswith("/") and not next_url.startswith(("//", "/\\")): + return next_url + return "/" + + +@router.get("/login") +async def login_page(request: Request, next: str = "", error: str = ""): + return templates.TemplateResponse("login.html", + {"request": request, "next": next, "error": error}) + + +@router.post("/login") +async def login_submit(request: Request, next: str = "", + email: str = Form(...), password: str = Form(...), + db: Session = Depends(get_db)): + user, status = authenticate(db, email, password) + if status == "locked": + return templates.TemplateResponse( + "login.html", + {"request": request, "next": next, + "error": "Too many attempts — try again in 15 minutes."}, + status_code=200) + if user is None: + return templates.TemplateResponse( + "login.html", + {"request": request, "next": next, "error": "Invalid email or password."}, + status_code=200) + dest = "/change-password" if user.must_change_password else _safe_next(next) + resp = RedirectResponse(url=dest, status_code=303) + resp.set_cookie(COOKIE_NAME, make_operator_cookie(user.id), + max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE) + return resp + + +@router.get("/logout") +async def logout(request: Request): + resp = RedirectResponse(url="/login", status_code=303) + resp.delete_cookie(COOKIE_NAME) + return resp + + +@router.get("/change-password") +async def change_password_page(request: Request, db: Session = Depends(get_db)): + user = getattr(request.state, "operator", None) or current_operator(request, db) + if user is None: + return RedirectResponse(url="/login", status_code=303) + return templates.TemplateResponse( + "change_password.html", + {"request": request, "must_change": user.must_change_password, "error": ""}) + + +@router.post("/change-password") +async def change_password_submit(request: Request, + current_password: str = Form(...), + new_password: str = Form(...), + confirm_password: str = Form(...), + db: Session = Depends(get_db)): + _user_ref = getattr(request.state, "operator", None) or current_operator(request, db) + if _user_ref is None: + return RedirectResponse(url="/login", status_code=303) + # Re-fetch a session-bound copy so mutations via `db` will be committed. + # request.state.operator may be expunged (detached) from the gate's own + # SessionLocal; operating on a detached object against a different session + # would silently drop the UPDATE. + user = db.query(OperatorUser).filter_by(id=_user_ref.id).first() + if user is None: + return RedirectResponse(url="/login", status_code=303) + + def _err(msg): + return templates.TemplateResponse( + "change_password.html", + {"request": request, "must_change": user.must_change_password, "error": msg}, + status_code=200) + + if not verify_password(current_password, user.password_hash): + return _err("Current password is incorrect.") + if len(new_password) < 8: + return _err("New password must be at least 8 characters.") + if new_password != confirm_password: + return _err("New passwords do not match.") + + new_iat = change_own_password(db, user, new_password) + resp = RedirectResponse(url="/", status_code=303) + resp.set_cookie(COOKIE_NAME, make_operator_cookie(user.id, iat=new_iat), + max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE) + return resp diff --git a/templates/change_password.html b/templates/change_password.html new file mode 100644 index 0000000..f9d7a5b --- /dev/null +++ b/templates/change_password.html @@ -0,0 +1,39 @@ + + + + + + Change password · Terra-View + + + +
+

Change your password

+ {% if must_change %} +

Please set a new password to continue.

+ {% endif %} + {% if error %} +
{{ error }}
+ {% endif %} +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..3d0e9b9 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,32 @@ + + + + + + Sign in · Terra-View + + + +
+

Terra-View

+ {% if error %} +
{{ error }}
+ {% endif %} +
+
+ + +
+
+ + +
+ +
+

Forgot your password? Contact your administrator.

+
+ + diff --git a/tests/test_operator_login.py b/tests/test_operator_login.py new file mode 100644 index 0000000..293314c --- /dev/null +++ b/tests/test_operator_login.py @@ -0,0 +1,98 @@ +# tests/test_operator_login.py +import uuid +from tests.conftest import wire_operator_auth +from backend.operator_auth import ( + create_operator, make_operator_cookie, COOKIE_NAME, MAX_LOGIN_FAILURES, +) + + +def test_login_page_renders(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.get("/login") + assert r.status_code == 200 + assert "password" in r.text.lower() + + +def test_login_success_sets_cookie_and_redirects(client, db_session, monkeypatch): + create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9") + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.post("/login", data={"email": "ok@x.com", "password": "rightpw-9"}, + follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/" + assert f"{COOKIE_NAME}=" in r.headers.get("set-cookie", "") + + +def test_login_honors_next(client, db_session, monkeypatch): + create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9") + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.post("/login?next=/settings", data={"email": "ok@x.com", "password": "rightpw-9"}, + follow_redirects=False) + assert r.headers["location"] == "/settings" + + +def test_login_wrong_password_no_cookie_generic_error(client, db_session, monkeypatch): + create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9") + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.post("/login", data={"email": "ok@x.com", "password": "nope"}, + follow_redirects=False) + assert r.status_code == 200 + assert f"{COOKIE_NAME}=" not in r.headers.get("set-cookie", "") + assert "invalid" in r.text.lower() + + +def test_login_must_change_redirects_to_change_password(client, db_session, monkeypatch): + create_operator(db_session, "new@x.com", "New", "admin") # generated temp → must_change + from backend.models import OperatorUser + user = db_session.query(OperatorUser).filter_by(email="new@x.com").first() + from backend.auth_passwords import hash_password + user.password_hash = hash_password("temp-pw-1") + db_session.commit() + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.post("/login", data={"email": "new@x.com", "password": "temp-pw-1"}, + follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/change-password" + + +def test_login_lockout_message_after_five(client, db_session, monkeypatch): + create_operator(db_session, "lk@x.com", "Lk", "admin", password="rightpw-9") + wire_operator_auth(monkeypatch, db_session, enabled=True) + for _ in range(MAX_LOGIN_FAILURES): + client.post("/login", data={"email": "lk@x.com", "password": "nope"}, follow_redirects=False) + r = client.post("/login", data={"email": "lk@x.com", "password": "rightpw-9"}, follow_redirects=False) + assert r.status_code == 200 + assert "too many" in r.text.lower() + + +def test_logout_clears_cookie(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.get("/logout", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/login" + set_cookie = r.headers.get("set-cookie", "").lower() + assert COOKIE_NAME.lower() in set_cookie + assert 'max-age=0' in set_cookie or 'expires=thu, 01 jan 1970' in set_cookie + + +def test_change_password_self_service(client, db_session, monkeypatch): + user, _ = create_operator(db_session, "c@x.com", "C", "admin", password="orig-pw-1") + wire_operator_auth(monkeypatch, db_session, enabled=True) + client.cookies.set(COOKIE_NAME, make_operator_cookie(user.id)) + r = client.post("/change-password", + data={"current_password": "orig-pw-1", "new_password": "brand-new-2", + "confirm_password": "brand-new-2"}, follow_redirects=False) + assert r.status_code == 303 + from backend.auth_passwords import verify_password + db_session.refresh(user) + assert verify_password("brand-new-2", user.password_hash) + assert user.must_change_password is False + + +def test_safe_next_blocks_open_redirect(): + from backend.routers.operator_auth_routes import _safe_next + assert _safe_next("//evil.com") == "/" + assert _safe_next("/\\evil.com") == "/" # backslash authority bypass + assert _safe_next("https://evil.com") == "/" + assert _safe_next("") == "/" + assert _safe_next("/settings") == "/settings" From bff9a4af4a3261b8dbd1308f3d4b895096f24581 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 19:42:05 +0000 Subject: [PATCH 09/31] feat(auth): superadmin user-management page + CRUD /admin/users page and /api/admin/users/* JSON CRUD endpoints, all behind require_role("superadmin"). Temp passwords are returned once on create/reset and never stored in plaintext. Admins get 403; password_hash is never leaked. Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 3 + backend/routers/operator_users.py | 103 +++++++++++++++++++++++++ templates/admin/users.html | 71 ++++++++++++++++++ tests/test_operator_users.py | 120 ++++++++++++++++++++++++++++++ 4 files changed, 297 insertions(+) create mode 100644 backend/routers/operator_users.py create mode 100644 templates/admin/users.html create mode 100644 tests/test_operator_users.py diff --git a/backend/main.py b/backend/main.py index afa2a63..6b7c64f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -98,6 +98,9 @@ app.middleware("http")(operator_gate) from backend.routers import operator_auth_routes app.include_router(operator_auth_routes.router) +from backend.routers import operator_users +app.include_router(operator_users.router) + # Override TemplateResponse to include environment and version in context original_template_response = templates.TemplateResponse def custom_template_response(name, context=None, *args, **kwargs): diff --git a/backend/routers/operator_users.py b/backend/routers/operator_users.py new file mode 100644 index 0000000..4a3531d --- /dev/null +++ b/backend/routers/operator_users.py @@ -0,0 +1,103 @@ +"""Operator account management — superadmin only. Temp passwords are returned in +the JSON response once (shown to the superadmin to hand off); only hashes persist.""" +from fastapi import APIRouter, Request, Depends, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.templates_config import templates +from backend.models import OperatorUser +from backend.operator_auth import ( + require_role, create_operator, reset_operator_password, + set_operator_active, set_operator_role, +) +from backend.utils.timezone import format_local_datetime + +router = APIRouter(tags=["operator-users"]) +_superadmin = require_role("superadmin") + + +class NewUser(BaseModel): + email: str + name: str + role: str = "admin" + + +class RoleChange(BaseModel): + role: str + + +def _serialize(u: OperatorUser) -> dict: + from datetime import datetime + return { + "id": u.id, "email": u.email, "display_name": u.display_name, "role": u.role, + "active": bool(u.active), "must_change_password": bool(u.must_change_password), + "locked": bool(u.locked_until and u.locked_until > datetime.utcnow()), + "last_login_at": format_local_datetime(u.last_login_at, "%Y-%m-%d %H:%M") if u.last_login_at else None, + } + + +@router.get("/admin/users") +async def users_page(request: Request, _=Depends(_superadmin)): + return templates.TemplateResponse("admin/users.html", {"request": request}) + + +@router.get("/api/admin/users") +async def list_users(_=Depends(_superadmin), db: Session = Depends(get_db)): + users = db.query(OperatorUser).order_by(OperatorUser.display_name).all() + return {"users": [_serialize(u) for u in users]} + + +@router.post("/api/admin/users") +async def add_user(body: NewUser, _=Depends(_superadmin), db: Session = Depends(get_db)): + if body.role not in ("admin", "superadmin"): + return JSONResponse(status_code=400, content={"detail": "role must be admin or superadmin"}) + try: + user, raw = create_operator(db, body.email, body.name, body.role) + except ValueError as e: + return JSONResponse(status_code=400, content={"detail": str(e)}) + return {"user": _serialize(user), "password": raw} + + +@router.post("/api/admin/users/{user_id}/reset-password") +async def reset_user_password(user_id: str, _=Depends(_superadmin), db: Session = Depends(get_db)): + user = db.query(OperatorUser).filter_by(id=user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + raw = reset_operator_password(db, user) + return {"password": raw} + + +@router.post("/api/admin/users/{user_id}/disable") +async def disable_user(user_id: str, acting=Depends(_superadmin), db: Session = Depends(get_db)): + if acting and acting.id == user_id: + return JSONResponse(status_code=400, content={"detail": "Cannot disable your own account"}) + user = db.query(OperatorUser).filter_by(id=user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + set_operator_active(db, user, False) + return {"active": False} + + +@router.post("/api/admin/users/{user_id}/enable") +async def enable_user(user_id: str, _=Depends(_superadmin), db: Session = Depends(get_db)): + user = db.query(OperatorUser).filter_by(id=user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + set_operator_active(db, user, True) + return {"active": True} + + +@router.post("/api/admin/users/{user_id}/role") +async def change_user_role(user_id: str, body: RoleChange, + acting=Depends(_superadmin), db: Session = Depends(get_db)): + if acting and acting.id == user_id: + return JSONResponse(status_code=400, content={"detail": "Cannot change your own role"}) + if body.role not in ("admin", "superadmin"): + return JSONResponse(status_code=400, content={"detail": "role must be admin or superadmin"}) + user = db.query(OperatorUser).filter_by(id=user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + set_operator_role(db, user, body.role) + return {"role": user.role} diff --git a/templates/admin/users.html b/templates/admin/users.html new file mode 100644 index 0000000..a16d42b --- /dev/null +++ b/templates/admin/users.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}Operator Accounts{% endblock %} +{% block content %} +
+
+

Operator Accounts

+ +
+ + + + + + +
NameEmailRoleStatusLast login
+
+ +{% endblock %} diff --git a/tests/test_operator_users.py b/tests/test_operator_users.py new file mode 100644 index 0000000..1d149aa --- /dev/null +++ b/tests/test_operator_users.py @@ -0,0 +1,120 @@ +# tests/test_operator_users.py +import uuid +from tests.conftest import wire_operator_auth +from backend.operator_auth import create_operator, make_operator_cookie, COOKIE_NAME +from backend.models import OperatorUser + + +def _login_as(client, user): + client.cookies.set(COOKIE_NAME, make_operator_cookie(user.id)) + + +def test_admin_cannot_reach_user_management(client, db_session, monkeypatch): + admin, _ = create_operator(db_session, "admin@x.com", "Admin", "admin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, admin) + assert client.get("/admin/users", follow_redirects=False).status_code == 403 + + +def test_superadmin_sees_user_management(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, su) + assert client.get("/admin/users", follow_redirects=False).status_code == 200 + + +def test_superadmin_lists_users_json(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, su) + r = client.get("/api/admin/users") + assert r.status_code == 200 + emails = [u["email"] for u in r.json()["users"]] + assert "su@x.com" in emails + assert all("password_hash" not in u for u in r.json()["users"]) # never leak hashes + + +def test_create_user_returns_temp_once(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, su) + r = client.post("/api/admin/users", + json={"email": "dad@x.com", "name": "Dad", "role": "admin"}) + assert r.status_code == 200 + assert len(r.json()["password"]) >= 12 + made = db_session.query(OperatorUser).filter_by(email="dad@x.com").first() + assert made.must_change_password is True + + +def test_reset_password_returns_temp_once(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, su) + r = client.post(f"/api/admin/users/{target.id}/reset-password") + assert r.status_code == 200 and len(r.json()["password"]) >= 12 + db_session.refresh(target) + assert target.must_change_password is True + + +def test_disable_and_enable(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, su) + assert client.post(f"/api/admin/users/{target.id}/disable").status_code == 200 + db_session.refresh(target); assert target.active is False + assert client.post(f"/api/admin/users/{target.id}/enable").status_code == 200 + db_session.refresh(target); assert target.active is True + + +def test_change_role(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, su) + r = client.post(f"/api/admin/users/{target.id}/role", json={"role": "superadmin"}) + assert r.status_code == 200 + db_session.refresh(target); assert target.role == "superadmin" + + +def test_admin_cannot_reach_json_endpoints(client, db_session, monkeypatch): + admin, _ = create_operator(db_session, "a@x.com", "A", "admin", password="pw-123456") + target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, admin) + assert client.get("/api/admin/users").status_code == 403 + assert client.post("/api/admin/users", json={"email": "x@x.com", "name": "X", "role": "admin"}).status_code == 403 + assert client.post(f"/api/admin/users/{target.id}/reset-password").status_code == 403 + assert client.post(f"/api/admin/users/{target.id}/disable").status_code == 403 + assert client.post(f"/api/admin/users/{target.id}/enable").status_code == 403 + assert client.post(f"/api/admin/users/{target.id}/role", json={"role": "superadmin"}).status_code == 403 + + +def test_cannot_disable_own_account(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, su) + r = client.post(f"/api/admin/users/{su.id}/disable") + assert r.status_code == 400 + db_session.refresh(su) + assert su.active is True + + +def test_cannot_change_own_role(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, su) + r = client.post(f"/api/admin/users/{su.id}/role", json={"role": "admin"}) + assert r.status_code == 400 + db_session.refresh(su) + assert su.role == "superadmin" + + +def test_deferred_operator_role_rejected_by_api(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=True) + _login_as(client, su) + assert client.post("/api/admin/users", json={"email": "op@x.com", "name": "Op", "role": "operator"}).status_code == 400 + assert client.post(f"/api/admin/users/{target.id}/role", json={"role": "operator"}).status_code == 400 From 7a4453108a0eaf02b9c05d57bbd0ddf6284d870e Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 19:50:37 +0000 Subject: [PATCH 10/31] feat(auth): operator admin/break-glass CLI Co-Authored-By: Claude Sonnet 4.6 --- backend/operator_admin.py | 137 +++++++++++++++++++++++++++++++ tests/test_operator_admin_cli.py | 44 ++++++++++ 2 files changed, 181 insertions(+) create mode 100644 backend/operator_admin.py create mode 100644 tests/test_operator_admin_cli.py diff --git a/backend/operator_admin.py b/backend/operator_admin.py new file mode 100644 index 0000000..f5860fb --- /dev/null +++ b/backend/operator_admin.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Operator-account admin CLI — the bootstrap and the break-glass. Run inside the +terra-view container against the live DB. Temp/raw passwords are printed ONCE; only +hashes persist. + + # first superadmin (before any UI is reachable) — prompts for a password, or --generate + python3 backend/operator_admin.py create-superadmin --email you@x.com --name "Brian" + + # a parent's account — generates a temp password, must-change on first login + python3 backend/operator_admin.py create-user --email dad@x.com --name "Dad" --role admin + + python3 backend/operator_admin.py reset-password --email dad@x.com + python3 backend/operator_admin.py list + python3 backend/operator_admin.py disable --email dad@x.com + python3 backend/operator_admin.py enable --email dad@x.com +""" +import os +import sys +import getpass +import argparse +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend.database import SessionLocal +from backend.models import OperatorUser +from backend.operator_auth import ( + create_operator, reset_operator_password, set_operator_active, _norm_email, +) + + +def _get(db, email): + u = db.query(OperatorUser).filter_by(email=_norm_email(email)).first() + if not u: + sys.exit(f"No operator with email '{email}'.") + return u + + +def cmd_create_superadmin(email, name, password=None, generate=False): + db = SessionLocal() + try: + if password is None and not generate: + password = getpass.getpass("Password for new superadmin: ") + if not password or len(password) < 8: + sys.exit("Password must be at least 8 characters.") + user, raw = create_operator(db, email, name, "superadmin", + password=None if generate else password) + if generate: + print(f"✓ Superadmin {user.email} created. Temp password (shown once): {raw}") + else: + print(f"✓ Superadmin {user.email} created.") + except ValueError as e: + sys.exit(str(e)) + finally: + db.close() + + +def cmd_create_user(email, name, role="admin"): + db = SessionLocal() + try: + user, raw = create_operator(db, email, name, role) + print(f"✓ {role} {user.email} created. Temp password (shown once): {raw}") + print(" They'll be required to change it on first login.") + except ValueError as e: + sys.exit(str(e)) + finally: + db.close() + + +def cmd_reset_password(email): + db = SessionLocal() + try: + user = _get(db, email) + raw = reset_operator_password(db, user) + print(f"✓ Reset {user.email}. Temp password (shown once): {raw}") + finally: + db.close() + + +def cmd_set_active(email, active): + db = SessionLocal() + try: + user = _get(db, email) + set_operator_active(db, user, active) + print(f"✓ {user.email} {'enabled' if active else 'disabled'}.") + finally: + db.close() + + +def cmd_list(): + db = SessionLocal() + try: + users = db.query(OperatorUser).order_by(OperatorUser.display_name).all() + if not users: + print("No operators yet. Run create-superadmin first.") + return + for u in users: + locked = " [LOCKED]" if (u.locked_until and u.locked_until > datetime.utcnow()) else "" + state = "active" if u.active else "DISABLED" + last = u.last_login_at.strftime("%Y-%m-%d %H:%M") if u.last_login_at else "never" + print(f" {u.display_name:<12} {u.email:<28} {u.role:<11} {state}{locked} last: {last}") + finally: + db.close() + + +def main(): + ap = argparse.ArgumentParser(description="Operator-account admin") + sub = ap.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("create-superadmin") + p.add_argument("--email", required=True); p.add_argument("--name", required=True) + p.add_argument("--generate", action="store_true", help="generate a temp password instead of prompting") + p.set_defaults(fn=lambda a: cmd_create_superadmin(a.email, a.name, generate=a.generate)) + + p = sub.add_parser("create-user") + p.add_argument("--email", required=True); p.add_argument("--name", required=True) + p.add_argument("--role", default="admin", choices=["admin", "superadmin"]) + p.set_defaults(fn=lambda a: cmd_create_user(a.email, a.name, a.role)) + + p = sub.add_parser("reset-password") + p.add_argument("--email", required=True) + p.set_defaults(fn=lambda a: cmd_reset_password(a.email)) + + p = sub.add_parser("disable"); p.add_argument("--email", required=True) + p.set_defaults(fn=lambda a: cmd_set_active(a.email, False)) + + p = sub.add_parser("enable"); p.add_argument("--email", required=True) + p.set_defaults(fn=lambda a: cmd_set_active(a.email, True)) + + p = sub.add_parser("list"); p.set_defaults(fn=lambda a: cmd_list()) + + args = ap.parse_args() + args.fn(args) + + +if __name__ == "__main__": + main() diff --git a/tests/test_operator_admin_cli.py b/tests/test_operator_admin_cli.py new file mode 100644 index 0000000..2e9d4e4 --- /dev/null +++ b/tests/test_operator_admin_cli.py @@ -0,0 +1,44 @@ +# tests/test_operator_admin_cli.py +from sqlalchemy.orm import sessionmaker +from backend.models import OperatorUser +from backend.auth_passwords import verify_password +import backend.operator_admin as cli + + +def _maker(db_session): + return sessionmaker(bind=db_session.get_bind(), autocommit=False, autoflush=False) + + +def test_seed_superadmin(db_session, monkeypatch): + monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False) + cli.cmd_create_superadmin(email="brian@x.com", name="Brian", password="chosen-pw-1") + u = db_session.query(OperatorUser).filter_by(email="brian@x.com").first() + assert u.role == "superadmin" + assert u.must_change_password is False + assert verify_password("chosen-pw-1", u.password_hash) + + +def test_create_user_generates_temp(db_session, monkeypatch, capsys): + monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False) + cli.cmd_create_user(email="dad@x.com", name="Dad", role="admin") + u = db_session.query(OperatorUser).filter_by(email="dad@x.com").first() + assert u.role == "admin" and u.must_change_password is True + assert "dad@x.com" in capsys.readouterr().out # prints the temp once + + +def test_reset_password_cli(db_session, monkeypatch): + monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False) + cli.cmd_create_user(email="r@x.com", name="R", role="admin") + before = db_session.query(OperatorUser).filter_by(email="r@x.com").first().password_hash + cli.cmd_reset_password(email="r@x.com") + after = db_session.query(OperatorUser).filter_by(email="r@x.com").first().password_hash + assert before != after + + +def test_disable_enable_cli(db_session, monkeypatch): + monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False) + cli.cmd_create_user(email="d@x.com", name="D", role="admin") + cli.cmd_set_active(email="d@x.com", active=False) + assert db_session.query(OperatorUser).filter_by(email="d@x.com").first().active is False + cli.cmd_set_active(email="d@x.com", active=True) + assert db_session.query(OperatorUser).filter_by(email="d@x.com").first().active is True From dc95a59dfa1787c56d972b523f941a022f5801f2 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 20:14:30 +0000 Subject: [PATCH 11/31] =?UTF-8?q?test(auth):=20regression=20guard=20?= =?UTF-8?q?=E2=80=94=20gate=20never=20blocks=20machine=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- tests/test_operator_machine_endpoints.py | 42 ++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/test_operator_machine_endpoints.py diff --git a/tests/test_operator_machine_endpoints.py b/tests/test_operator_machine_endpoints.py new file mode 100644 index 0000000..21febbd --- /dev/null +++ b/tests/test_operator_machine_endpoints.py @@ -0,0 +1,42 @@ +# tests/test_operator_machine_endpoints.py +from tests.conftest import wire_operator_auth + + +def test_machine_endpoints_not_blocked_by_gate(client, db_session, monkeypatch): + """With the gate ON and no cookie, the LAN-only watcher endpoints must reach + their handlers (the gate must never silently break heartbeats). A handler may + return 422 for an empty body — that still proves the gate let it through. + + Note: /emitters/report uses a minimal valid body to avoid triggering the + app's validation_exception_handler (which calls await request.body() — a + known deadlock in Starlette 0.27 TestClient when the body is already + consumed). The gate behaviour is identical regardless of body validity. + """ + wire_operator_auth(monkeypatch, db_session, enabled=True) + + r = client.post("/api/series3/heartbeat", json={}, follow_redirects=False) + assert r.status_code != 401 # gate would 401 an unauth /api/* route + assert r.status_code != 303 + + r = client.post("/api/series4/heartbeat", json={}, follow_redirects=False) + assert r.status_code not in (401, 303) + + # /emitters/report is a sync endpoint with required Pydantic fields; supply a + # valid body so the validation_exception_handler (which awaits request.body()) + # is never triggered — that handler deadlocks the Starlette 0.27 TestClient. + valid_report = { + "unit": "TEST001", + "unit_type": "series3", + "timestamp": "2024-01-01T00:00:00Z", + "file": "test.evt", + "status": "OK", + } + r = client.post("/emitters/report", json=valid_report, follow_redirects=False) + assert r.status_code != 303 # gate would 303 an unauth HTML route + + +def test_static_assets_exempt(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + # /sw.js and /manifest.json are PWA assets clients fetch pre-login. + assert client.get("/sw.js", follow_redirects=False).status_code in (200, 404) + assert client.get("/sw.js", follow_redirects=False).status_code != 303 From 054eebe68b80233a1a9cab82abc7f507763a9cf3 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 20:17:01 +0000 Subject: [PATCH 12/31] chore(auth): wire OPERATOR_AUTH_ENABLED into compose + changelog --- CHANGELOG.md | 1 + docker-compose.yml | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 727b9ce..38478d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ SLM live monitoring — fan-out feed + cache-first reads. Targets **0.14.0**. ### Added +- **Operator authentication (login + roles), shipped dark behind `OPERATOR_AUTH_ENABLED`.** The internal app gains a deny-by-default login gate (one Starlette middleware over every route except an allow-list: `/login` `/logout` `/health` `/static/*` `/portal/*` + the three machine heartbeat endpoints). Two roles — `superadmin` (account management) and `admin` (full app); `operator` reserved. Sessions are a 30-day HMAC-signed `tv_session` cookie re-validated against the DB each request (instant revoke via `active` / `sessions_valid_from`). Password reset is superadmin-driven: reset-anyone (temp shown once + forced change), self-service `/change-password`, and a `backend/operator_admin.py` seed/break-glass CLI. Brute-force lockout (5 tries / 15 min) + constant-time login (no user-enumeration). New `operator_users` table auto-creates — no migration. Reuses the portal's argon2 hasher + a new shared `backend/auth_cookies.py` signer. Rollout: ship with the flag off (app unchanged), seed a superadmin, confirm login, then flip on — the flag is an instant escape hatch. Spec: `docs/superpowers/specs/2026-06-17-operator-auth-design.md`. - **Fan-out `/monitor` feed consumption.** The unit live view (`partials/slm_live_view.html`) and the dashboard live tile (`sound_level_meters.html`) now subscribe to SLMM's shared per-device monitor over `WS /api/slmm/{unit}/monitor` instead of each opening its own device stream. Any number of clients attach without each consuming the NL-43's single connection — the "second viewer sees nothing" contention is gone. A WS proxy handler for `/monitor` was added to `backend/routers/slmm.py`. - **L1/L10 percentile lines + cards.** Both the per-unit live chart and the dashboard card chart now plot L1 (purple) and L10 (orange) alongside Lp/Leq, and the KPI cards show L1/L10. Sourced from the DOD feed's `ln1`/`ln2` (DRD streaming can't carry percentiles, DOD can). Missing/`-.-` values leave a gap rather than dropping the line to 0. - **Live-chart backfill on open.** Charts seed from SLMM's downsampled DOD trail (`GET /api/slmm/{unit}/history?hours=2`) so a viewer sees recent trend immediately instead of a blank chart that fills one point per second. diff --git a/docker-compose.yml b/docker-compose.yml index 93ef140..1080a91 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,10 @@ services: # browser won't send the cookie and the portal breaks). - SECRET_KEY=${SECRET_KEY:-dev-insecure-change-me} - COOKIE_SECURE=${COOKIE_SECURE:-false} + # Operator login gate. Leave false to ship dark; seed a superadmin via + # backend/operator_admin.py, confirm you can log in, THEN set true to enforce. + # Instant escape hatch: set back to false. See docs/superpowers/specs/2026-06-17-operator-auth-design.md + - OPERATOR_AUTH_ENABLED=${OPERATOR_AUTH_ENABLED:-false} # Display timezone for server logs + any text-rendered timestamps. # DB columns are stored UTC regardless; this only affects what # operators see. Override here for non-US-East deployments. From 68161298a458c0eae09560a16d3581917bcdcdba Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 20:27:01 +0000 Subject: [PATCH 13/31] fix(auth): hide /admin/users when flag off; pass OPTIONS preflight through gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - operator_users router now depends on _require_auth_enabled, which raises 404 when OPERATOR_AUTH_ENABLED is false — prevents world-open pre-seeding of a superadmin while the flag is off (the default). Flag is read as a live module attribute (operator_auth.OPERATOR_AUTH_ENABLED) so monkeypatching in tests and a runtime flip both take effect. - operator_gate passes OPTIONS requests through immediately before the exempt- path check, so CORS preflight reaches CORSMiddleware rather than being 303/401'd by the gate. - Two new tests: test_admin_surface_404s_when_flag_off (test_operator_users) and test_options_preflight_passes_through_gate (test_operator_gate). Full suite: 90 passed. Co-Authored-By: Claude Sonnet 4.6 --- backend/operator_auth.py | 4 ++++ backend/routers/operator_users.py | 14 +++++++++++++- tests/test_operator_gate.py | 7 +++++++ tests/test_operator_users.py | 12 ++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/backend/operator_auth.py b/backend/operator_auth.py index 93b5cc6..1069003 100644 --- a/backend/operator_auth.py +++ b/backend/operator_auth.py @@ -185,6 +185,10 @@ async def operator_gate(request: Request, call_next): if not OPERATOR_AUTH_ENABLED: return await call_next(request) + # CORS preflight carries no auth and must reach CORSMiddleware, not the gate. + if request.method == "OPTIONS": + return await call_next(request) + path = request.url.path if _is_exempt(path): return await call_next(request) diff --git a/backend/routers/operator_users.py b/backend/routers/operator_users.py index 4a3531d..6d58b9f 100644 --- a/backend/routers/operator_users.py +++ b/backend/routers/operator_users.py @@ -12,9 +12,21 @@ from backend.operator_auth import ( require_role, create_operator, reset_operator_password, set_operator_active, set_operator_role, ) +import backend.operator_auth as operator_auth from backend.utils.timezone import format_local_datetime -router = APIRouter(tags=["operator-users"]) + +def _require_auth_enabled(): + """The operator-management surface does not exist while operator auth is + disabled — otherwise these net-new endpoints would be world-open with the + flag off (the default), letting anyone pre-seed a superadmin. Read the flag + as a live module attribute so the test monkeypatch and a runtime flip both + take effect.""" + if not operator_auth.OPERATOR_AUTH_ENABLED: + raise HTTPException(status_code=404, detail="Not found") + + +router = APIRouter(tags=["operator-users"], dependencies=[Depends(_require_auth_enabled)]) _superadmin = require_role("superadmin") diff --git a/tests/test_operator_gate.py b/tests/test_operator_gate.py index bb0697d..01494aa 100644 --- a/tests/test_operator_gate.py +++ b/tests/test_operator_gate.py @@ -67,3 +67,10 @@ def test_must_change_user_on_api_gets_403_json_not_redirect(client, db_session, r = client.get("/api/status-snapshot", follow_redirects=False) assert r.status_code == 403 assert r.json()["detail"] == "Password change required" + + +def test_options_preflight_passes_through_gate(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + # CORS preflight has no cookie; the gate must not 303/401 it. + r = client.options("/api/status-snapshot", follow_redirects=False) + assert r.status_code not in (303, 401) diff --git a/tests/test_operator_users.py b/tests/test_operator_users.py index 1d149aa..b8881e0 100644 --- a/tests/test_operator_users.py +++ b/tests/test_operator_users.py @@ -118,3 +118,15 @@ def test_deferred_operator_role_rejected_by_api(client, db_session, monkeypatch) _login_as(client, su) assert client.post("/api/admin/users", json={"email": "op@x.com", "name": "Op", "role": "operator"}).status_code == 400 assert client.post(f"/api/admin/users/{target.id}/role", json={"role": "operator"}).status_code == 400 + + +def test_admin_surface_404s_when_flag_off(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=False) + _login_as(client, su) + # With operator auth OFF, the management surface must not exist (404), even + # though require_role passes through — otherwise it'd be world-open. + assert client.get("/admin/users").status_code == 404 + assert client.get("/api/admin/users").status_code == 404 + assert client.post("/api/admin/users", + json={"email": "x@x.com", "name": "X", "role": "admin"}).status_code == 404 From c5ffa5c8ea4da2d90b063d85883216f9588e3409 Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 18 Jun 2026 20:03:35 +0000 Subject: [PATCH 14/31] docs(deploy): add .env.example documenting SECRET_KEY / COOKIE_SECURE / OPERATOR_AUTH_ENABLED --- .env.example | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f529dc1 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Terra-View deployment configuration — EXAMPLE / template. +# +# Copy this to `.env` in the same directory as docker-compose.yml and fill in +# real values: cp .env.example .env +# `.env` is gitignored — NEVER commit real secrets. Docker Compose auto-loads +# `.env` and substitutes these into the ${VAR} placeholders in docker-compose.yml. + +# Cookie-signing secret shared by the client portal AND the operator-auth +# session cookie. MUST be a strong random value in production — the in-code +# fallback ("dev-insecure-change-me") is public and forgeable. +# Generate one (and keep it secret): +# python3 -c "import secrets; print(secrets.token_urlsafe(48))" +SECRET_KEY=change-me-generate-a-strong-random-value + +# Set true ONLY when the app is served over HTTPS. On plain HTTP leave it false, +# or the browser won't send the session cookie and login will look broken. +COOKIE_SECURE=false + +# Operator-auth login gate. Leave false to deploy "dark" (the app behaves exactly +# as before — nothing gated, nothing can lock you out). Roll out by: deploy with +# false -> seed a superadmin via `docker compose exec web-app python3 +# backend/operator_admin.py create-superadmin ...` -> confirm you can log in -> +# set true and `docker compose up -d web-app` to enforce. Setting it back to +# false is the instant escape hatch. +OPERATOR_AUTH_ENABLED=false From c9bb25e7e18f52c0fc8911b02141782be5b7f522 Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 18 Jun 2026 20:43:29 +0000 Subject: [PATCH 15/31] =?UTF-8?q?chore(release):=200.15.0=20=E2=80=94=20op?= =?UTF-8?q?erator=20authentication=20(version,=20CHANGELOG=20split,=20SW?= =?UTF-8?q?=20cache,=20test=20isolation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 21 ++++++++++++++++++++- backend/main.py | 2 +- backend/static/sw.js | 2 +- docs/ROADMAP.md | 2 +- tests/conftest.py | 14 ++++++++++++++ 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aea8f7e..fa43ec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.0] - 2026-06-18 + +**Operator authentication** — the internal app gets a login. The operator-facing surface had **zero auth**; this adds a deny-by-default login gate and roles, the prerequisite that makes the app safe to put behind a public URL (office-deployment sequencing: auth → expose). Built test-first (10 tasks, 90 passing tests — the project's first auth test suite alongside the portal's). + +### Added + +- **Operator authentication (login + roles), shipped dark behind `OPERATOR_AUTH_ENABLED`.** The internal app gains a deny-by-default login gate (one Starlette middleware over every route except an allow-list: `/login` `/logout` `/health` `/static/*` `/portal/*` + the three machine heartbeat endpoints `/emitters/report` `/api/series3/heartbeat` `/api/series4/heartbeat`). Two roles — `superadmin` (account management at `/admin/users`) and `admin` (full app); `operator` reserved/deferred. Sessions are a 30-day HMAC-signed `tv_session` cookie re-validated against the DB each request (instant revoke via `active` / `sessions_valid_from`). Password reset is superadmin-driven: reset-anyone (temp shown once + forced change), self-service `/change-password`, and a `backend/operator_admin.py` seed/break-glass CLI. Brute-force lockout (5 tries / 15 min) + constant-time login (no user-enumeration). Reuses the portal's argon2 hasher + a new shared `backend/auth_cookies.py` HMAC signer. Spec: `docs/superpowers/specs/2026-06-17-operator-auth-design.md`, plan: `docs/superpowers/plans/2026-06-17-operator-auth.md`. +- **`.env.example`** documenting `SECRET_KEY` / `COOKIE_SECURE` / `OPERATOR_AUTH_ENABLED` for deployment. + +### Known limitations + +- **SLMM proxy WebSocket endpoints bypass the gate.** `/api/slmm/{id}/stream|live|monitor` are WebSocket upgrades, which a Starlette HTTP middleware never sees — they stay unauthenticated even with the gate on. Pre-existing (not a regression); close it (in-handler `tv_session` check, as the portal WS already does) before true internet exposure. +- **No TLS yet** — until served over HTTPS the login password crosses the wire in cleartext. Still a large improvement over the prior zero-auth exposure; real internet exposure needs the deployment-phase TLS. + +### Upgrade Notes + +- New `operator_users` table **auto-creates on startup — no migration**. +- Set a real `SECRET_KEY` (in a gitignored `.env`; template at `.env.example`) before internet exposure; set `COOKIE_SECURE=true` once on HTTPS (leave `false` on plain HTTP or the browser won't send the cookie). +- **Rollout (no self-lockout):** deploy with `OPERATOR_AUTH_ENABLED=false` (app behaves exactly as before) → seed a superadmin via `docker compose exec web-app python3 backend/operator_admin.py create-superadmin …` → confirm you can log in → set the flag `true` and `docker compose up -d web-app`. Flipping it back to `false` is the instant escape hatch. + ## [0.14.0] - 2026-06-17 Rounds out **sound monitoring** and adds a **client-facing portal**, consolidating four threads since 0.13.x: SLM live monitoring (now on SLMM's shared, cached feed), an automated **FTP night-report pipeline**, a read-only **client portal**, and **per-project password auth** for it. Depends on the matching **SLMM `dev`** build — see Upgrade Notes at the end of each section. @@ -17,7 +37,6 @@ SLM live monitoring — fan-out feed + cache-first reads. The throughline: the #### Added -- **Operator authentication (login + roles), shipped dark behind `OPERATOR_AUTH_ENABLED`.** The internal app gains a deny-by-default login gate (one Starlette middleware over every route except an allow-list: `/login` `/logout` `/health` `/static/*` `/portal/*` + the three machine heartbeat endpoints). Two roles — `superadmin` (account management) and `admin` (full app); `operator` reserved. Sessions are a 30-day HMAC-signed `tv_session` cookie re-validated against the DB each request (instant revoke via `active` / `sessions_valid_from`). Password reset is superadmin-driven: reset-anyone (temp shown once + forced change), self-service `/change-password`, and a `backend/operator_admin.py` seed/break-glass CLI. Brute-force lockout (5 tries / 15 min) + constant-time login (no user-enumeration). New `operator_users` table auto-creates — no migration. Reuses the portal's argon2 hasher + a new shared `backend/auth_cookies.py` signer. Rollout: ship with the flag off (app unchanged), seed a superadmin, confirm login, then flip on — the flag is an instant escape hatch. Spec: `docs/superpowers/specs/2026-06-17-operator-auth-design.md`. - **Fan-out `/monitor` feed consumption.** The unit live view (`partials/slm_live_view.html`) and the dashboard live tile (`sound_level_meters.html`) now subscribe to SLMM's shared per-device monitor over `WS /api/slmm/{unit}/monitor` instead of each opening its own device stream. Any number of clients attach without each consuming the NL-43's single connection — the "second viewer sees nothing" contention is gone. A WS proxy handler for `/monitor` was added to `backend/routers/slmm.py`. - **L1/L10 percentile lines + cards.** Both the per-unit live chart and the dashboard card chart now plot L1 (purple) and L10 (orange) alongside Lp/Leq, and the KPI cards show L1/L10. Sourced from the DOD feed's `ln1`/`ln2` (DRD streaming can't carry percentiles, DOD can). Missing/`-.-` values leave a gap rather than dropping the line to 0. - **Live-chart backfill on open.** Charts seed from SLMM's downsampled DOD trail (`GET /api/slmm/{unit}/history?hours=2`) so a viewer sees recent trend immediately instead of a blank chart that fills one point per second. diff --git a/backend/main.py b/backend/main.py index 0c5e59c..31e7db3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine) ENVIRONMENT = os.getenv("ENVIRONMENT", "production") # Initialize FastAPI app -VERSION = "0.14.0" +VERSION = "0.15.0" if ENVIRONMENT == "development": _build = os.getenv("BUILD_NUMBER", "0") if _build and _build != "0": diff --git a/backend/static/sw.js b/backend/static/sw.js index f321980..30f8279 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -8,7 +8,7 @@ // PWA users actually receive the new bundles instead of being stuck on // the pre-bump version. Convention: keep it in sync with the Terra-View // version string in backend/main.py. -const CACHE_VERSION = 'v0.13.2'; +const CACHE_VERSION = 'v0.15.0'; const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`; const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`; const DATA_CACHE = `sfm-data-${CACHE_VERSION}`; diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b3adc16..9315126 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -4,7 +4,7 @@ Living document — captures known deferred work, in-flight initiatives, and lon Bump items up/down or strike them through as priorities shift. Source of truth for "what's next" should be this file plus the `## Current Development Focus` block in `CLAUDE.md`. -Last updated: 2026-06-05 (Terra-View v0.13.3) +Last updated: 2026-06-18 (Terra-View v0.15.0) --- diff --git a/tests/conftest.py b/tests/conftest.py index 031748c..717d19d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,6 +50,20 @@ def _reset_portal_lockout(): yield +@pytest.fixture(autouse=True) +def _operator_auth_off_by_default(monkeypatch): + """Pin the operator-auth gate OFF for every test, so the suite is deterministic + regardless of the container's OPERATOR_AUTH_ENABLED env (the dev container may + have it ON for manual testing). Tests that exercise the gate opt in via + wire_operator_auth(enabled=True), which overrides this within the test body.""" + try: + import backend.operator_auth as _oa + monkeypatch.setattr(_oa, "OPERATOR_AUTH_ENABLED", False, raising=False) + except Exception: + pass + yield + + def make_project(db_session, name=None, **kwargs): """Insert and return a Project with a unique name.""" p = models.Project( From c049ac8a4127d089ef59a3869f8cba8821bd3661 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 21 Jun 2026 20:22:52 +0000 Subject: [PATCH 16/31] fix: SLM control no longer shows false "Unknown error" on start Starting a measurement could pop "Error: Unknown error" in the browser even though the device started recording fine. Two causes: the proxy's 10s timeout was shorter than a real device start over cellular, and on an httpx timeout str(e) is empty, so the relayed detail was "" -> the frontend's `result.detail || 'Unknown error'` rendered "Unknown error". - Raise the control proxy timeout to 30s so a healthy start isn't cut off. - Surface SLMM's own error detail on non-200 responses. - Add an explicit, honest timeout message. - Never return an empty detail (which rendered as "Unknown error"). Pairs with the SLMM-side fix that makes /start confirm promptly. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/slm_dashboard.py | 33 +++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/backend/routers/slm_dashboard.py b/backend/routers/slm_dashboard.py index d35746c..bb8d9a0 100644 --- a/backend/routers/slm_dashboard.py +++ b/backend/routers/slm_dashboard.py @@ -207,24 +207,39 @@ async def control_slm(unit_id: str, action: str): return {"status": "error", "detail": f"Invalid action. Must be one of: {valid_actions}"} try: - async with httpx.AsyncClient(timeout=10.0) as client: + # 30s: a real device start confirms over cellular in a few seconds, but + # leave headroom so a healthy start is never cut off mid-flight (which + # surfaced to users as a misleading "Unknown error"). + async with httpx.AsyncClient(timeout=30.0) as client: response = await client.post( f"{SLMM_BASE_URL}/api/nl43/{unit_id}/{action}" ) if response.status_code == 200: return response.json() - else: - return { - "status": "error", - "detail": f"SLMM returned status {response.status_code}" - } - except Exception as e: - logger.error(f"Failed to control {unit_id}: {e}") + + # Surface SLMM's own error detail when it provides one. + detail = f"SLMM returned status {response.status_code}" + try: + body = response.json() + if isinstance(body, dict) and body.get("detail"): + detail = body["detail"] + except Exception: + pass + return {"status": "error", "detail": detail} + except httpx.TimeoutException: + logger.error(f"Timeout controlling {unit_id} (action={action}) via SLMM") return { "status": "error", - "detail": str(e) + "detail": ( + f"Timed out waiting for the device to {action}. " + f"The command may still have been applied — refresh to confirm." + ), } + except Exception as e: + logger.error(f"Failed to control {unit_id}: {e}") + # Never return an empty detail — it renders to users as "Unknown error". + return {"status": "error", "detail": str(e) or f"{type(e).__name__}"} @router.get("/config/{unit_id}", response_class=HTMLResponse) async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)): From 76330f6137d6f9a948c1d472c936a62258145fd4 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 21 Jun 2026 21:15:23 +0000 Subject: [PATCH 17/31] feat: live monitoring section on internal project Overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client portal has a live dashboard but the internal project page only showed static counts. Add a portal-style live section to the Overview tab so operators can see real-time sound levels at a glance. Backend: - New GET /api/projects/{id}/live-stats — resolves each sound NRL to its active SLM unit and returns SLMM's cached /status snapshot (concurrent fetch). Internal-rich: includes battery/power/reachability the portal scrubs. Degrades to no_device/unreachable/no_data per location. Frontend (project detail Overview tab): - Rollup strip (live / offline / loudest-now) + a live tile per NRL with a Live/Stopped/Offline/Wedged badge, color-coded Leq (55/70 thresholds), Lp/Lmax, last-seen, and battery/power. - Self-refreshes every 15s, pauses when the browser tab is hidden, and sits outside the 30s htmx dashboard swap so it never flickers. Polls only for projects with the sound module. Reuses the same SLMM /status source as the portal; no SLMM changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/projects.py | 94 ++++++++++++++++++ templates/projects/detail.html | 175 +++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) diff --git a/backend/routers/projects.py b/backend/routers/projects.py index b1407de..4c94f2b 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -1103,6 +1103,100 @@ async def get_project_dashboard( }) +@router.get("/{project_id}/live-stats") +async def get_project_live_stats(project_id: str, db: Session = Depends(get_db)): + """Live SLM readings for each sound NRL in the project. + + Reads SLMM's cached per-unit status snapshots (the same source the client + portal uses) and returns one entry per active sound location. Powers the + Overview tab's live monitoring section. Internal-only, so it includes + device-health fields (battery, power source, reachability) the portal hides. + """ + import os + import asyncio + import httpx + + project = db.query(Project).filter_by(id=project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + now = datetime.utcnow() + locations = ( + db.query(MonitoringLocation) + .filter( + MonitoringLocation.project_id == project_id, + MonitoringLocation.location_type == "sound", + MonitoringLocation.removed_at.is_(None), + ) + .order_by(MonitoringLocation.sort_order, MonitoringLocation.name) + .all() + ) + + # Active SLM unit per location (mirrors portal.active_unit_for_location). + def _active_unit(loc_id: str): + asg = ( + db.query(UnitAssignment) + .filter( + UnitAssignment.location_id == loc_id, + UnitAssignment.status == "active", + UnitAssignment.device_type == "slm", + or_( + UnitAssignment.assigned_until.is_(None), + UnitAssignment.assigned_until > now, + ), + ) + .order_by(UnitAssignment.assigned_at.desc()) + .first() + ) + return asg.unit_id if asg else None + + loc_units = [(loc, _active_unit(loc.id)) for loc in locations] + + slmm_base = os.getenv("SLMM_BASE_URL", "http://localhost:8100") + + async def _fetch(unit_id): + if not unit_id: + return None, "no_device" + try: + async with httpx.AsyncClient(timeout=5.0) as hc: + r = await hc.get(f"{slmm_base}/api/nl43/{unit_id}/status") + except Exception: + return None, "unreachable" + if r.status_code != 200: + return None, "no_data" + return (r.json() or {}).get("data") or {}, None + + results = await asyncio.gather(*[_fetch(u) for (_, u) in loc_units]) + + out = [] + for (loc, unit_id), (data, reason) in zip(loc_units, results): + entry = { + "id": loc.id, + "name": loc.name, + "unit_id": unit_id, + } + if data is None: + entry["reason"] = reason + entry["measurement_state"] = None + else: + entry.update( + { + "measurement_state": data.get("measurement_state"), + "leq": data.get("leq"), + "lp": data.get("lp"), + "lmax": data.get("lmax"), + "last_seen": data.get("last_seen"), + "battery_level": data.get("battery_level"), + "power_source": data.get("power_source"), + "is_reachable": data.get("is_reachable"), + "connection_state": data.get("connection_state"), + } + ) + out.append(entry) + + return {"status": "ok", "locations": out} + + # ============================================================================ # Project Types # ============================================================================ diff --git a/templates/projects/detail.html b/templates/projects/detail.html index b7a35b6..2f214fe 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -85,6 +85,36 @@
+ + +
"']/g, c => ( + {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} +function lsNum(v) { const f = parseFloat(v); return isNaN(f) ? null : f; } +function lsFmtAgo(iso) { + if (!iso) return ''; + const t = new Date(iso.endsWith('Z') ? iso : iso + 'Z'); + const s = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000)); + if (s < 60) return s + 's ago'; + if (s < 3600) return Math.round(s / 60) + 'm ago'; + if (s < 86400) return Math.round(s / 3600) + 'h ago'; + return Math.round(s / 86400) + 'd ago'; +} +// Headline Leq color, matched to the portal thresholds. +function lsLeqColor(leq, measuring) { + if (!measuring || leq == null) return 'text-gray-400 dark:text-gray-500'; + if (leq >= LS_LEVEL_RED) return 'text-red-500'; + if (leq >= LS_LEVEL_AMBER) return 'text-amber-500'; + return 'text-green-500'; +} +// Friendly labels for NL-43 battery / power-source codes (fall back to raw). +function lsBattery(code) { + return ({F:'Full', M:'Mid', L:'Low', D:'Dead', E:'Empty'})[code] || (code || ''); +} +function lsPower(code) { + return ({I:'Battery', E:'External', U:'USB'})[code] || (code || ''); +} + +function lsRenderTile(loc) { + const measuring = loc.measurement_state === 'Start' || loc.measurement_state === 'Measure'; + const wedged = loc.connection_state === 'wedged'; + const reachable = loc.is_reachable !== false; // null/absent → assume ok + const hasData = loc.measurement_state != null || loc.leq != null; + + // Status badge + let badge; + if (!loc.unit_id) { + badge = 'No unit'; + } else if (wedged) { + badge = 'Wedged'; + } else if (!reachable || !hasData) { + badge = 'Offline'; + } else if (measuring) { + badge = 'Live'; + } else { + badge = 'Stopped'; + } + + const leqNum = lsNum(loc.leq); + const leqStr = (loc.leq == null || loc.leq === '') ? '--' : loc.leq; + const leqColor = lsLeqColor(leqNum, measuring); + + // Health line: unit · last-seen · battery/power + const bits = []; + if (loc.unit_id) bits.push(`${lsEsc(loc.unit_id)}`); + if (hasData && loc.last_seen) bits.push(lsEsc(lsFmtAgo(loc.last_seen))); + if (hasData && (loc.battery_level || loc.power_source)) { + const b = lsBattery(loc.battery_level), p = lsPower(loc.power_source); + const low = loc.battery_level === 'L' || loc.battery_level === 'D' || loc.battery_level === 'E'; + bits.push(`${lsEsc([p, b].filter(Boolean).join(' · '))}`); + } + + const levels = (hasData) + ? `
+ Lp ${lsEsc(loc.lp ?? '--')}   Lmax ${lsEsc(loc.lmax ?? '--')} +
` + : ''; + + return ` +
+
+
${lsEsc(loc.name)}
+ ${badge} +
+
+ ${lsEsc(leqStr)} + dB Leq +
+ ${levels} +
+ ${bits.join('·')} +
+
`; +} + +function lsRender(locations) { + const section = document.getElementById('live-stats-section'); + if (!section) return; + if (!locations.length) { section.classList.add('hidden'); return; } + section.classList.remove('hidden'); + + // Rollup + let live = 0, off = 0, peak = null, peakStr = null, peakLoc = null; + for (const l of locations) { + const measuring = l.measurement_state === 'Start' || l.measurement_state === 'Measure'; + const hasData = l.measurement_state != null || l.leq != null; + if (measuring) { + live++; + const n = lsNum(l.leq); + if (n != null && (peak == null || n > peak)) { peak = n; peakStr = l.leq; peakLoc = l.name; } + } else if (!l.unit_id || !hasData || l.is_reachable === false) { + off++; + } + } + document.getElementById('ls-live').textContent = live; + document.getElementById('ls-offline').textContent = off; + const pw = document.getElementById('ls-loudest-wrap'); + if (peak != null) { + pw.classList.remove('hidden'); + document.getElementById('ls-loudest').textContent = peakStr; + document.getElementById('ls-loudest-loc').textContent = peakLoc; + } else { pw.classList.add('hidden'); } + + document.getElementById('ls-tiles').innerHTML = locations.map(lsRenderTile).join(''); +} + +async function loadLiveStats() { + // Skip work while the tab is hidden in the background. + if (document.hidden) return; + try { + const r = await fetch(`/api/projects/${projectId}/live-stats`); + if (!r.ok) return; + const j = await r.json(); + lsRender(j.locations || []); + } catch (e) { /* keep last render */ } +} + +function startLiveStats() { + if (liveStatsTimer) return; // already running + loadLiveStats(); + liveStatsTimer = setInterval(loadLiveStats, 15000); +} + document.addEventListener('DOMContentLoaded', function() { loadProjectDetails(); From bb5b407c982f84504c7800431d52abc800586e34 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 21 Jun 2026 21:30:54 +0000 Subject: [PATCH 18/31] feat: live status chip on NRL list cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a compact live-status chip to each NRL card in the location list, so the inline list next to the map (Overview tab) and the Sound > NRLs tab show live state alongside the new live tiles. Both surfaces share location_list.html, so this lands in both. - Chip shows "● dB" when measuring (tinted green/amber/red at the same 55/70 thresholds as the live tiles), else Stopped / Offline / Wedged. Hidden for cards with no assigned unit and for vibration locations. - Painted by the existing 15s Overview poller (no extra requests). Repaints on htmx:afterSwap so the chips survive the NRL list reloading (e.g. the 30s dashboard swap). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../partials/projects/location_list.html | 6 ++- templates/projects/detail.html | 48 ++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/templates/partials/projects/location_list.html b/templates/partials/projects/location_list.html index d5ccecb..8dfb08e 100644 --- a/templates/partials/projects/location_list.html +++ b/templates/partials/projects/location_list.html @@ -78,8 +78,12 @@
- +
+ + {% if not item.assignment %} diff --git a/templates/projects/detail.html b/templates/projects/detail.html index 2f214fe..ad3a85e 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -2233,6 +2233,43 @@ function lsRender(locations) { document.getElementById('ls-tiles').innerHTML = locations.map(lsRenderTile).join(''); } +// Compact level-tinted pill classes for the inline NRL-card chips. +function lsInlineLevelPill(leq) { + if (leq == null) return 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'; + if (leq >= LS_LEVEL_RED) return 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-300'; + if (leq >= LS_LEVEL_AMBER) return 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300'; + return 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'; +} +function lsInlineChipHtml(loc) { + if (!loc.unit_id) return ''; // no unit assigned → no chip + const base = 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium '; + const measuring = loc.measurement_state === 'Start' || loc.measurement_state === 'Measure'; + const hasData = loc.measurement_state != null || loc.leq != null; + const reachable = loc.is_reachable !== false; + if (loc.connection_state === 'wedged') + return `Wedged`; + if (!reachable || !hasData) + return `Offline`; + if (measuring) { + const leqStr = (loc.leq == null || loc.leq === '') ? '--' : loc.leq; + return `` + + `${lsEsc(leqStr)} dB`; + } + return `Stopped`; +} +// Paint the inline chips on the NRL list cards (Overview + Sound tab). +function lsPaintInline(locations) { + const byId = {}; + for (const l of locations) byId[l.id] = l; + document.querySelectorAll('[data-loc-live]').forEach(el => { + const loc = byId[el.getAttribute('data-loc-live')]; + const html = loc ? lsInlineChipHtml(loc) : ''; + el.innerHTML = html; + el.classList.toggle('hidden', !html); + }); +} + +let lsLastData = []; async function loadLiveStats() { // Skip work while the tab is hidden in the background. if (document.hidden) return; @@ -2240,10 +2277,19 @@ async function loadLiveStats() { const r = await fetch(`/api/projects/${projectId}/live-stats`); if (!r.ok) return; const j = await r.json(); - lsRender(j.locations || []); + lsLastData = j.locations || []; + lsRender(lsLastData); + lsPaintInline(lsLastData); } catch (e) { /* keep last render */ } } +// The NRL list partial reloads via htmx (e.g. the 30s dashboard swap), which +// wipes the painted chips — repaint from the last poll as soon as it settles. +document.body.addEventListener('htmx:afterSwap', (e) => { + const id = e.target && e.target.id; + if (id === 'project-locations' || id === 'sound-locations') lsPaintInline(lsLastData); +}); + function startLiveStats() { if (liveStatsTimer) return; // already running loadLiveStats(); From 5b70dcf0716bd70afdf9fe1d8171c6e61bf6c838 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 21 Jun 2026 22:01:12 +0000 Subject: [PATCH 19/31] feat: make Overview live tiles link to NRL detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each live monitoring tile is now a clickable link to its NRL detail page (/projects/{id}/nrl/{location_id}) — same target as the NRL card name — with a hover border + lift affordance so it reads as clickable. Co-Authored-By: Claude Opus 4.8 (1M context) --- templates/projects/detail.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/templates/projects/detail.html b/templates/projects/detail.html index ad3a85e..340b910 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -2186,7 +2186,8 @@ function lsRenderTile(loc) { : ''; return ` -
+
${lsEsc(loc.name)}
${badge} @@ -2199,7 +2200,7 @@ function lsRenderTile(loc) {
${bits.join('·')}
-
`; +
`; } function lsRender(locations) { From ceb893a54c48673186a5de7302935e89e13d9747 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 16:37:38 +0000 Subject: [PATCH 20/31] feat: add 24-Hour (full-day) session period type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions could only be tagged day or night (weekday/weekend). 24/7 continuous jobs had no fitting period type. Add "24-Hour" (full_24h) — a single full-day period covering day + night. UI (session_list.html): - Full-width "24-Hour" button under the WD/WE x Day/Night grid; teal badge. - Selecting it clears + disables the hour inputs (no window); reopening an existing 24-Hour session opens with hours disabled. Badge current-period kept in sync after save. Backend (projects.py): - full_24h added to VALID_PERIOD_TYPES and the session-label maps ("... - 24-Hour"). Operator-set only; never auto-derived. - Combined report: include ALL rows for a 24-hour session (no day/night window filter) and split them by hour into the three non-overlapping buckets — Daytime 7-18:59, Evening 19-21:59, Nighttime 22:00-06:59. Empty period columns are dropped downstream, so it shows whatever periods have data. Scoped to the combined-report path; the older per-session single report still uses the fixed Evening/Nighttime layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/projects.py | 51 ++++++++++++---- templates/partials/projects/session_list.html | 61 ++++++++++++++++--- 2 files changed, 92 insertions(+), 20 deletions(-) diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 4c94f2b..498c68c 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -2241,7 +2241,10 @@ async def delete_session( }) -VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night"} +# full_24h = a single continuous 24-hour period (day + night). Operator-set +# only; never auto-derived. In reports its rows are split across +# Daytime/Evening/Nighttime by hour rather than filtered to one window. +VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night", "full_24h"} def _derive_period_type(dt: datetime) -> str: @@ -2255,7 +2258,7 @@ def _derive_period_type(dt: datetime) -> str: def _build_session_label(dt: datetime, location_name: str, period_type: str) -> str: day_abbr = dt.strftime("%a") date_str = f"{dt.month}/{dt.day}" - period_str = {"weekday_day": "Day", "weekday_night": "Night", "weekend_day": "Day", "weekend_night": "Night"}.get(period_type, "") + period_str = {"weekday_day": "Day", "weekday_night": "Night", "weekend_day": "Day", "weekend_night": "Night", "full_24h": "24-Hour"}.get(period_type, "") parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p] return " — ".join(parts) @@ -4085,7 +4088,15 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids # Prefer per-session period_start/end_hour; fall back to hardcoded defaults. sh = entry.get("period_start_hour") # e.g. 7 for Day, 19 for Night eh = entry.get("period_end_hour") # e.g. 19 for Day, 7 for Night - if sh is None or eh is None: + + target_date = None + if period_type == 'full_24h': + # 24-hour continuous: keep every row (rows get split across + # Daytime/Evening/Nighttime by hour in the sheet builder). No + # hour-window filtering and no single target date. + is_day_session = False + filtered = [(dt, row) for dt, row in parsed if dt] + elif sh is None or eh is None: # Legacy defaults based on period_type is_day_session = period_type in ('weekday_day', 'weekend_day') sh = 7 if is_day_session else 19 @@ -4093,8 +4104,9 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids else: is_day_session = eh > sh # crosses midnight when end < start - target_date = None - if is_day_session: + if period_type == 'full_24h': + pass # filtered already set above + elif is_day_session: # Day-style: start_h <= hour < end_h, restricted to the LAST calendar date in_window = lambda h: sh <= h < eh if entry.get("report_date"): @@ -4152,7 +4164,8 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids # Rebuild session label using the correct label date if label_dt and entry["loc_name"]: period_str = {"weekday_day": "Day", "weekday_night": "Night", - "weekend_day": "Day", "weekend_night": "Night"}.get(period_type, "") + "weekend_day": "Day", "weekend_night": "Night", + "full_24h": "24-Hour"}.get(period_type, "") day_abbr = label_dt.strftime("%a") date_label = f"{label_dt.month}/{label_dt.day}" session_label = " — ".join(p for p in [loc_name, f"{day_abbr} {date_label}", period_str] if p) @@ -4411,21 +4424,35 @@ async def generate_combined_from_preview( evening_rows_data = [] night_rows_data = [] + def _row_hour(time_v): + if time_v and ':' in str(time_v): + try: + return int(str(time_v).split(':')[0]) + except ValueError: + pass + return 0 + for pt, time_v, lmx, l1, l2 in parsed_rows: if pt in PERIOD_TYPE_IS_DAY: day_rows_data.append((lmx, l1, l2)) elif pt in PERIOD_TYPE_IS_NIGHT: # Split by time: Evening = 19:00–21:59, Nighttime = 22:00–06:59 - hour = 0 - if time_v and ':' in str(time_v): - try: - hour = int(str(time_v).split(':')[0]) - except ValueError: - pass + hour = _row_hour(time_v) if 19 <= hour <= 21: evening_rows_data.append((lmx, l1, l2)) else: night_rows_data.append((lmx, l1, l2)) + elif pt == 'full_24h': + # 24-hour continuous: split each row by hour into the three + # non-overlapping buckets (Daytime 7–18:59, Evening 19–21:59, + # Nighttime 22:00–06:59). Empty buckets are dropped downstream. + hour = _row_hour(time_v) + if 7 <= hour < 19: + day_rows_data.append((lmx, l1, l2)) + elif 19 <= hour <= 21: + evening_rows_data.append((lmx, l1, l2)) + else: + night_rows_data.append((lmx, l1, l2)) else: day_rows_data.append((lmx, l1, l2)) diff --git a/templates/partials/projects/session_list.html b/templates/partials/projects/session_list.html index 2886855..1b44bfc 100644 --- a/templates/partials/projects/session_list.html +++ b/templates/partials/projects/session_list.html @@ -13,12 +13,14 @@ 'weekday_night': 'Weekday Night', 'weekend_day': 'Weekend Day', 'weekend_night': 'Weekend Night', + 'full_24h': '24-Hour', } %} {% set period_colors = { 'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', 'weekday_night': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300', 'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', 'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + 'full_24h': 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-300', } %}
{% endfor %} +
@@ -229,14 +238,17 @@ const PERIOD_COLORS = { weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300', weekend_day: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', weekend_night: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + full_24h: 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-300', }; const PERIOD_LABELS = { weekday_day: 'Weekday Day', weekday_night: 'Weekday Night', weekend_day: 'Weekend Day', weekend_night: 'Weekend Night', + full_24h: '24-Hour', }; -// Default hours for each period type +// Default hours for each period type. full_24h has no window (the report splits +// its rows by hour), so its hour inputs are cleared + disabled in the editor. const PERIOD_DEFAULT_HOURS = { weekday_day: {start: 7, end: 19}, weekday_night: {start: 19, end: 7}, @@ -260,6 +272,20 @@ function openPeriodEditor(sessionId) { if (el.id !== 'period-editor-' + sessionId) el.classList.add('hidden'); }); document.getElementById('period-editor-' + sessionId).classList.toggle('hidden'); + + // Reflect the current period type's hour-input state on open (24-Hour + // has no window, so its hour inputs open disabled). + const cur = document.getElementById('period-badge-' + sessionId)?.dataset?.currentPeriod; + const sh = document.getElementById('period-start-hr-' + sessionId); + const eh = document.getElementById('period-end-hr-' + sessionId); + const disable = cur === 'full_24h'; + [sh, eh].forEach(el => { + if (!el) return; + el.disabled = disable; + el.classList.toggle('opacity-50', disable); + el.classList.toggle('cursor-not-allowed', disable); + if (disable) el.placeholder = 'n/a'; + }); } function closePeriodEditor(sessionId) { @@ -281,13 +307,31 @@ function selectPeriodType(sessionId, pt) { btn.classList.toggle('text-gray-600', !isSelected); btn.classList.toggle('dark:text-gray-400', !isSelected); }); - // Fill default hours - const defaults = PERIOD_DEFAULT_HOURS[pt]; - if (defaults) { - const sh = document.getElementById('period-start-hr-' + sessionId); - const eh = document.getElementById('period-end-hr-' + sessionId); - if (sh && !sh.value) sh.value = defaults.start; - if (eh && !eh.value) eh.value = defaults.end; + // Hour inputs: full_24h has no window — clear + disable them. Other types + // re-enable and fill their default window if empty. + const sh = document.getElementById('period-start-hr-' + sessionId); + const eh = document.getElementById('period-end-hr-' + sessionId); + if (pt === 'full_24h') { + [sh, eh].forEach(el => { + if (!el) return; + el.value = ''; + el.disabled = true; + el.classList.add('opacity-50', 'cursor-not-allowed'); + el.placeholder = 'n/a'; + }); + } else { + const defaults = PERIOD_DEFAULT_HOURS[pt]; + [sh, eh].forEach(el => { + if (!el) return; + el.disabled = false; + el.classList.remove('opacity-50', 'cursor-not-allowed'); + }); + if (sh) sh.placeholder = 'e.g. 19'; + if (eh) eh.placeholder = 'e.g. 7'; + if (defaults) { + if (sh && !sh.value) sh.value = defaults.start; + if (eh && !eh.value) eh.value = defaults.end; + } } } @@ -315,6 +359,7 @@ async function savePeriodEditor(sessionId) { const badge = document.getElementById('period-badge-' + sessionId); const label = document.getElementById('period-label-' + sessionId); const newPt = result.period_type; + badge.dataset.currentPeriod = newPt || ''; ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c)); if (newPt && PERIOD_COLORS[newPt]) { badge.classList.add(...PERIOD_COLORS[newPt].split(' ').filter(Boolean)); From 03f3dca243ac1ae1313564894a40514033f48543 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 16:43:51 +0000 Subject: [PATCH 21/31] fix: empty project dropdown in pending-deployment classify modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The classify modal's _loadProjects() fetched /api/projects/list and called .json() on it, but that endpoint returns HTML project cards (used by the projects overview via htmx). Parsing HTML as JSON threw, the catch swallowed it, and the Project dropdown came up empty — so deployments couldn't be assigned to a project. - Add GET /api/projects/list-json returning assignable projects (id, name, status) as JSON, excluding deleted/archived/completed to match the default /list view. - Point the modal's _loadProjects() at the JSON endpoint. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/projects.py | 20 ++++++++++++++++++++ templates/admin/pending_deployments.html | 5 +++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 498c68c..cab2a34 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -404,6 +404,26 @@ def _build_combined_location_data( # Project List & Overview # ============================================================================ +@router.get("/list-json") +async def get_projects_list_json(db: Session = Depends(get_db)): + """JSON list of assignable projects (id, name, status) for pickers such as + the pending-deployment classify modal. Excludes deleted/archived/completed, + matching the default /list view. (The /list endpoint returns HTML cards, so + JSON consumers must use this one.)""" + projects = ( + db.query(Project) + .filter(Project.status.notin_(["deleted", "archived", "completed"])) + .order_by(Project.name) + .all() + ) + return JSONResponse({ + "projects": [ + {"id": p.id, "name": p.name, "status": p.status} + for p in projects + ] + }) + + @router.get("/list", response_class=HTMLResponse) async def get_projects_list( request: Request, diff --git a/templates/admin/pending_deployments.html b/templates/admin/pending_deployments.html index 2157f52..1d22ffd 100644 --- a/templates/admin/pending_deployments.html +++ b/templates/admin/pending_deployments.html @@ -295,9 +295,10 @@ function closeClassifyModal() { async function _loadProjects() { try { - const r = await fetch('/api/projects/list'); + // Must be the JSON endpoint — /api/projects/list returns HTML cards. + const r = await fetch('/api/projects/list-json'); const data = r.ok ? await r.json() : { projects: [] }; - // Endpoint shape varies; tolerate either { projects: [...] } or a flat array. + // Tolerate either { projects: [...] } or a flat array. _pdState.projectsCache = Array.isArray(data) ? data : (data.projects || []); } catch (e) { _pdState.projectsCache = []; From 85a64c83f8d866f7753ed14f6af04ae9202e5f6b Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 16:55:45 +0000 Subject: [PATCH 22/31] =?UTF-8?q?fix:=20classify=20button=20stuck=20on=20"?= =?UTF-8?q?Classifying=E2=80=A6"=20on=20modal=20reopen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit submitClassify()'s success path closes the modal and reloads the list but never resets the submit button (only the error paths did), and openClassifyModal() reset the form fields but not the button. So after a successful classify, the next modal opened with the button stuck disabled on "Classifying…" — only a full page refresh cleared it. Reset the submit button to "Classify"/enabled in openClassifyModal so every open starts clean regardless of how the previous one ended. Co-Authored-By: Claude Opus 4.8 (1M context) --- templates/admin/pending_deployments.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/templates/admin/pending_deployments.html b/templates/admin/pending_deployments.html index 1d22ffd..04df4fe 100644 --- a/templates/admin/pending_deployments.html +++ b/templates/admin/pending_deployments.html @@ -266,6 +266,12 @@ async function openClassifyModal(pendingId) { document.getElementById('new-project-name').value = ''; document.getElementById('new-location-name').value = ''; + // Reset the submit button — the success path closes the modal without + // clearing it, so without this it stays stuck on "Classifying…" on reopen. + const submitBtn = document.getElementById('classify-submit'); + submitBtn.disabled = false; + submitBtn.textContent = 'Classify'; + // Coords hint for "use captured coords" checkbox. const hint = document.getElementById('captured-coords-hint'); if (pd.coordinates) { From ee6062f9fb5f750def71927abac66d3e651a2ad7 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 18:12:45 +0000 Subject: [PATCH 23/31] feat: Overview live-mode-only NRLs + split locations by type Two Overview improvements for projects that mix vibration + sound: - Live monitoring now includes only live-mode (connected) NRLs. connection_mode lives in the location's metadata JSON (default "connected"); offline/manual NRLs are excluded, and since the section hides when the list is empty, it disappears entirely when no NRL is a live SLM. - The Overview location list is split into separate "Vibration Locations" and "NRLs" sections (driven by enabled modules) instead of one mixed list. Single-module projects still show just their one section. Live-chip repaint listener updated for the per-type list ids. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/projects.py | 13 +++++ .../partials/projects/project_dashboard.html | 48 +++++++++---------- templates/projects/detail.html | 6 ++- 3 files changed, 40 insertions(+), 27 deletions(-) diff --git a/backend/routers/projects.py b/backend/routers/projects.py index cab2a34..e195590 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -1135,6 +1135,7 @@ async def get_project_live_stats(project_id: str, db: Session = Depends(get_db)) import os import asyncio import httpx + import json as _json project = db.query(Project).filter_by(id=project_id).first() if not project: @@ -1152,6 +1153,18 @@ async def get_project_live_stats(project_id: str, db: Session = Depends(get_db)) .all() ) + # Only connected/live-mode NRLs belong in live monitoring. connection_mode + # lives in location_metadata JSON (default "connected"); offline/manual NRLs + # are excluded. With none connected, the caller gets [] and hides the section. + def _is_connected(loc) -> bool: + try: + meta = _json.loads(loc.location_metadata or "{}") + return meta.get("connection_mode", "connected") != "offline" + except Exception: + return True + + locations = [loc for loc in locations if _is_connected(loc)] + # Active SLM unit per location (mirrors portal.active_unit_for_location). def _active_unit(loc_id: str): asg = ( diff --git a/templates/partials/projects/project_dashboard.html b/templates/partials/projects/project_dashboard.html index c4ff551..65e1f14 100644 --- a/templates/partials/projects/project_dashboard.html +++ b/templates/partials/projects/project_dashboard.html @@ -48,34 +48,32 @@
+{# Separate location lists per module type so vibration points and sound NRLs + don't get mixed in one list. Build the section set from the enabled modules. #} +{% set loc_sections = [] %} +{% if 'vibration_monitoring' in modules %}{% set _ = loc_sections.append(('vibration', 'Vibration Locations', 'Add Location')) %}{% endif %} +{% if 'sound_monitoring' in modules %}{% set _ = loc_sections.append(('sound', 'NRLs', 'Add NRL')) %}{% endif %} +{% if not loc_sections %}{% set _ = loc_sections.append(('', 'Locations', 'Add Location')) %}{% endif %} +
-
-
-

- {% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %} - NRLs - {% else %} - Locations - {% endif %} -

- -
-
-
-
-
-
+
+ {% for ltype, title, add_label in loc_sections %} +
+
+

{{ title }}

+ +
+
+
+
+
+
+ {% endfor %}
{# Location map — uses the reusable partial that fetches from diff --git a/templates/projects/detail.html b/templates/projects/detail.html index 340b910..c37f190 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -2287,8 +2287,10 @@ async function loadLiveStats() { // The NRL list partial reloads via htmx (e.g. the 30s dashboard swap), which // wipes the painted chips — repaint from the last poll as soon as it settles. document.body.addEventListener('htmx:afterSwap', (e) => { - const id = e.target && e.target.id; - if (id === 'project-locations' || id === 'sound-locations') lsPaintInline(lsLastData); + const id = (e.target && e.target.id) || ''; + // Overview lists are now per-type (project-locations-sound/-vibration); the + // Sound tab uses sound-locations. Repaint chips whenever any of them swap. + if (id.startsWith('project-locations') || id === 'sound-locations') lsPaintInline(lsLastData); }); function startLiveStats() { From 93f01be4710cd045d6650f780a60bce74b6a5ac6 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 18:12:56 +0000 Subject: [PATCH 24/31] fix: deployment capture coords now reach existing locations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /deploy classify "Assign to existing location" path dropped the captured GPS — only "Create new location" applied it — so units assigned to pre-existing coordless locations left those locations without a pin. - Classify (promote) now backfills the captured GPS onto an existing location that has no coordinates (doesn't clobber operator-set coords). - Add "Reforward info" button on Assigned deployment cards + endpoint POST /pending/{id}/resync-location that re-pushes a capture's GPS onto its assigned location (explicit action, overwrites). Fixes already-classified locations and guards against this recurring. Logged to unit history. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/pending_deployments.py | 50 ++++++++++++++++++++++++ templates/admin/pending_deployments.html | 28 ++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/backend/routers/pending_deployments.py b/backend/routers/pending_deployments.py index 7496d74..608c409 100644 --- a/backend/routers/pending_deployments.py +++ b/backend/routers/pending_deployments.py @@ -296,6 +296,12 @@ async def promote_pending( if not location: raise HTTPException(status_code=404, detail=f"Location {location_id!r} not found.") project_id = location.project_id + # Backfill the captured GPS onto the existing location if it doesn't + # have coordinates yet. (Previously the captured coords were dropped on + # the assign-to-existing path, so only create-new locations got a pin.) + # Don't clobber coordinates an operator already set. + if pd.coordinates and not (location.coordinates or "").strip(): + location.coordinates = pd.coordinates else: # Create-new path. Need a project (existing or new). project_id = payload.get("project_id") @@ -456,6 +462,50 @@ async def cancel_pending( return {"success": True, "cancelled_at": pd.cancelled_at.isoformat()} +@router.post("/pending/{pending_id}/resync-location") +async def resync_location(pending_id: str, db: Session = Depends(get_db)): + """Re-push a promoted capture's GPS onto its assigned location. + + Use when a capture's coordinates didn't land on the location (e.g. it was + assigned to a pre-existing location that had none). Unlike the auto-backfill + on classify, this is an explicit operator action and OVERWRITES the + location's coordinates with the captured GPS. + """ + pd = db.query(PendingDeployment).filter_by(id=pending_id).first() + if not pd: + raise HTTPException(status_code=404, detail="Pending deployment not found.") + if pd.status != "assigned" or not pd.resulting_assignment_id: + raise HTTPException(status_code=400, detail="Only a promoted (assigned) capture can be re-forwarded.") + if not (pd.coordinates or "").strip(): + raise HTTPException(status_code=400, detail="This capture has no GPS coordinates to forward.") + + asg = db.query(UnitAssignment).filter_by(id=pd.resulting_assignment_id).first() + if not asg: + raise HTTPException(status_code=404, detail="Resulting assignment not found.") + location = db.query(MonitoringLocation).filter_by(id=asg.location_id).first() + if not location: + raise HTTPException(status_code=404, detail="Assigned location not found.") + + old = location.coordinates + location.coordinates = pd.coordinates.strip() + + _record_history( + db, unit_id=pd.unit_id, + change_type="deployment_coords_reforwarded", + old_value=old, + new_value=location.coordinates, + notes=f"Re-forwarded capture GPS to location '{location.name}'", + ) + + db.commit() + return { + "success": True, + "location_id": location.id, + "location_name": location.name, + "coordinates": location.coordinates, + } + + # ── Helpers ─────────────────────────────────────────────────────────────────── def _to_dict(pd: PendingDeployment, unit: Optional[RosterUnit] = None, detail: bool = False) -> dict: diff --git a/templates/admin/pending_deployments.html b/templates/admin/pending_deployments.html index 04df4fe..edb1eda 100644 --- a/templates/admin/pending_deployments.html +++ b/templates/admin/pending_deployments.html @@ -207,9 +207,19 @@ function _renderPdCard(pd) {
`; } else if (pd.status === 'assigned') { + const reforward = pd.coordinates + ? `` + : ''; footerActions = `
Promoted ${_fmtDateTime(pd.promoted_at)} → assignment ${_esc((pd.resulting_assignment_id || '').slice(0, 8))}… -
`; +
${reforward}`; } else if (pd.status === 'cancelled') { footerActions = `
Cancelled ${_fmtDateTime(pd.cancelled_at)}${pd.cancelled_reason ? ` — ${_esc(pd.cancelled_reason)}` : ''} @@ -459,6 +469,22 @@ async function cancelPending(pendingId) { } } +// Re-push a promoted capture's GPS coordinates onto its assigned location. +async function reforwardInfo(pendingId) { + try { + const r = await fetch(`/api/deployments/pending/${pendingId}/resync-location`, { + method: 'POST', + }); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(j.detail || 'HTTP ' + r.status); + const msg = `Coordinates synced to "${j.location_name}": ${j.coordinates}`; + if (window.showToast) showToast(msg, 'success'); else alert(msg); + } catch (e) { + const msg = 'Reforward failed: ' + e.message; + if (window.showToast) showToast(msg, 'error'); else alert(msg); + } +} + // Kick off the initial load. loadPdList(); // Refresh awaiting count every 30s for the badge. From c45fcd780438686e348df10bc07c546a37b223f5 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 19:39:31 +0000 Subject: [PATCH 25/31] fix: event date filters unusable (datetime-local, no apply) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit and vibration-location event tabs used datetime-local From/To inputs with onchange. Picking a date but leaving the time blank left the value empty, so nothing applied — and there was no Apply button, so date-only filtering was impossible. Switch From/To to plain date inputs (apply immediately on selection) and send a full inclusive day to the backend (From 00:00:00 → To 23:59:59). Co-Authored-By: Claude Opus 4.8 (1M context) --- templates/unit_detail.html | 9 +++++---- templates/vibration_location_detail.html | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/templates/unit_detail.html b/templates/unit_detail.html index 1402292..b6129ae 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -473,12 +473,12 @@
-
-
@@ -2882,8 +2882,9 @@ async function loadUnitEvents() { const ft = document.getElementById('ue-filter-ft').value; const limit = document.getElementById('ue-filter-limit').value; params.set('bucket', bucket); - if (from) params.set('from_dt', from.replace('T', ' ')); - if (to) params.set('to_dt', to.replace('T', ' ')); + // Date inputs: From = start of that day, To = inclusive end of that day. + if (from) params.set('from_dt', from + ' 00:00:00'); + if (to) params.set('to_dt', to + ' 23:59:59'); if (ft) params.set('false_trigger', ft); params.set('limit', limit); diff --git a/templates/vibration_location_detail.html b/templates/vibration_location_detail.html index a0fefa2..319a226 100644 --- a/templates/vibration_location_detail.html +++ b/templates/vibration_location_detail.html @@ -287,12 +287,12 @@
-
-
@@ -501,8 +501,9 @@ async function loadLocationEvents() { const to = document.getElementById('ev-filter-to').value; const ft = document.getElementById('ev-filter-ft').value; const limit = document.getElementById('ev-filter-limit').value; - if (from) params.set('from_dt', from.replace('T', ' ')); - if (to) params.set('to_dt', to.replace('T', ' ')); + // Date inputs: From = start of that day, To = inclusive end of that day. + if (from) params.set('from_dt', from + ' 00:00:00'); + if (to) params.set('to_dt', to + ' 23:59:59'); if (ft) params.set('false_trigger', ft); params.set('limit', limit); From 5dc0aa4064ea9c940a4578f64255d831c138ec66 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 19:48:55 +0000 Subject: [PATCH 26/31] feat: Vibration Events sub-tab + last-event on location cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions to the project Vibration tab: - Events sub-tab (next to Locations): a project-wide events table across all vibration locations. New GET /api/projects/{id}/vibration-events fans events_for_location across the project's vibration locations, tags each event with its location, and merges newest-first (From/To date filters, Real/FT filter, limit). Table columns Timestamp/Location/Serial/Tran/Vert/Long/PVS/ Mic/Flags; rows open the shared event-detail modal (Chart.js + event-modal.js come from the modal partial). Lazy-loads on first open; refreshes on sfm-event-review-saved. - Last event per location card: thread last_event (already in events_for_location stats) through the locations endpoint and show "Last event: …" on vibration cards. Reuses the same event source + modal as the per-location Events tab. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/project_locations.py | 72 ++++++++- .../partials/projects/location_list.html | 3 + templates/projects/detail.html | 152 +++++++++++++++++- 3 files changed, 225 insertions(+), 2 deletions(-) diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 1074a5a..376b202 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -160,6 +160,7 @@ async def get_project_locations( # (sessions don't really exist for the watcher-forward pipeline). # Sound locations skip this and keep showing session counts. event_counts: dict[str, int] = {} + last_events: dict[str, str] = {} vibration_locations = [l for l in locations if l.location_type == "vibration"] if vibration_locations: import asyncio @@ -171,7 +172,10 @@ async def get_project_locations( for loc, res in zip(vibration_locations, results): if isinstance(res, Exception): continue # leave event_counts[loc.id] unset → template falls back - event_counts[loc.id] = (res.get("stats") or {}).get("event_count", 0) or 0 + stats = res.get("stats") or {} + event_counts[loc.id] = stats.get("event_count", 0) or 0 + if stats.get("last_event"): + last_events[loc.id] = stats["last_event"] # Enrich with assignment info, splitting active vs removed. active_data: list = [] @@ -205,6 +209,8 @@ async def get_project_locations( } if location.id in event_counts: item["event_count"] = event_counts[location.id] + if location.id in last_events: + item["last_event"] = last_events[location.id] if location.removed_at is None: active_data.append(item) else: @@ -1579,6 +1585,70 @@ async def get_project_vibration_summary( ) +@router.get("/vibration-events", response_class=JSONResponse) +async def get_project_vibration_events( + project_id: str, + from_dt: Optional[datetime] = Query(None), + to_dt: Optional[datetime] = Query(None), + false_trigger: Optional[bool] = Query(None), + limit: int = Query(500, ge=1, le=5000), + db: Session = Depends(get_db), +): + """Project-wide SFM events across every active vibration location. + + Fans out events_for_location per location (each of which unions that + location's assignment windows), tags each event with its location, then + merges newest-first. Powers the Vibration tab's Events sub-tab. + """ + project = db.query(Project).filter_by(id=project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found.") + + locations = ( + db.query(MonitoringLocation) + .filter( + MonitoringLocation.project_id == project_id, + MonitoringLocation.location_type == "vibration", + MonitoringLocation.removed_at.is_(None), + ) + .all() + ) + if not locations: + return {"events": [], "count": 0, "location_count": 0} + + import asyncio + from backend.services.sfm_events import events_for_location + + results = await asyncio.gather( + *( + events_for_location( + db, loc.id, from_dt=from_dt, to_dt=to_dt, + false_trigger=false_trigger, limit=limit, + ) + for loc in locations + ), + return_exceptions=True, + ) + + merged = [] + for loc, res in zip(locations, results): + if isinstance(res, Exception): + continue + for ev in res.get("events", []): + ev = dict(ev) + ev["location_id"] = loc.id + ev["location_name"] = loc.name + merged.append(ev) + + merged.sort(key=lambda e: e.get("timestamp") or "", reverse=True) + total = len(merged) + return { + "events": merged[:limit], + "count": total, + "location_count": len(locations), + } + + @router.get("/locations/{location_id}/events", response_class=JSONResponse) async def get_location_events( project_id: str, diff --git a/templates/partials/projects/location_list.html b/templates/partials/projects/location_list.html index 8dfb08e..d44921c 100644 --- a/templates/partials/projects/location_list.html +++ b/templates/partials/projects/location_list.html @@ -74,6 +74,9 @@ {% else %} No active assignment {% endif %} + {% if item.last_event %} + Last event: {{ item.last_event[:16] }} + {% endif %}
diff --git a/templates/projects/detail.html b/templates/projects/detail.html index c37f190..7093a51 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -137,7 +137,10 @@ class="vib-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-seismo-orange text-seismo-orange -mb-px transition-colors whitespace-nowrap"> Locations - +
@@ -183,6 +186,50 @@
+ + + @@ -985,6 +1032,98 @@ function switchVibSubTab(name) { btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400'); btn.classList.add('border-seismo-orange', 'text-seismo-orange'); } + // Lazy-load the Events table on first open. + if (name === 'events' && !_projectEventsLoaded) { + _projectEventsLoaded = true; + loadProjectVibrationEvents(); + } +} + +// ── Vibration Events sub-tab ───────────────────────────────────────────── +let _projectEventsLoaded = false; + +function clearProjectEventFilters() { + document.getElementById('pve-from').value = ''; + document.getElementById('pve-to').value = ''; + document.getElementById('pve-ft').value = ''; + loadProjectVibrationEvents(); +} + +function _pveFmtPPV(v) { return (v === null || v === undefined) ? '—' : Number(v).toFixed(4); } +function _pvePPVClass(v) { + if (v == null) return 'text-gray-400'; + if (v >= 0.5) return 'text-red-500'; + if (v >= 0.2) return 'text-amber-500'; + return 'text-green-600 dark:text-green-400'; +} + +async function loadProjectVibrationEvents() { + const container = document.getElementById('pve-container'); + if (!container) return; + container.innerHTML = '
Loading events…
'; + + const params = new URLSearchParams(); + const from = document.getElementById('pve-from').value; + const to = document.getElementById('pve-to').value; + const ft = document.getElementById('pve-ft').value; + const limit = document.getElementById('pve-limit').value; + if (from) params.set('from_dt', from + ' 00:00:00'); + if (to) params.set('to_dt', to + ' 23:59:59'); + if (ft) params.set('false_trigger', ft); + params.set('limit', limit); + + try { + const r = await fetch(`/api/projects/${projectId}/vibration-events?${params.toString()}`); + if (!r.ok) throw new Error('HTTP ' + r.status); + const d = await r.json(); + _renderProjectEvents(d.events || [], d.count || 0, container); + } catch (e) { + container.innerHTML = `
Failed to load events: ${lsEsc(e.message)}
`; + } +} + +function _renderProjectEvents(events, total, container) { + if (!events.length) { + container.innerHTML = '
No events for the current filter.
'; + return; + } + const rows = events.map(ev => { + const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—'; + const mic = ev.mic_ppv != null ? Number(ev.mic_ppv).toFixed(3) : '—'; + const ft = ev.false_trigger + ? 'FT' : ''; + return ` + ${ts} + ${lsEsc(ev.location_name || '—')} + + ${lsEsc(ev.serial)} + + ${_pveFmtPPV(ev.tran_ppv)} + ${_pveFmtPPV(ev.vert_ppv)} + ${_pveFmtPPV(ev.long_ppv)} + ${_pveFmtPPV(ev.peak_vector_sum)} + ${mic} + ${ft} + `; + }).join(''); + container.innerHTML = ` +
Showing ${events.length} of ${total.toLocaleString()} events
+ + + + + + + + + + + + + + + ${rows} +
TimestampLocationSerialTranVertLongPVSMicFlags
`; } function switchSoundSubTab(name) { @@ -2413,4 +2552,15 @@ async function regeneratePassword() { } catch (e) { paToast('Could not generate a password.'); } } + + +{% include 'partials/event_detail_modal.html' %} + + {% endblock %} From 092b72f63c0ab17b07252c06e399f574d184a933 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 20:24:27 +0000 Subject: [PATCH 27/31] feat(projects): per-module status (independent sound/vibration lifecycle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each ProjectModule now carries its own status (active|on_hold|completed) so one half of a combined project can wrap up while the other keeps running — e.g. mark Sound "completed" while Vibration stays "active", without archiving the whole project. - models.py: ProjectModule.status column (default 'active') - migrate_add_module_status.py: idempotent ALTER (run on prod before deploy) - projects.py: _get_module_statuses() helper, MODULE_STATUSES, and a PUT /{id}/modules/{type}/status endpoint; module_status now included in the project GET, header, and /list contexts so the UI can render it. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015m9FuJvk65kJmmP3c9c6r1 --- backend/migrate_add_module_status.py | 54 ++++++++++++++++++++++++++++ backend/models.py | 4 +++ backend/routers/projects.py | 44 +++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 backend/migrate_add_module_status.py diff --git a/backend/migrate_add_module_status.py b/backend/migrate_add_module_status.py new file mode 100644 index 0000000..61685c7 --- /dev/null +++ b/backend/migrate_add_module_status.py @@ -0,0 +1,54 @@ +""" +Migration: add a per-module `status` column to `project_modules`. + +A combined project (sound + vibration) often finishes one kind of work before +the other. Rather than archiving the whole project, each module now carries its +own lifecycle so e.g. the sound side can read "Completed" while vibration stays +"Active". + +Behavior: + - status = 'active' → module is live (default for all existing rows) + - status = 'on_hold' → paused; data/tabs stay visible + - status = 'completed' → wrapped up; surfaced as a done badge + +Idempotent — safe to re-run. Non-destructive — adds only. + +Run with: + docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_module_status.py +""" + +import os +import sqlite3 + +DB_PATH = "./data/seismo_fleet.db" + + +def _has_column(cur: sqlite3.Cursor, table: str, column: str) -> bool: + cur.execute(f"PRAGMA table_info({table})") + return any(row[1] == column for row in cur.fetchall()) + + +def migrate_database() -> None: + if not os.path.exists(DB_PATH): + print(f"Database not found at {DB_PATH}") + return + + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + + if not _has_column(cur, "project_modules", "status"): + cur.execute( + "ALTER TABLE project_modules ADD COLUMN status TEXT NOT NULL DEFAULT 'active'" + ) + conn.commit() + print(" Added column project_modules.status (default 'active').") + else: + print(" project_modules already has status — nothing to do.") + + conn.close() + + +if __name__ == "__main__": + print("Running migration: add status to project_modules") + migrate_database() + print("Done.") diff --git a/backend/models.py b/backend/models.py index 888ca24..4852de7 100644 --- a/backend/models.py +++ b/backend/models.py @@ -225,6 +225,10 @@ class ProjectModule(Base): project_id = Column(String, nullable=False, index=True) # FK to projects.id module_type = Column(String, nullable=False) # sound_monitoring | vibration_monitoring | ... enabled = Column(Boolean, default=True, nullable=False) + # Per-module lifecycle, independent of the parent project's status. Lets one + # part of a combined project wrap up (e.g. sound "completed") while another + # keeps running ("active"). Values: active | on_hold | completed. + status = Column(String, default="active", nullable=False) created_at = Column(DateTime, default=datetime.utcnow) __table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),) diff --git a/backend/routers/projects.py b/backend/routers/projects.py index e195590..b564c7c 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -58,12 +58,25 @@ MODULES = { } +MODULE_STATUSES = {"active", "on_hold", "completed"} + + def _get_project_modules(project_id: str, db: Session) -> list[str]: """Return list of enabled module_type strings for a project.""" rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all() return [r.module_type for r in rows] +def _get_module_statuses(project_id: str, db: Session) -> dict[str, str]: + """Return {module_type: status} for a project's enabled modules. + + Per-module lifecycle is independent of the parent project's status — lets + one half of a combined project be "completed" while the other stays "active". + """ + rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all() + return {r.module_type: (r.status or "active") for r in rows} + + def _require_module(project: Project, module_type: str, db: Session) -> None: """Raise 400 if the project does not have the given module enabled.""" if not project: @@ -482,6 +495,8 @@ async def get_projects_list( projects_data.append({ "project": project, "project_type": project_type, + "modules": _get_project_modules(project.id, db), + "module_status": _get_module_statuses(project.id, db), "location_count": location_count, "unit_count": unit_count, "active_session_count": active_session_count, @@ -838,6 +853,7 @@ async def get_project(project_id: str, db: Session = Depends(get_db)): "project_type_id": project.project_type_id, "project_type_name": project_type.name if project_type else None, "modules": modules, + "module_status": _get_module_statuses(project.id, db), "status": project.status, "client_name": project.client_name, "site_address": project.site_address, @@ -902,6 +918,33 @@ async def remove_project_module(project_id: str, module_type: str, db: Session = return {"ok": True, "modules": _get_project_modules(project_id, db)} +@router.put("/{project_id}/modules/{module_type}/status") +async def set_project_module_status( + project_id: str, module_type: str, request: Request, db: Session = Depends(get_db) +): + """Set a module's lifecycle status. Body: {status: active|on_hold|completed}. + + Independent of the parent project's status — used to wrap up one half of a + combined project (e.g. sound "completed") while the other stays "active". + """ + data = await request.json() + status = (data.get("status") or "").strip() + if status not in MODULE_STATUSES: + raise HTTPException( + status_code=400, + detail=f"Invalid status '{status}'. Expected one of: {', '.join(sorted(MODULE_STATUSES))}.", + ) + row = db.query(ProjectModule).filter_by( + project_id=project_id, module_type=module_type, enabled=True + ).first() + if not row: + raise HTTPException(status_code=404, detail="Module not enabled on this project.") + row.status = status + db.commit() + return {"ok": True, "module_type": module_type, "status": status, + "module_status": _get_module_statuses(project_id, db)} + + @router.put("/{project_id}") async def update_project( project_id: str, @@ -1255,6 +1298,7 @@ async def get_project_header( "project": project, "project_type": project_type, "modules": _get_project_modules(project_id, db), + "module_status": _get_module_statuses(project_id, db), }) From f54c62b3326d176e9a4cd478e31f8374089de181 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 20:24:40 +0000 Subject: [PATCH 28/31] =?UTF-8?q?feat(projects):=20projects=20page=20overh?= =?UTF-8?q?aul=20=E2=80=94=20events,=20header,=20module=20toolbars,=20card?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a batch of projects-page UX issues: 1. Vibration Events sub-tab: add a Location filter + clickable column sorting (Timestamp/Location/Serial/Tran/Vert/Long/PVS/Mic). Events are cached client-side so location-filter and sort are instant (no SFM refetch). 3. Drop the misleading single-module "Sound Monitoring" subtitle on the Overview card (combined projects have multiple modules); show the project number · client identity instead. 4. Header cleanup: move the sound-only actions (Generate Combined Report, Night Report, Report Settings) and the Manual/Remote chip out of the global project header and into the Sound tab's module toolbar. The header now carries project-level concerns only (status, modules, merge). The Night Report / Report Settings modals stay defined in the header partial (global), so the relocated buttons still call them. 2. Per-module status UI: each module tab gets a status dropdown (active/on_hold/completed) wired to the new endpoint; the header module chips show a "✓ Done" / "On hold" badge. 5. Project cards redesigned: module mix accent strip, Sound/Vibration chips with per-module status, project number · client identity, and per-module "Sound"/"Vibration" quick-open buttons that deep-link into that module's tab (#sound / #vibration). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015m9FuJvk65kJmmP3c9c6r1 --- .../partials/projects/project_dashboard.html | 15 +- .../partials/projects/project_header.html | 49 +---- templates/partials/projects/project_list.html | 156 ++++++++------ templates/projects/detail.html | 202 +++++++++++++++++- 4 files changed, 293 insertions(+), 129 deletions(-) diff --git a/templates/partials/projects/project_dashboard.html b/templates/partials/projects/project_dashboard.html index 65e1f14..c97a03d 100644 --- a/templates/partials/projects/project_dashboard.html +++ b/templates/partials/projects/project_dashboard.html @@ -3,13 +3,14 @@

{{ project.name }}

-

- {% if project_type %} - {{ project_type.name }} - {% else %} - Project - {% endif %} -

+ {# Identity line — project number / client, not a module name. The + enabled modules are already shown as chips in the page header. #} + {% set _idbits = [] %} + {% if project.project_number %}{% set _ = _idbits.append(project.project_number) %}{% endif %} + {% if project.client_name %}{% set _ = _idbits.append(project.client_name) %}{% endif %} + {% if _idbits %} +

{{ _idbits | join(' · ') }}

+ {% endif %}
{% if project.status == 'upcoming' %} Upcoming diff --git a/templates/partials/projects/project_header.html b/templates/partials/projects/project_header.html index 9ccffb2..a4f5e9b 100644 --- a/templates/partials/projects/project_header.html +++ b/templates/partials/projects/project_header.html @@ -37,6 +37,12 @@ Vibration Monitoring {% else %}{{ m }}{% endif %} + {% set mstatus = (module_status or {}).get(m, 'active') %} + {% if mstatus == 'completed' %} + ✓ Done + {% elif mstatus == 'on_hold' %} + On hold + {% endif %} @@ -47,50 +53,11 @@ Add Module
- {% if project.data_collection_mode == 'remote' %} - - - - - Remote - - {% else %} - - - - - Manual - - {% endif %} - +
- {% if 'sound_monitoring' in modules %} - - - - - Generate Combined Report - - - - {% endif %}
+
+ + +
+ + + + +
+ +
+ + + + + Generate Combined Report + + + +
+ +
`; } } -function _renderProjectEvents(events, total, container) { +// Rebuild the Location dropdown from whatever locations actually have events in +// the current fetch, preserving the operator's current selection if still valid. +function _pvePopulateLocations() { + const sel = document.getElementById('pve-loc'); + if (!sel) return; + const prev = sel.value; + const seen = new Map(); + _pveAllEvents.forEach(ev => { + if (ev.location_id && !seen.has(ev.location_id)) seen.set(ev.location_id, ev.location_name || ev.location_id); + }); + const opts = ['']; + [...seen.entries()] + .sort((a, b) => String(a[1]).localeCompare(String(b[1]))) + .forEach(([id, name]) => opts.push(``)); + sel.innerHTML = opts.join(''); + if (prev && seen.has(prev)) sel.value = prev; +} + +function _pveSortBy(key) { + if (_pveSort.key === key) { + _pveSort.dir = (_pveSort.dir === 'asc') ? 'desc' : 'asc'; + } else { + _pveSort.key = key; + _pveSort.dir = 'desc'; // numbers + dates most useful high→low first + } + _pveApplyAndRender(); +} + +const _PVE_NUM_KEYS = new Set(['tran_ppv', 'vert_ppv', 'long_ppv', 'peak_vector_sum', 'mic_ppv']); +const _PVE_STR_KEYS = new Set(['location_name', 'serial']); + +function _pveApplyAndRender() { + const container = document.getElementById('pve-container'); + if (!container) return; + + const locId = document.getElementById('pve-loc')?.value || ''; + let rows = locId ? _pveAllEvents.filter(ev => ev.location_id === locId) : _pveAllEvents.slice(); + + const { key, dir } = _pveSort; + const mul = dir === 'asc' ? 1 : -1; + rows.sort((a, b) => { + if (_PVE_NUM_KEYS.has(key)) { + const av = (a[key] == null) ? -Infinity : Number(a[key]); + const bv = (b[key] == null) ? -Infinity : Number(b[key]); + return (av - bv) * mul; + } + if (_PVE_STR_KEYS.has(key)) { + return String(a[key] || '').toLowerCase().localeCompare(String(b[key] || '').toLowerCase()) * mul; + } + // timestamp — ISO strings sort lexicographically + return String(a.timestamp || '').localeCompare(String(b.timestamp || '')) * mul; + }); + + _renderProjectEvents(rows, container, locId); +} + +function _pveTh(label, key, align) { + const active = _pveSort.key === key; + const arrow = active ? (_pveSort.dir === 'asc' ? '▲' : '▼') : ''; + const alignCls = align === 'right' ? 'text-right' : 'text-left'; + return ` + ${label}${arrow}`; +} + +function _renderProjectEvents(events, container, locId) { if (!events.length) { container.innerHTML = '
No events for the current filter.
'; return; @@ -1106,19 +1240,22 @@ function _renderProjectEvents(events, total, container) { ${ft} `; }).join(''); + const scope = locId + ? `Showing ${events.length.toLocaleString()} event${events.length === 1 ? '' : 's'} at this location` + : `Showing ${events.length.toLocaleString()} of ${_pveTotal.toLocaleString()} events`; container.innerHTML = ` -
Showing ${events.length} of ${total.toLocaleString()} events
+
${scope}
- - - - - - - - + ${_pveTh('Timestamp', 'timestamp')} + ${_pveTh('Location', 'location_name')} + ${_pveTh('Serial', 'serial')} + ${_pveTh('Tran', 'tran_ppv')} + ${_pveTh('Vert', 'vert_ppv')} + ${_pveTh('Long', 'long_ppv')} + ${_pveTh('PVS', 'peak_vector_sum')} + ${_pveTh('Mic', 'mic_ppv')} @@ -1140,6 +1277,41 @@ function switchSoundSubTab(name) { } } +// ── Per-module status (active / on_hold / completed) ───────────────────── +// Each module has its own lifecycle independent of the parent project, so the +// sound side can be "completed" while vibration keeps running. +const _MODULE_STATUS_LABEL = { active: 'Active', on_hold: 'On hold', completed: 'Completed' }; +async function setModuleStatus(moduleType, status, selectEl) { + const prev = selectEl ? selectEl.getAttribute('data-prev') : null; + try { + const r = await fetch(`/api/projects/${projectId}/modules/${moduleType}/status`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }) + }); + if (!r.ok) throw new Error('HTTP ' + r.status); + if (selectEl) selectEl.setAttribute('data-prev', status); + // Refresh the header so the module chip's status badge updates. + if (window.htmx) htmx.ajax('GET', `/api/projects/${projectId}/header`, { target: '#project-header', swap: 'innerHTML' }); + const name = (moduleType === 'sound_monitoring') ? 'Sound' : 'Vibration'; + if (window.showToast) showToast(`${name} module marked ${_MODULE_STATUS_LABEL[status] || status}.`, 'success'); + } catch (e) { + if (selectEl && prev) selectEl.value = prev; // revert the dropdown on failure + if (window.showToast) showToast('Could not update module status.', 'error'); + else alert('Could not update module status.'); + } +} + +function _renderSoundModeChip(mode) { + const chip = document.getElementById('sound-mode-chip'); + if (!chip) return; + const remote = mode === 'remote'; + chip.classList.remove('hidden'); + chip.className = 'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ' + + (remote ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300' + : 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'); + chip.textContent = remote ? 'Remote — data via FTP' : 'Manual — SD card upload'; +} + // Load project details async function loadProjectDetails() { try { @@ -1169,6 +1341,14 @@ async function loadProjectDetails() { if (modeRadio) modeRadio.checked = true; settingsUpdateModeStyles(); + // Per-module status selects + the (sound-scoped) data-collection chip. + const ms = data.module_status || {}; + const ssel = document.getElementById('sound-module-status'); + if (ssel) { ssel.value = ms.sound_monitoring || 'active'; ssel.setAttribute('data-prev', ssel.value); } + const vsel = document.getElementById('vibration-module-status'); + if (vsel) { vsel.value = ms.vibration_monitoring || 'active'; vsel.setAttribute('data-prev', vsel.value); } + _renderSoundModeChip(mode); + // Show/hide module tabs based on active modules const hasSoundModule = projectModules.includes('sound_monitoring'); const hasVibrationModule = projectModules.includes('vibration_monitoring'); From 80464c6f11da0c8604f867831c48d32044decdaa Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 20:38:48 +0000 Subject: [PATCH 29/31] feat(projects): per-module stat breakdown on project cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single Locations/Units/Active row was confusing: "Active" collided with the green Active status badge and actually meant sound recording sessions, so vibration-only projects showed a meaningless "Active 0", and combined projects lumped both modules together with no split. Cards now show one stat line per module, each carrying its own identity + status badge (so the separate chip row is dropped as redundant): Vibration N locations · M units Sound N NRLs · M units · K recording - /list endpoint computes module_stats: locations (active, by type) and units counted via a join on the assigned location's type — so a module's unit count always reconciles with its location count (verified: sound+vibration units == total active assignments for every project). - "recording" (active sessions) shows only under Sound, where it's meaningful. - Projects with no modules fall back to a simple Locations/Units row. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015m9FuJvk65kJmmP3c9c6r1 --- backend/routers/projects.py | 43 +++++++++++- templates/partials/projects/project_list.html | 69 +++++++++++-------- 2 files changed, 80 insertions(+), 32 deletions(-) diff --git a/backend/routers/projects.py b/backend/routers/projects.py index b564c7c..554f7d5 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -470,8 +470,10 @@ async def get_projects_list( for project in projects: # Get project type project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first() + mods = _get_project_modules(project.id, db) - # Count locations + # Count locations (project-wide, includes removed — kept for back-compat + # with the compact list view). location_count = db.query(func.count(MonitoringLocation.id)).filter_by( project_id=project.id ).scalar() @@ -484,7 +486,7 @@ async def get_projects_list( ) ).scalar() - # Count active sessions + # Count active (recording) sessions — a sound-monitoring concept active_session_count = db.query(func.count(MonitoringSession.id)).filter( and_( MonitoringSession.project_id == project.id, @@ -492,11 +494,46 @@ async def get_projects_list( ) ).scalar() + # Per-module stats — each module shows only its own, relevant counts. + # Locations: active only (removed_at IS NULL). Units: active assignments + # counted by the TYPE OF LOCATION they sit on (join), so a module's unit + # count always lines up with its own location count — independent of the + # assignment's denormalized device_type. + def _module_loc_count(loc_type): + return db.query(func.count(MonitoringLocation.id)).filter( + MonitoringLocation.project_id == project.id, + MonitoringLocation.location_type == loc_type, + MonitoringLocation.removed_at.is_(None), + ).scalar() + + def _module_unit_count(loc_type): + return db.query(func.count(UnitAssignment.id)).join( + MonitoringLocation, MonitoringLocation.id == UnitAssignment.location_id + ).filter( + UnitAssignment.project_id == project.id, + UnitAssignment.assigned_until.is_(None), + MonitoringLocation.location_type == loc_type, + ).scalar() + + module_stats = {} + if "vibration_monitoring" in mods: + module_stats["vibration"] = { + "locations": _module_loc_count("vibration"), + "units": _module_unit_count("vibration"), + } + if "sound_monitoring" in mods: + module_stats["sound"] = { + "locations": _module_loc_count("sound"), + "units": _module_unit_count("sound"), + "recording": active_session_count, + } + projects_data.append({ "project": project, "project_type": project_type, - "modules": _get_project_modules(project.id, db), + "modules": mods, "module_status": _get_module_statuses(project.id, db), + "module_stats": module_stats, "location_count": location_count, "unit_count": unit_count, "active_session_count": active_session_count, diff --git a/templates/partials/projects/project_list.html b/templates/partials/projects/project_list.html index 071efaa..e2f6e46 100644 --- a/templates/partials/projects/project_list.html +++ b/templates/partials/projects/project_list.html @@ -39,36 +39,50 @@ {% endif %} - - {% if mods %} -
- {% for m in mods %} - {% set st = mstatus.get(m, 'active') %} - - {% if m == 'sound_monitoring' %} - - Sound - {% elif m == 'vibration_monitoring' %} - - Vibration - {% else %}{{ m }}{% endif %} - {% if st == 'completed' %} - {% elif st == 'on_hold' %}{% endif %} - - {% endfor %} -
- {% endif %} - {% if p.description %}

{{ p.description }}

{% endif %} - -
+ + {% set ms = item.module_stats %} + {% if ms %} +
+ {% if 'vibration' in ms %} + {% set vst = mstatus.get('vibration_monitoring', 'active') %} +
+ + + Vibration + {% if vst == 'completed' %}{% elif vst == 'on_hold' %}{% endif %} + + + {{ ms.vibration.locations }} location{{ '' if ms.vibration.locations == 1 else 's' }} + · {{ ms.vibration.units }} unit{{ '' if ms.vibration.units == 1 else 's' }} + +
+ {% endif %} + {% if 'sound' in ms %} + {% set sst = mstatus.get('sound_monitoring', 'active') %} +
+ + + Sound + {% if sst == 'completed' %}{% elif sst == 'on_hold' %}{% endif %} + + + {{ ms.sound.locations }} NRL{{ '' if ms.sound.locations == 1 else 's' }} + · {{ ms.sound.units }} unit{{ '' if ms.sound.units == 1 else 's' }} + · {% if ms.sound.recording > 0 %}{{ ms.sound.recording }} recording{% else %}0 recording{% endif %} + +
+ {% endif %} +
+ {% else %} + +

Locations

{{ item.location_count }}

@@ -77,11 +91,8 @@

Units

{{ item.unit_count }}

-
-

Active

-

{{ item.active_session_count }}

-
+ {% endif %} From 2f5f6a2fce270c95ec2ee1bb5797cc257a99f975 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 23:04:42 +0000 Subject: [PATCH 30/31] chore: add redeploy script --- redeploy.sh | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100755 redeploy.sh diff --git a/redeploy.sh b/redeploy.sh new file mode 100755 index 0000000..100e310 --- /dev/null +++ b/redeploy.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# +# Rebuild + redeploy a SINGLE compose service without touching the rest of the +# stack. The whole-stack rebuild you keep hitting happens because `web-app` +# depends_on slmm + sfm, so `compose up --build web-app` rebuilds the entire +# dependency tree. `--no-deps` is the fix: build + recreate ONLY this service. +# +# Usage: +# ./redeploy.sh # rebuild + redeploy web-app (prod-style, :8001) +# ./redeploy.sh terra-view # the dev container (:1001, bind-mounted source) +# ./redeploy.sh sfm # any single service +# +# Tip: the dev `terra-view` service bind-mounts the source, so for plain +# code/template edits you usually only need: docker compose restart terra-view +# Rebuild it only when requirements.txt or the Dockerfile changed. + +set -euo pipefail +cd "$(dirname "$0")" + +SVC="${1:-web-app}" + +if ! docker compose config --services | grep -qx "$SVC"; then + echo "✗ '$SVC' is not a service in this compose project." + echo " Available: $(docker compose config --services | paste -sd' ')" + exit 1 +fi + +echo "▶ Rebuilding '$SVC' (dependencies untouched)…" +docker compose build "$SVC" + +echo "▶ Recreating ONLY '$SVC'…" +docker compose up -d --no-deps "$SVC" + +echo +echo "✓ '$SVC' redeployed. Current state:" +docker compose ps "$SVC" From ff4b7f3d866720fcb280e821ee256583bcf887e9 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 23 Jun 2026 01:02:48 +0000 Subject: [PATCH 31/31] =?UTF-8?q?chore(release):=200.16.0=20=E2=80=94=20mo?= =?UTF-8?q?dular=20projects=20&=20live=20Overview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version bump 0.15.0 → 0.16.0 across main.py VERSION, sw.js CACHE_VERSION (evicts stale PWA caches), README (header + highlights + version section), ROADMAP stamp, and the CHANGELOG 0.16.0 entry. Covers everything since 0.15.0: per-module status (independent sound/vibration lifecycle, new project_modules.status column + migration), live monitoring on the internal project Overview, browsable vibration events (Events sub-tab + location filter + sortable columns), 24-Hour session period type, redesigned project cards + per-module quick-open, the module-folder header restructure, and five fixes (SLM start false-error, classify-modal dropdown + stuck button, deployment GPS on existing locations, event date filters). Deploy: run backend/migrate_add_module_status.py on prod; ships with SLMM v0.4.0. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015m9FuJvk65kJmmP3c9c6r1 --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ README.md | 20 +++++++++++++++++--- backend/main.py | 2 +- backend/static/sw.js | 2 +- docs/ROADMAP.md | 2 +- 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa43ec5..032576b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.16.0] - 2026-06-23 + +**Modular projects & live Overview** — the project page becomes a module-aware workspace. Sound and vibration are now first-class *modules* with independent lifecycles (finish the sound study while vibration keeps running), the internal Overview gains the live-monitoring treatment the client portal already had, and vibration events become browsable in-app. Rounds out a batch of project-page UX work plus deployment-hopper and filter fixes carried since 0.15.0. + +### Added + +- **Per-module status (independent lifecycle).** Each project module (Sound / Vibration) now carries its own status — `active` / `on_hold` / `completed` — separate from the parent project. Mark the sound side "Completed" while vibration stays "Active" instead of archiving the whole project. Set from a status control in each module's tab; surfaced as a badge on the header module chips and on the project cards. New `PUT /api/projects/{id}/modules/{type}/status`; new `project_modules.status` column. +- **Live monitoring on the internal project Overview.** The internal project page gets the live treatment the client portal already had: a live-monitoring section with per-NRL tiles (current level + status), live/offline counts, a "loudest now" readout, and auto-refresh — reading SLMM's shared cached feed, no extra device hits. Live status chips on the NRL list cards; tiles are clickable through to the NRL detail page. Shown only for projects with live-mode (connected) sound NRLs. +- **Vibration events, browsable in-app.** A project-wide **Events** sub-tab on the Vibration tab lists SFM events across every vibration location, with a **Location** filter and **sortable columns** (timestamp, location, serial, Tran/Vert/Long/PVS/Mic). Each Vibration location card shows its **last event**. Rows open the shared event-detail modal. +- **24-Hour session period type.** A full-day (day + night) period type for 24/7 jobs, alongside the existing weekday/weekend day/night types; combined reports bucket a 24-Hour session's intervals into Daytime / Evening / Nighttime. +- **Per-module project cards + quick-open.** Redesigned project cards: a module-mix accent strip, per-module stat lines (e.g. "Vibration · 4 locations · 2 units" / "Sound · 2 NRLs · 2 units · 0 recording"), and **Sound / Vibration quick-open buttons** that deep-link straight into that module's tab. +- **"Reforward info" button** on classified deployment-hopper cards — re-syncs the captured photo's GPS/metadata onto the location (recovery path for the coords-drop bug fixed below). +- **`redeploy.sh`** helper — rebuild + redeploy a single compose service with `--no-deps` instead of rebuilding the whole stack. + +### Changed + +- **Project page restructured around modules.** Sound-only actions (Generate Combined Report, Night Report, Report Settings) and the Manual/Remote chip moved out of the global project header into the **Sound tab's** toolbar; the header now carries project-level concerns only (status, modules, merge). The Overview's locations are split into **Vibration locations** and **NRLs** instead of one mixed list. +- **Project cards: clearer stats.** The ambiguous single Locations / Units / **Active** row (where "Active" collided with the status badge and only counted sound recording sessions) is replaced by per-module stat lines; the misleading single-module subtitle ("Sound Monitoring") is replaced by the project's identity (number · client). Unit counts are derived by the assigned location's type, so each module's unit count reconciles with its location count. +- **Overview live-monitoring scope.** The live section lists only connected / live-mode NRLs; offline / manual-upload NRLs are excluded, and the section is hidden entirely when no NRL is in a live mode. + +### Fixed + +- **SLM "Start measurement" showed a false "Unknown error"** even though the unit was actually starting — the proxy timed out on a slow handler and surfaced an empty error. (Pairs with the SLMM-side `'Start'`-state fix in SLMM v0.4.0.) +- **Empty project dropdown** in the pending-deployment classify modal — the projects endpoint returned HTML while the modal expected JSON; now backed by a JSON endpoint. +- **Classify button stuck on "Classifying…"** after reopening the modal post-deploy. +- **Deployment-capture GPS dropped** when assigning to an *existing* location — the captured coordinates now backfill onto a coordless existing location (and the new Reforward button re-syncs after the fact). +- **Event date filters were unusable** on the unit / vibration-location detail pages (datetime-local inputs with no apply) — replaced with date inputs that apply on change. + +### Upgrade Notes + +- **Migration:** run `backend/migrate_add_module_status.py` once against the production DB (adds `project_modules.status`, default `active`) — e.g. `docker compose exec web-app python3 backend/migrate_add_module_status.py`. Without it, the per-module status endpoints/UI error. +- **Ships with SLMM v0.4.0.** The Overview live monitoring reads SLMM's shared `/monitor` fan-out feed, and the SLM start fix pairs with SLMM's `'Start'`-state recognition — deploy the matching SLMM v0.4.0 build, not one without the other. + ## [0.15.0] - 2026-06-18 **Operator authentication** — the internal app gets a login. The operator-facing surface had **zero auth**; this adds a deny-by-default login gate and roles, the prerequisite that makes the app safe to put behind a public URL (office-deployment sequencing: auth → expose). Built test-first (10 tasks, 90 passing tests — the project's first auth test suite alongside the portal's). diff --git a/README.md b/README.md index f06d19d..b9daddd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Terra-View v0.14.0 +# Terra-View v0.16.0 Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs, sound level meters, and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. ## Features @@ -504,6 +504,16 @@ docker compose down -v ## Release Highlights +### v0.16.0 — 2026-06-23 +- **Per-Module Status**: Sound and Vibration are independent modules with their own lifecycle (`active` / `on_hold` / `completed`) — mark the sound study "Completed" while vibration keeps running, instead of archiving the whole project. Shown as badges on the header chips and project cards. (Migration: `backend/migrate_add_module_status.py`.) +- **Live Monitoring on the Internal Overview**: The project Overview gains per-NRL live tiles (current level + status), live/offline counts, a "loudest now" readout, and live status chips on the NRL cards — reading SLMM's shared cached feed (no extra device hits). Tiles deep-link to the NRL detail page. +- **Browsable Vibration Events**: A project-wide Events sub-tab on the Vibration tab with a Location filter and sortable columns (timestamp / location / serial / Tran / Vert / Long / PVS / Mic); each Vibration location card shows its last event. +- **24-Hour Session Period Type**: Full-day (day + night) period for 24/7 jobs; combined reports bucket a 24-Hour session's intervals into Daytime / Evening / Nighttime. +- **Redesigned Project Cards**: Module-mix accent strip, per-module stat lines (replacing the ambiguous Locations / Units / Active row), and Sound / Vibration quick-open buttons that jump straight into a module's tab. +- **Project page restructured around modules**: sound-only actions (Combined Report, Night Report, Report Settings) moved from the global header into the Sound tab; Overview locations split into Vibration locations and NRLs. +- **Fixes**: false "Unknown error" on SLM start, empty project dropdown + stuck button in the deployment classify modal, deployment GPS dropped when assigning to an existing location (+ Reforward button), unusable event date filters. +- **Ships with SLMM v0.4.0** (shared `/monitor` fan-out feed + `'Start'`-state fix). + ### v0.11.0 — 2026-05-15 - **Soft-Remove Monitoring Locations**: Mark a location as no longer actively monitored without destroying history. Closes active unit assignments and cancels pending scheduled actions; historical events stay attributed. Restore brings it back. Surfaces as a Removed Locations collapsed section on the project page. - **Per-Unit Deployment Gantt**: Visual timeline above the deployment history list on each unit detail page. Color-coded bars per location, today marker, mergeable-group dashed underlines, click a bar to scroll its detail row into view. @@ -633,9 +643,13 @@ MIT ## Version -**Current: 0.11.0** — Soft-remove locations, per-unit Gantt, merge/delete assignments, drag-to-reorder, three-dot kebab menu, event count on vibration cards, project location map, stricter backfill fuzzy match, modal/typeahead bug fixes (2026-05-15) +**Current: 0.16.0** — Modular projects & live Overview: per-module status (independent sound/vibration lifecycle), internal live-monitoring Overview, browsable vibration events, 24-Hour period type, redesigned project cards (2026-06-23) -Previous: 0.10.0 — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14) +Previous: 0.15.0 — Operator authentication: deny-by-default login gate + superadmin/admin roles, 30-day session cookie, `/admin/users` (2026-06-18) + +0.11.0 — Soft-remove locations, per-unit Gantt, merge/delete assignments, drag-to-reorder, three-dot kebab menu, event count on vibration cards, project location map, stricter backfill fuzzy match, modal/typeahead bug fixes (2026-05-15) + +0.10.0 — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14) 0.9.4 — Modular project types, deleted project management, swap modal search, roster auto-refresh fix (2026-04-06) diff --git a/backend/main.py b/backend/main.py index 31e7db3..7fa893c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine) ENVIRONMENT = os.getenv("ENVIRONMENT", "production") # Initialize FastAPI app -VERSION = "0.15.0" +VERSION = "0.16.0" if ENVIRONMENT == "development": _build = os.getenv("BUILD_NUMBER", "0") if _build and _build != "0": diff --git a/backend/static/sw.js b/backend/static/sw.js index 30f8279..4d736d7 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -8,7 +8,7 @@ // PWA users actually receive the new bundles instead of being stuck on // the pre-bump version. Convention: keep it in sync with the Terra-View // version string in backend/main.py. -const CACHE_VERSION = 'v0.15.0'; +const CACHE_VERSION = 'v0.16.0'; const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`; const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`; const DATA_CACHE = `sfm-data-${CACHE_VERSION}`; diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9315126..a4f7377 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -4,7 +4,7 @@ Living document — captures known deferred work, in-flight initiatives, and lon Bump items up/down or strike them through as priorities shift. Source of truth for "what's next" should be this file plus the `## Current Development Focus` block in `CLAUDE.md`. -Last updated: 2026-06-18 (Terra-View v0.15.0) +Last updated: 2026-06-23 (Terra-View v0.16.0) ---
TimestampLocationSerialTranVertLongPVSMicFlags