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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aa9db9..032576b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,59 @@ 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). + +### 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. 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/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/backend/main.py b/backend/main.py index e6b5a17..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.14.0" +VERSION = "0.16.0" if ENVIRONMENT == "development": _build = os.getenv("BUILD_NUMBER", "0") if _build and _build != "0": @@ -89,6 +89,18 @@ 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) + +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/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 8de63e8..4852de7 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" @@ -218,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"),) @@ -772,3 +783,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_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/backend/operator_auth.py b/backend/operator_auth.py new file mode 100644 index 0000000..1069003 --- /dev/null +++ b/backend/operator_auth.py @@ -0,0 +1,231 @@ +# 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 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. +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} + +# 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.""" + return _ROLE_RANK.get(role, 0) >= _ROLE_RANK[minimum] + + +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 + + +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 + + +# 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) + + # 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) + + 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/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/backend/routers/operator_users.py b/backend/routers/operator_users.py new file mode 100644 index 0000000..6d58b9f --- /dev/null +++ b/backend/routers/operator_users.py @@ -0,0 +1,115 @@ +"""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, +) +import backend.operator_auth as operator_auth +from backend.utils.timezone import format_local_datetime + + +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") + + +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/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/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/backend/routers/projects.py b/backend/routers/projects.py index b1407de..554f7d5 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: @@ -404,6 +417,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, @@ -437,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() @@ -451,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, @@ -459,9 +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": 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, @@ -818,6 +890,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, @@ -882,6 +955,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, @@ -1103,6 +1203,113 @@ 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 + import json as _json + + 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() + ) + + # 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 = ( + 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 # ============================================================================ @@ -1128,6 +1335,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), }) @@ -2147,7 +2355,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: @@ -2161,7 +2372,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) @@ -3991,7 +4202,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 @@ -3999,8 +4218,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"): @@ -4058,7 +4278,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) @@ -4317,21 +4538,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/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)): diff --git a/backend/static/sw.js b/backend/static/sw.js index f321980..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.13.2'; +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/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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b3adc16..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-05 (Terra-View v0.13.3) +Last updated: 2026-06-23 (Terra-View v0.16.0) --- 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. 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. 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" diff --git a/templates/admin/pending_deployments.html b/templates/admin/pending_deployments.html index 2157f52..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)}` : ''} @@ -266,6 +276,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) { @@ -295,9 +311,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 = []; @@ -452,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. 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/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/templates/partials/projects/location_list.html b/templates/partials/projects/location_list.html index d5ccecb..d44921c 100644 --- a/templates/partials/projects/location_list.html +++ b/templates/partials/projects/location_list.html @@ -74,12 +74,19 @@ {% else %} No active assignment {% endif %} + {% if item.last_event %} + Last event: {{ item.last_event[:16] }} + {% endif %}
- +
+ + {% if not item.assignment %} diff --git a/templates/partials/projects/project_dashboard.html b/templates/partials/projects/project_dashboard.html index c4ff551..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 @@ -48,34 +49,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/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 %} {% 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)); diff --git a/templates/projects/detail.html b/templates/projects/detail.html index b7a35b6..bce0b0c 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -85,6 +85,36 @@
+ + +
+ + +