Redesign phase B: users, sessions, first-run setup, admin panel

Real identity on top of phase A's schema: scrypt-hashed passwords
(stdlib, no new deps -- parameters baked into each stored hash),
server-side sessions (sha256 of the cookie value stored, 30-day rolling
expiry), and per-session CSRF tokens enforced on every mutating
session-authed request -- via X-CSRF-Token for the JSON API (a fetch()
wrapper in base.html injects it, so the existing page scripts didn't
need touching) and a hidden form field for the HTML forms.

/setup runs once while no users exist: creates admin #1, links every
existing frame to them (owner + controller), and inherits the migrated
Immich creds onto their account -- per-user creds are now the primary
source, with env vars still winning as the operator fallback. /login,
/logout, /settings (display name, Immich creds, password change), and
/admin (enroll users, reset passwords, link users to frames, close a
frame's legacy-token window, delete) round out the pages, all in the
existing template/card style.

The legacy shared token stays accepted on browser routes so the
deployed frame's on-panel manage QR keeps working until phase C swaps
it for the limited manage page; token access renders without nav or
CSRF shim and is exempt from CSRF (explicit credential, not an ambient
cookie). Device routes untouched -- the legacy curl suite passes
verbatim.

Identity is provider-pluggable (identity_provider/provider_subject
already modeled) so OIDC can land later without schema surgery.
This commit is contained in:
2026-07-21 23:28:14 -04:00
parent 9fbbb8ed2b
commit 1e8d6803ac
9 changed files with 863 additions and 39 deletions
+163 -21
View File
@@ -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()/<img> 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:
+37 -15
View File
@@ -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,
},
)
+2 -2
View File
@@ -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
+395
View File
@@ -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.")
+100
View File
@@ -0,0 +1,100 @@
{% extends "base.html" %}
{% block subtitle %}
<p class="sub">Administration</p>
{% endblock %}
{% block content %}
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Users</h2>
<table class="admin-table">
<thead><tr><th>Username</th><th>Display name</th><th>Role</th><th></th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.username }}</td>
<td>{{ u.display_name }}</td>
<td>{{ "admin" if u.is_admin else "user" }}</td>
<td class="admin-actions">
<details>
<summary>Reset password</summary>
<form method="post" action="/admin/users/{{ u.id }}/reset-password">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="password" name="password" minlength="8" placeholder="New password" required>
<button type="submit" class="secondary btn-inline">Reset</button>
</form>
</details>
{% if u.id != user.id %}
<form method="post" action="/admin/users/{{ u.id }}/delete"
onsubmit="return confirm('Delete user {{ u.username }}?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary btn-inline">Delete</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h2 class="card-title" style="margin-top: 24px;">Enroll a user</h2>
<form method="post" action="/admin/users">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Username
<input type="text" name="username" maxlength="64" required>
</label>
<label>Password
<input type="password" name="password" minlength="8" required autocomplete="new-password">
</label>
<div class="checkbox-row">
<input type="checkbox" id="is_admin" name="is_admin" value="true">
<label for="is_admin">Administrator</label>
</div>
<button type="submit">Create user</button>
</form>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Frames</h2>
{% for f in frames %}
<div class="admin-frame">
<p class="sub">
<strong>#{{ f.id }} {{ f.name }}</strong><br>
device: <code>{{ f.device_id or "not yet reported" }}</code><br>
owner: {{ (f.owner.username if f.owner else none) or "UNCLAIMED" }}
&middot; linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br>
firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }})
&middot; token ack: {{ "yes" if f.device_token_ack else "no" }}
{% if f.legacy_token_enabled %}&middot; <strong>legacy token window OPEN</strong>{% endif %}
</p>
<form method="post" action="/admin/frames/{{ f.id }}/link-user" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="text" name="username" placeholder="Link user by name" required>
<button type="submit" class="secondary btn-inline">Link</button>
</form>
{% if f.legacy_token_enabled %}
<form method="post" action="/admin/frames/{{ f.id }}/end-legacy" class="admin-inline-form"
onsubmit="return confirm('Close the legacy-token window for frame #{{ f.id }}? Only do this once the device has acknowledged its own token.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary btn-inline">Close legacy window</button>
</form>
{% endif %}
<form method="post" action="/admin/frames/{{ f.id }}/delete" class="admin-inline-form"
onsubmit="return confirm('Delete frame #{{ f.id }} and all its history?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary btn-inline">Delete</button>
</form>
</div>
{% endfor %}
{% if not frames %}<p class="sub">No frames yet.</p>{% endif %}
</section>
</div>
</div>
{% endblock %}
+68 -1
View File
@@ -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 %}
</div>
</div>
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
<div class="topbar-actions">
{% if user %}
<nav class="topnav">
<a href="/">Home</a>
<a href="/settings">Settings</a>
{% if user.is_admin %}<a href="/admin">Admin</a>{% endif %}
<form method="post" action="/logout" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="linklike">Log out</button>
</form>
</nav>
{% endif %}
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
</div>
</header>
{% block content %}{% endblock %}
</div>
{% if csrf_token %}
<script>
// Session-cookie auth needs CSRF proof on mutating requests. Rather
// than touching every fetch() call site in the page scripts, wrap
// fetch once: same-origin non-GET requests automatically carry the
// per-session token. (Legacy shared-token access renders without a
// csrf_token, so this block doesn't exist there at all.)
(function () {
var CSRF = {{ csrf_token | tojson }};
var origFetch = window.fetch;
window.fetch = function (input, init) {
init = init || {};
var method = (init.method || (input && input.method) || 'GET').toUpperCase();
var url = typeof input === 'string' ? input : (input && input.url) || '';
var sameOrigin = url.indexOf('://') === -1 || url.indexOf(location.origin) === 0;
if (sameOrigin && method !== 'GET' && method !== 'HEAD') {
init.headers = new Headers(init.headers || (input && input.headers) || {});
init.headers.set('X-CSRF-Token', CSRF);
}
return origFetch.call(this, input, init);
};
})();
</script>
{% endif %}
<script>
// Shared theme toggle: explicit choice wins over the OS preference and
// is remembered; with no explicit choice, the CSS above falls back to
+23
View File
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block page_class %}page-narrow{% endblock %}
{% block subtitle %}
<p class="sub">Sign in</p>
{% endblock %}
{% block content %}
<section class="card">
<h2 class="card-title">Log in</h2>
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
<form method="post" action="/login">
<label>Username
<input type="text" name="username" maxlength="64" required autofocus autocomplete="username">
</label>
<label>Password
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit">Log in</button>
</form>
</section>
{% endblock %}
+45
View File
@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block page_class %}page-narrow{% endblock %}
{% block subtitle %}
<p class="sub">Your account</p>
{% endblock %}
{% block content %}
{% if saved %}<div class="status ok">Saved.</div>{% endif %}
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
<section class="card">
<h2 class="card-title">Profile &amp; photo library</h2>
<form method="post" action="/settings">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Display name
<input type="text" name="display_name" maxlength="64" value="{{ user.display_name }}">
</label>
<label>Immich URL
<input type="text" name="immich_url" placeholder="http://your-immich-host:2283"
value="{{ user.immich_url }}">
</label>
<label>Immich API key
<input type="password" name="immich_api_key" autocomplete="off"
placeholder="{% if user.immich_api_key %}(unchanged -- enter a new key to replace){% else %}your-immich-api-key{% endif %}">
</label>
<p class="sub" style="margin-top: 8px;">Frames you own pull photos from
this Immich library. The key needs read access to albums/assets/faces
plus <code>sharedLink.create</code> for the on-frame share QR.</p>
<h2 class="card-title" style="margin-top: 24px;">Change password</h2>
<label>Current password
<input type="password" name="current_password" autocomplete="current-password">
</label>
<label>New password
<input type="password" name="new_password" minlength="8" autocomplete="new-password">
</label>
<p class="sub" style="margin-top: 8px;">Leave both blank to keep your
current password.</p>
<button type="submit">Save</button>
</form>
</section>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block page_class %}page-narrow{% endblock %}
{% block subtitle %}
<p class="sub">First-run setup</p>
{% endblock %}
{% block content %}
<section class="card">
<h2 class="card-title">Create the admin account</h2>
<p class="sub">This server has no users yet. The account you create here
is the administrator: it can enroll other users and manage every
frame. Any frame this server already knows about is linked to it
automatically.</p>
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
<form method="post" action="/setup">
<label>Username
<input type="text" name="username" maxlength="64" required autofocus autocomplete="username">
</label>
<label>Display name (optional)
<input type="text" name="display_name" maxlength="64" autocomplete="name">
</label>
<label>Password
<input type="password" name="password" minlength="8" required autocomplete="new-password">
</label>
<button type="submit">Create admin account</button>
</form>
</section>
{% endblock %}