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

+ + + + {% for u in users %} + + + + + + + {% endfor %} + +
UsernameDisplay nameRole
{{ u.username }}{{ u.display_name }}{{ "admin" if u.is_admin else "user" }} +
+ Reset password +
+ + + +
+
+ {% if u.id != user.id %} +
+ + +
+ {% endif %} +
+ +

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 %} +