diff --git a/server/app/auth.py b/server/app/auth.py
index ba41789..71c2723 100644
--- a/server/app/auth.py
+++ b/server/app/auth.py
@@ -1,17 +1,24 @@
-"""Authentication dependencies.
+"""Authentication: password hashing, user sessions + CSRF, the legacy
+shared-token gate, and device resolution.
-Phase A scope: browser routes keep the legacy shared-token gate
-(MANAGEMENT_TOKEN env var -- empty means open on a trusted LAN, exactly
-the old behavior), and device routes move to require_device, which
-already implements the full multi-frame resolution: per-frame device
-tokens, self-registration by device id, the legacy-token migration
-window, and pending-claim attachment. User sessions arrive in Phase B.
+Three independent credential classes:
+- User sessions (cookie "session", server-side sessions table, per-
+ session CSRF token required on mutating requests) -- humans.
+- The legacy shared MANAGEMENT_TOKEN (env-only). Still accepted on
+ browser routes so the deployed frame's on-panel manage QR (which
+ embeds ?token=) keeps working until Phase C replaces it with the
+ limited /m/ page; CSRF doesn't apply to it (it's explicit per-request
+ credential, not an ambient cookie a cross-site request could ride).
+- Device credentials (?id= + ?token=, see require_device below).
"""
from __future__ import annotations
+import hashlib
+import hmac
import logging
import os
+import secrets
import time
from fastapi import Depends, HTTPException, Request
@@ -20,11 +27,133 @@ from sqlalchemy.orm import Session
from .db import get_db
from .migration import new_device_token, new_manage_token
-from .models import Frame, PendingClaim, UserFrame
+from .models import Frame, PendingClaim, User, UserFrame, UserSession
logger = logging.getLogger(__name__)
MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
+SESSION_COOKIE = "session"
+SESSION_LIFETIME_S = 30 * 86400
+SESSION_REFRESH_BELOW_S = 15 * 86400 # rolling expiry: extend when under this much left
+
+# stdlib scrypt instead of a passlib/argon2 dependency: zero new deps,
+# and the parameters are baked into each stored hash so they can be
+# raised later without invalidating existing ones.
+_SCRYPT_N = 16384
+_SCRYPT_R = 8
+_SCRYPT_P = 1
+
+
+def hash_password(password: str) -> str:
+ salt = os.urandom(16)
+ digest = hashlib.scrypt(
+ password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P
+ )
+ return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${digest.hex()}"
+
+
+def verify_password(password: str, stored: str) -> bool:
+ try:
+ scheme, n, r, p, salt_hex, hash_hex = stored.split("$")
+ if scheme != "scrypt":
+ return False
+ digest = hashlib.scrypt(
+ password.encode(), salt=bytes.fromhex(salt_hex), n=int(n), r=int(r), p=int(p)
+ )
+ return hmac.compare_digest(digest.hex(), hash_hex)
+ except (ValueError, AttributeError):
+ return False
+
+
+def _hash_session_token(value: str) -> str:
+ return hashlib.sha256(value.encode()).hexdigest()
+
+
+def create_session(db: Session, user: User) -> tuple[str, UserSession]:
+ """Returns (cookie_value, session row). Only the sha256 of the cookie
+ value is stored, so a leaked database doesn't yield usable cookies."""
+ cookie_value = secrets.token_urlsafe(32)
+ now = time.time()
+ session = UserSession(
+ token_hash=_hash_session_token(cookie_value),
+ user_id=user.id,
+ csrf_token=secrets.token_urlsafe(32),
+ created_at=now,
+ expires_at=now + SESSION_LIFETIME_S,
+ )
+ db.add(session)
+ # Opportunistic prune -- no background scheduler in this project.
+ for stale in db.scalars(select(UserSession).where(UserSession.expires_at < now)):
+ db.delete(stale)
+ db.commit()
+ return cookie_value, session
+
+
+def destroy_session(db: Session, request: Request) -> None:
+ cookie_value = request.cookies.get(SESSION_COOKIE)
+ if not cookie_value:
+ return
+ session = db.scalars(
+ select(UserSession).where(UserSession.token_hash == _hash_session_token(cookie_value))
+ ).first()
+ if session is not None:
+ db.delete(session)
+ db.commit()
+
+
+def current_session(request: Request, db: Session) -> UserSession | None:
+ cookie_value = request.cookies.get(SESSION_COOKIE)
+ if not cookie_value:
+ return None
+ session = db.scalars(
+ select(UserSession).where(UserSession.token_hash == _hash_session_token(cookie_value))
+ ).first()
+ now = time.time()
+ if session is None or session.expires_at < now:
+ return None
+ if session.expires_at - now < SESSION_REFRESH_BELOW_S:
+ session.expires_at = now + SESSION_LIFETIME_S
+ db.commit()
+ return session
+
+
+def current_user(request: Request, db: Session) -> User | None:
+ session = current_session(request, db)
+ if session is None:
+ return None
+ return db.get(User, session.user_id)
+
+
+def users_exist(db: Session) -> bool:
+ return db.scalars(select(User).limit(1)).first() is not None
+
+
+def _csrf_ok(request: Request, session: UserSession) -> bool:
+ supplied = request.headers.get("X-CSRF-Token") or ""
+ return hmac.compare_digest(supplied, session.csrf_token)
+
+
+def require_user_api(request: Request, db: Session = Depends(get_db)) -> User:
+ """JSON-API dependency: a logged-in user, with CSRF enforced on
+ mutating methods (the session rides an ambient cookie; the CSRF
+ header is what proves the request came from our own JS, not a
+ cross-site form)."""
+ session = current_session(request, db)
+ if session is None:
+ raise HTTPException(401, "Not logged in")
+ if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
+ raise HTTPException(403, "Missing or invalid CSRF token")
+ user = db.get(User, session.user_id)
+ if user is None:
+ raise HTTPException(401, "Not logged in")
+ return user
+
+
+def require_admin_api(request: Request, db: Session = Depends(get_db)) -> User:
+ user = require_user_api(request, db)
+ if not user.is_admin:
+ raise HTTPException(403, "Admin only")
+ return user
def management_token() -> str:
@@ -34,25 +163,38 @@ def management_token() -> str:
def browser_token_valid(request: Request) -> bool:
- """No MANAGEMENT_TOKEN configured means the web UI stays open on a
- trusted LAN, matching this project's original default. Once one's
- set, a request is authorized by either a ?token= query param or the
- cookie index() sets after a valid query-param hit (so the web UI's
- own fetch()/
calls, which carry no query string, stay authorized
- for the rest of that browsing visit)."""
+ """The legacy shared-token check. No MANAGEMENT_TOKEN configured means
+ token-holders don't exist -- but unlike Phase A this no longer means
+ "open": once users exist, sessions are the primary gate and this is
+ only the compatibility path for the deployed frame's manage QR
+ (?token=) until Phase C. Empty token => not valid (sessions rule)."""
token = management_token()
if not token:
- return True
+ return False
supplied = request.query_params.get("token") or request.cookies.get(MANAGEMENT_TOKEN_COOKIE)
return supplied is not None and supplied == token
-def require_access_token(request: Request) -> None:
- """Dependency for the browser-facing /api/* routes (Phase A only --
- replaced by real sessions in Phase B). index() handles the
- unauthorized case itself with a friendlier HTML prompt."""
- if not browser_token_valid(request):
- raise HTTPException(401, "Missing or invalid access token")
+def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None:
+ """Dependency for the web UI's /api/* routes: a real user session
+ (CSRF-checked on mutations, returns the User), or the legacy shared
+ token (returns None -- token bearers act as an anonymous operator,
+ exactly the pre-user model). While NO users exist yet (fresh install
+ or freshly migrated, before /setup has been run) the API stays open
+ if no MANAGEMENT_TOKEN is set -- the Phase A/legacy behavior --
+ since there's nobody to log in as yet."""
+ session = current_session(request, db)
+ if session is not None:
+ if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
+ raise HTTPException(403, "Missing or invalid CSRF token")
+ user = db.get(User, session.user_id)
+ if user is not None:
+ return user
+ if browser_token_valid(request):
+ return None
+ if not users_exist(db) and not management_token():
+ return None
+ raise HTTPException(401, "Not logged in")
def _register_frame(db: Session, device_id: str) -> Frame:
diff --git a/server/app/main.py b/server/app/main.py
index 6ab6edd..8c26016 100644
--- a/server/app/main.py
+++ b/server/app/main.py
@@ -3,22 +3,30 @@ for the panel, and serves ESP32 frames ready-to-display images.
This module is assembly only -- routes live in app/routers/ (device.py
for the firmware-facing /frame/* protocol, api.py for the web UI's
-/api/*), storage in SQLite via models.py/db.py, with migration.py
-importing a pre-database config.json deployment on first boot."""
+/api/*, pages.py for setup/login/settings/admin), storage in SQLite via
+models.py/db.py, with migration.py importing a pre-database config.json
+deployment on first boot."""
from __future__ import annotations
import logging
from fastapi import FastAPI, Request
-from fastapi.responses import HTMLResponse
+from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from . import migration
-from .auth import MANAGEMENT_TOKEN_COOKIE, browser_token_valid, management_token
+from .auth import (
+ MANAGEMENT_TOKEN_COOKIE,
+ browser_token_valid,
+ current_session,
+ current_user,
+ management_token,
+ users_exist,
+)
from .db import SessionLocal
from .quiet_hours import ALL_TIMEZONES
-from .routers import api, device
+from .routers import api, device, pages
from .routers.common import default_frame, immich_creds
logger = logging.getLogger(__name__)
@@ -31,6 +39,7 @@ templates = Jinja2Templates(directory="app/templates")
app.include_router(device.router)
app.include_router(api.router)
+app.include_router(pages.router)
@app.get("/health")
@@ -40,17 +49,28 @@ def health() -> dict:
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
- """The web UI (Phase A: still the single-frame page, bound to the
- default frame). Handles the unauthorized case itself with a friendly
- token prompt rather than a bare 401, since this is the one route a
- human lands on with no token yet."""
- if not browser_token_valid(request):
- supplied = request.query_params.get("token")
- return templates.TemplateResponse(
- "token_prompt.html", {"request": request, "wrong": supplied is not None}
- )
-
+ """The web UI (still the single-frame page until Phase D). Access:
+ a user session (normal path once /setup has run), the legacy shared
+ token (the deployed frame's manage QR embeds ?token= -- kept working
+ until Phase C replaces it with the limited manage page), or -- only
+ while no users exist AND no token is configured -- fully open, the
+ original trusted-LAN default."""
with SessionLocal() as db:
+ user = current_user(request, db)
+ session = current_session(request, db) if user else None
+ legacy_ok = browser_token_valid(request)
+
+ if user is None and not legacy_ok:
+ if not users_exist(db):
+ if management_token():
+ supplied = request.query_params.get("token")
+ return templates.TemplateResponse(
+ "token_prompt.html", {"request": request, "wrong": supplied is not None}
+ )
+ # Fresh install, nothing configured: open, but nudge setup.
+ return RedirectResponse("/setup", status_code=303)
+ return RedirectResponse("/login", status_code=303)
+
frame = default_frame(db)
immich_url, _ = immich_creds(frame)
response = templates.TemplateResponse(
@@ -60,6 +80,8 @@ def index(request: Request):
"cfg": frame,
"immich_url": immich_url,
"timezones": ALL_TIMEZONES,
+ "user": user,
+ "csrf_token": session.csrf_token if session else None,
},
)
diff --git a/server/app/routers/api.py b/server/app/routers/api.py
index aa3b5df..902afc3 100644
--- a/server/app/routers/api.py
+++ b/server/app/routers/api.py
@@ -16,7 +16,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import gitea_releases, photo_queue, quiet_hours
-from ..auth import require_access_token
+from ..auth import require_browser
from ..db import frame_locked, get_db
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame
@@ -32,7 +32,7 @@ from .common import (
logger = logging.getLogger(__name__)
-router = APIRouter(dependencies=[Depends(require_access_token)])
+router = APIRouter(dependencies=[Depends(require_browser)])
MIN_REFRESH_INTERVAL_S = 60
MAX_REFRESH_INTERVAL_S = 86400
diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py
new file mode 100644
index 0000000..d5ea7d3
--- /dev/null
+++ b/server/app/routers/pages.py
@@ -0,0 +1,395 @@
+"""HTML page routes: first-run setup, login/logout, user settings, and
+the admin panel. The frame pages themselves stay in main.py (Phase A's
+single-frame index) until the Phase D restructure.
+
+All POSTs here are plain HTML forms, so CSRF rides a hidden form field
+(checked explicitly) rather than the X-CSRF-Token header the JSON API
+uses."""
+
+from __future__ import annotations
+
+import hmac
+import logging
+import time
+
+from fastapi import APIRouter, Depends, Form, HTTPException, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
+from fastapi.templating import Jinja2Templates
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from ..auth import (
+ SESSION_COOKIE,
+ SESSION_LIFETIME_S,
+ create_session,
+ current_session,
+ current_user,
+ destroy_session,
+ hash_password,
+ users_exist,
+ verify_password,
+)
+from ..db import get_db
+from ..models import Frame, User, UserFrame
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+templates = Jinja2Templates(directory="app/templates")
+
+USERNAME_MAX_LEN = 64
+PASSWORD_MIN_LEN = 8
+
+
+def _set_session_cookie(response, cookie_value: str) -> None:
+ # No Secure flag: the server itself is plain HTTP by design (TLS is a
+ # reverse proxy's job, see README) and a LAN deployment without HTTPS
+ # must still be able to log in.
+ response.set_cookie(
+ SESSION_COOKIE,
+ cookie_value,
+ max_age=SESSION_LIFETIME_S,
+ httponly=True,
+ samesite="lax",
+ )
+
+
+def _check_form_csrf(request: Request, db: Session, csrf_token: str) -> None:
+ session = current_session(request, db)
+ if session is None or not hmac.compare_digest(csrf_token, session.csrf_token):
+ raise HTTPException(403, "Missing or invalid CSRF token")
+
+
+def _normalize_username(username: str) -> str:
+ return username.strip().lower()
+
+
+def _validate_credentials(username: str, password: str) -> str:
+ username = _normalize_username(username)
+ if not username or len(username) > USERNAME_MAX_LEN:
+ raise HTTPException(400, "Invalid username")
+ if len(password) < PASSWORD_MIN_LEN:
+ raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LEN} characters")
+ return username
+
+
+@router.get("/setup", response_class=HTMLResponse)
+def setup_page(request: Request, db: Session = Depends(get_db)):
+ if users_exist(db):
+ return RedirectResponse("/login", status_code=303)
+ return templates.TemplateResponse("setup.html", {"request": request, "error": None})
+
+
+@router.post("/setup")
+def setup_submit(
+ request: Request,
+ username: str = Form(...),
+ display_name: str = Form(""),
+ password: str = Form(...),
+ db: Session = Depends(get_db),
+):
+ """Creates admin #1 -- only ever available while no users exist, so it
+ needs no CSRF/session (there is nothing to ride). Links every
+ existing frame (i.e. the migrated frame #1) to the new admin, makes
+ them its owner + controller, and inherits the migrated Immich creds
+ onto their account (that's how env/config.json creds become per-user
+ state)."""
+ if users_exist(db):
+ raise HTTPException(403, "Setup has already been completed")
+ username = _validate_credentials(username, password)
+
+ admin = User(
+ username=username,
+ display_name=display_name.strip() or username,
+ password_hash=hash_password(password),
+ is_admin=True,
+ created_at=time.time(),
+ )
+ db.add(admin)
+ db.flush()
+
+ for frame in db.scalars(select(Frame)):
+ db.add(UserFrame(user_id=admin.id, frame_id=frame.id))
+ if frame.owner_user_id is None:
+ frame.owner_user_id = admin.id
+ frame.claimed_at = time.time()
+ if frame.controlled_by_user_id is None:
+ frame.controlled_by_user_id = admin.id
+ if not admin.immich_url and frame.immich_url and frame.immich_api_key:
+ admin.immich_url = frame.immich_url
+ admin.immich_api_key = frame.immich_api_key
+
+ db.commit()
+ logger.info("First-run setup: created admin '%s' and linked %s", username,
+ ", ".join(f"frame #{f.id}" for f in db.scalars(select(Frame))) or "no frames")
+
+ cookie_value, _ = create_session(db, admin)
+ response = RedirectResponse("/", status_code=303)
+ _set_session_cookie(response, cookie_value)
+ return response
+
+
+@router.get("/login", response_class=HTMLResponse)
+def login_page(request: Request, db: Session = Depends(get_db)):
+ if not users_exist(db):
+ return RedirectResponse("/setup", status_code=303)
+ if current_user(request, db) is not None:
+ return RedirectResponse("/", status_code=303)
+ return templates.TemplateResponse("login.html", {"request": request, "error": None})
+
+
+@router.post("/login")
+def login_submit(
+ request: Request,
+ username: str = Form(...),
+ password: str = Form(...),
+ db: Session = Depends(get_db),
+):
+ user = db.scalars(
+ select(User).where(User.username == _normalize_username(username))
+ ).first()
+ if user is None or not user.password_hash or not verify_password(password, user.password_hash):
+ return templates.TemplateResponse(
+ "login.html",
+ {"request": request, "error": "Wrong username or password."},
+ status_code=401,
+ )
+ cookie_value, _ = create_session(db, user)
+ response = RedirectResponse("/", status_code=303)
+ _set_session_cookie(response, cookie_value)
+ return response
+
+
+@router.post("/logout")
+def logout(request: Request, csrf_token: str = Form(""), db: Session = Depends(get_db)):
+ _check_form_csrf(request, db, csrf_token)
+ destroy_session(db, request)
+ response = RedirectResponse("/login", status_code=303)
+ response.delete_cookie(SESSION_COOKIE)
+ return response
+
+
+@router.get("/settings", response_class=HTMLResponse)
+def settings_page(request: Request, db: Session = Depends(get_db)):
+ user = current_user(request, db)
+ if user is None:
+ return RedirectResponse("/login", status_code=303)
+ session = current_session(request, db)
+ return templates.TemplateResponse(
+ "settings.html",
+ {"request": request, "user": user, "csrf_token": session.csrf_token, "saved": False, "error": None},
+ )
+
+
+@router.post("/settings", response_class=HTMLResponse)
+def settings_submit(
+ request: Request,
+ csrf_token: str = Form(""),
+ display_name: str = Form(""),
+ immich_url: str = Form(""),
+ immich_api_key: str = Form(""),
+ current_password: str = Form(""),
+ new_password: str = Form(""),
+ db: Session = Depends(get_db),
+):
+ user = current_user(request, db)
+ if user is None:
+ return RedirectResponse("/login", status_code=303)
+ _check_form_csrf(request, db, csrf_token)
+ session = current_session(request, db)
+
+ error = None
+ user.display_name = display_name.strip() or user.username
+ user.immich_url = immich_url.strip()
+ # Blank API key field = keep the existing one (it's never echoed back
+ # into the form -- a secret that round-trips through HTML is a secret
+ # in every browser's autofill store).
+ if immich_api_key.strip():
+ user.immich_api_key = immich_api_key.strip()
+
+ if new_password:
+ if not user.password_hash or not verify_password(current_password, user.password_hash):
+ error = "Current password is wrong -- password not changed."
+ elif len(new_password) < PASSWORD_MIN_LEN:
+ error = f"New password must be at least {PASSWORD_MIN_LEN} characters."
+ else:
+ user.password_hash = hash_password(new_password)
+
+ db.commit()
+ return templates.TemplateResponse(
+ "settings.html",
+ {"request": request, "user": user, "csrf_token": session.csrf_token,
+ "saved": error is None, "error": error},
+ )
+
+
+def _require_admin_page(request: Request, db: Session) -> User:
+ user = current_user(request, db)
+ if user is None or not user.is_admin:
+ raise HTTPException(403, "Admin only")
+ return user
+
+
+def _render_admin(request: Request, db: Session, admin: User, notice: str | None = None,
+ error: str | None = None) -> HTMLResponse:
+ session = current_session(request, db)
+ users = list(db.scalars(select(User).order_by(User.id)))
+ frames = list(db.scalars(select(Frame).order_by(Frame.id)))
+ links = list(db.scalars(select(UserFrame)))
+ links_by_frame: dict[int, list[User]] = {}
+ users_by_id = {u.id: u for u in users}
+ for link in links:
+ links_by_frame.setdefault(link.frame_id, []).append(users_by_id[link.user_id])
+ return templates.TemplateResponse(
+ "admin.html",
+ {
+ "request": request,
+ "user": admin,
+ "csrf_token": session.csrf_token,
+ "users": users,
+ "frames": frames,
+ "links_by_frame": links_by_frame,
+ "notice": notice,
+ "error": error,
+ },
+ )
+
+
+@router.get("/admin", response_class=HTMLResponse)
+def admin_page(request: Request, db: Session = Depends(get_db)):
+ user = current_user(request, db)
+ if user is None:
+ return RedirectResponse("/login", status_code=303)
+ if not user.is_admin:
+ raise HTTPException(403, "Admin only")
+ return _render_admin(request, db, user)
+
+
+@router.post("/admin/users", response_class=HTMLResponse)
+def admin_create_user(
+ request: Request,
+ csrf_token: str = Form(""),
+ username: str = Form(...),
+ password: str = Form(...),
+ is_admin: bool = Form(False),
+ db: Session = Depends(get_db),
+):
+ admin = _require_admin_page(request, db)
+ _check_form_csrf(request, db, csrf_token)
+ username = _validate_credentials(username, password)
+ if db.scalars(select(User).where(User.username == username)).first() is not None:
+ return _render_admin(request, db, admin, error=f"Username '{username}' already exists.")
+ db.add(User(
+ username=username,
+ display_name=username,
+ password_hash=hash_password(password),
+ is_admin=is_admin,
+ created_at=time.time(),
+ ))
+ db.commit()
+ return _render_admin(request, db, admin, notice=f"User '{username}' created.")
+
+
+@router.post("/admin/users/{user_id}/reset-password", response_class=HTMLResponse)
+def admin_reset_password(
+ user_id: int,
+ request: Request,
+ csrf_token: str = Form(""),
+ password: str = Form(...),
+ db: Session = Depends(get_db),
+):
+ admin = _require_admin_page(request, db)
+ _check_form_csrf(request, db, csrf_token)
+ target = db.get(User, user_id)
+ if target is None:
+ return _render_admin(request, db, admin, error="No such user.")
+ if len(password) < PASSWORD_MIN_LEN:
+ return _render_admin(request, db, admin,
+ error=f"Password must be at least {PASSWORD_MIN_LEN} characters.")
+ target.password_hash = hash_password(password)
+ db.commit()
+ return _render_admin(request, db, admin, notice=f"Password reset for '{target.username}'.")
+
+
+@router.post("/admin/users/{user_id}/delete", response_class=HTMLResponse)
+def admin_delete_user(
+ user_id: int,
+ request: Request,
+ csrf_token: str = Form(""),
+ db: Session = Depends(get_db),
+):
+ admin = _require_admin_page(request, db)
+ _check_form_csrf(request, db, csrf_token)
+ if user_id == admin.id:
+ return _render_admin(request, db, admin, error="You can't delete your own account.")
+ target = db.get(User, user_id)
+ if target is None:
+ return _render_admin(request, db, admin, error="No such user.")
+ name = target.username
+ db.delete(target) # sessions/links cascade; frames.owner goes NULL
+ db.commit()
+ return _render_admin(request, db, admin, notice=f"User '{name}' deleted.")
+
+
+@router.post("/admin/frames/{frame_id}/link-user", response_class=HTMLResponse)
+def admin_link_user(
+ frame_id: int,
+ request: Request,
+ csrf_token: str = Form(""),
+ username: str = Form(...),
+ db: Session = Depends(get_db),
+):
+ admin = _require_admin_page(request, db)
+ _check_form_csrf(request, db, csrf_token)
+ frame = db.get(Frame, frame_id)
+ target = db.scalars(select(User).where(User.username == _normalize_username(username))).first()
+ if frame is None or target is None:
+ return _render_admin(request, db, admin, error="No such frame or user.")
+ if db.get(UserFrame, (target.id, frame_id)) is not None:
+ return _render_admin(request, db, admin, error=f"'{target.username}' is already linked.")
+ db.add(UserFrame(user_id=target.id, frame_id=frame_id))
+ if frame.owner_user_id is None:
+ # Linking to an unclaimed frame claims it -- the admin flow for
+ # adopting a frame that self-registered without a pending claim.
+ frame.owner_user_id = target.id
+ frame.claimed_at = time.time()
+ db.commit()
+ return _render_admin(request, db, admin,
+ notice=f"Linked '{target.username}' to frame #{frame_id}.")
+
+
+@router.post("/admin/frames/{frame_id}/end-legacy", response_class=HTMLResponse)
+def admin_end_legacy(
+ frame_id: int,
+ request: Request,
+ csrf_token: str = Form(""),
+ db: Session = Depends(get_db),
+):
+ """Closes the legacy-token migration window once the device is
+ confirmed on per-frame auth (device_token_ack + recent last_seen in
+ the frames table below)."""
+ admin = _require_admin_page(request, db)
+ _check_form_csrf(request, db, csrf_token)
+ frame = db.get(Frame, frame_id)
+ if frame is None:
+ return _render_admin(request, db, admin, error="No such frame.")
+ frame.legacy_token_enabled = False
+ db.commit()
+ return _render_admin(request, db, admin, notice=f"Legacy token disabled for frame #{frame_id}.")
+
+
+@router.post("/admin/frames/{frame_id}/delete", response_class=HTMLResponse)
+def admin_delete_frame(
+ frame_id: int,
+ request: Request,
+ csrf_token: str = Form(""),
+ db: Session = Depends(get_db),
+):
+ admin = _require_admin_page(request, db)
+ _check_form_csrf(request, db, csrf_token)
+ frame = db.get(Frame, frame_id)
+ if frame is None:
+ return _render_admin(request, db, admin, error="No such frame.")
+ db.delete(frame) # links/battery log cascade
+ db.commit()
+ return _render_admin(request, db, admin, notice=f"Frame #{frame_id} deleted.")
diff --git a/server/app/templates/admin.html b/server/app/templates/admin.html
new file mode 100644
index 0000000..65c100f
--- /dev/null
+++ b/server/app/templates/admin.html
@@ -0,0 +1,100 @@
+{% extends "base.html" %}
+
+{% block subtitle %}
+
Administration
+{% endblock %}
+
+{% block content %}
+ {% if notice %}{{ notice }}
{% endif %}
+ {% if error %}{{ error }}
{% endif %}
+
+
+
+
+ Users
+
+ | Username | Display name | Role | |
+
+ {% for u in users %}
+
+ | {{ u.username }} |
+ {{ u.display_name }} |
+ {{ "admin" if u.is_admin else "user" }} |
+
+
+ Reset password
+
+
+ {% if u.id != user.id %}
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+ Enroll a user
+
+
+
+
+
+
+ Frames
+ {% for f in frames %}
+
+
+ #{{ f.id }} {{ f.name }}
+ device: {{ f.device_id or "not yet reported" }}
+ owner: {{ (f.owner.username if f.owner else none) or "UNCLAIMED" }}
+ · linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}
+ firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }})
+ · token ack: {{ "yes" if f.device_token_ack else "no" }}
+ {% if f.legacy_token_enabled %}· legacy token window OPEN{% endif %}
+
+
+ {% if f.legacy_token_enabled %}
+
+ {% endif %}
+
+
+ {% endfor %}
+ {% if not frames %}No frames yet.
{% endif %}
+
+
+
+{% endblock %}
diff --git a/server/app/templates/base.html b/server/app/templates/base.html
index 6568dea..4d7de6e 100644
--- a/server/app/templates/base.html
+++ b/server/app/templates/base.html
@@ -145,6 +145,35 @@
.icon-btn:hover { background: var(--surface-alt); }
.icon-btn:active { transform: scale(0.94); }
+ .topbar-actions { display: flex; align-items: center; gap: 14px; }
+ .topnav { display: flex; align-items: center; gap: 14px; font-size: 13.5px; }
+ .topnav a { color: var(--text-muted); text-decoration: none; }
+ .topnav a:hover { color: var(--text); }
+ .inline-form { display: inline; margin: 0; }
+ button.linklike {
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 13.5px;
+ font-weight: 400;
+ cursor: pointer;
+ box-shadow: none;
+ }
+ button.linklike:hover { color: var(--text); background: none; }
+
+ .admin-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
+ .admin-table th { text-align: left; color: var(--text-muted); font-weight: 600; padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border); }
+ .admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
+ .admin-actions form { margin: 4px 0 0; }
+ .admin-actions details summary { cursor: pointer; color: var(--text-muted); font-size: 13px; }
+ .admin-actions input[type="password"] { margin-top: 6px; }
+ .admin-frame { border-bottom: 1px solid var(--border); padding: 10px 0; }
+ .admin-frame:last-child { border-bottom: none; }
+ .admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
+ .admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
+
h2.card-title, summary.card-title {
font-size: 14.5px;
font-weight: 650;
@@ -319,12 +348,50 @@
{% block subtitle %}{% endblock %}
-
+
+ {% if user %}
+
+ {% endif %}
+
+
{% block content %}{% endblock %}
+ {% if csrf_token %}
+
+ {% endif %}
+