feat(#1108): long-lived camera-stream tokens + fix(#1089) audit-pass tweaks

#1108 — Long-lived camera-stream tokens for HA / Frigate / kiosks. Camera-only
  V1, hard 365-day cap (no infinite tokens), pbkdf2 hashed at rest, plaintext
  shown to user exactly once on creation. New "Camera API Tokens" panel under
  Settings → API Keys with self-service create/revoke, styled confirm modal,
  admin "All users" view for leak triage. Auth path: /camera/stream tries the
  existing 60-min ephemeral table first, falls through to the long-lived path.
  Indexed lookup_prefix keeps verify O(1) per token.

  Permission audit: gated the existing API-keys-CRUD + Webhook docs + API
  Browser content behind api_keys:read so non-admins with camera:view land on
  the API Keys tab and see only the Camera Tokens panel they actually have
  permission to use. Grid layout collapses to single column for non-admins.

  Tests: 29 new backend (15 service + 14 integration covering create/list/
  revoke ownership rules, the auth fall-through, scope enforcement, prefix
  collisions) + 6 new frontend tests for the section UI including the new
  modal flow. All 77 backend tests + 21 frontend camera tests pass. Ruff
  clean (lint + format).

  Docs: README updated with fan-out + long-lived-token bullets. Wiki gets a
  new "Long-Lived Camera Tokens" section under features/camera.md (HA YAML
  example, security model, permission requirements, revoke flow). Website
  features.html gets the bullet under Camera Streaming.

  Also includes #1089 follow-up tweaks already merged in this branch:
  _stream_start_times.setdefault for accurate stream_uptime, subscribe()
  RuntimeError retry to close the grace-vs-subscribe race, atomic
  unsubscribe count via the iter_subscriber on_unsubscribe callback.
This commit is contained in:
maziggy 2026-04-25 10:44:37 +02:00
parent 1e3ad697f2
commit fcda728af4
26 changed files with 2660 additions and 431 deletions

View file

@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.4b1] - Unreleased
### Added
- **Long-lived camera-stream tokens for HA / Frigate / kiosks** ([#1108](https://github.com/maziggy/bambuddy/issues/1108)) — The existing `?token=…` camera-stream tokens expire after 60 minutes which forced home-automation integrations (Home Assistant cards, Frigate, hallway kiosks) to either refresh on a cron or run with auth disabled. New self-service "Camera API Tokens" panel under **Settings → API Keys** (also reachable via the existing settings search box — type "camera token" / "frigate" / "home assistant") lets any user holding `camera:view` mint a long-lived token they can paste once and forget. Revoke uses Bambuddy's standard styled confirmation modal (no `window.confirm` browser default — same pattern as the rest of the app). Tokens are scoped strictly to camera streaming (no privilege escalation surface — no other endpoint accepts them), formatted `bblt_<8-char-prefix>_<32-char-secret>`, and stored as a pbkdf2 hash so even a DB dump can't replay them; the plaintext is shown to the user **exactly once** in a copy-to-clipboard modal (with a `document.execCommand('copy')` fallback for plain-HTTP LAN deployments where `navigator.clipboard` is gated by the secure-context requirement). Hard 365-day max — the issue's `expire_in: 0` (never) is explicitly rejected because an irrevocable infinite token is a footgun-by-design; UI defaults to 90 days, the cap is enforced both client-side (input clamp) and server-side (validation guard). Owners can revoke their own tokens; admins additionally see an "All users" view for leak triage and can revoke anyone's. The `/camera/stream?token=…` auth dependency tries the existing 60-min ephemeral row first (no behaviour change for the common browser case) and falls through to the long-lived path, so the SPA's existing camera flow is unaffected. Indexed `lookup_prefix` keeps verify O(1) per token even on large installs — pbkdf2 only runs against the one candidate row that matches the prefix, never the whole table. New `long_lived_tokens` table (separate from `auth_ephemeral_tokens` because the lifecycle is different — user-owned, named, revocable, hashed; and separate from `api_keys` because that one is for global webhooks with no user FK and a different permission shape). 15 unit tests covering create-validation/scope/expiry rules, verify happy/garbage/expired/revoked/scope-mismatch/prefix-collision paths, list-by-user vs list-all, idempotent revoke; 14 integration tests covering the create-once-then-listing-hides-plaintext contract, the 365-day cap, the auth gate, owner-vs-admin revoke ownership rules, and that the long-lived token verifies through the same camera-stream auth dependency the route uses (and that revoke immediately invalidates it). 6 frontend tests covering list render, empty state, create-then-shown-once flow, days-input clamp, revoke-with-confirm, and revoke-cancelled paths. New `cameraTokens.*` keys across all 8 locales (English fully translated; the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features).
- **Tailscale integration for virtual printers** (builds on [#1070](https://github.com/maziggy/bambuddy/issues/1070) by @legend813) — Opt-in per-VP Tailscale toggle brings each virtual printer into the tailnet, so it's reachable from any tailnet device over a private WireGuard tunnel without port forwarding or public exposure. When enabled, Bambuddy provisions a Let's Encrypt cert for the VP's MagicDNS hostname via `tailscale cert` and the MQTT/FTPS listeners serve it. **Slicer-side caveat worth knowing up front:** both Bambu Studio and OrcaSlicer only accept IP addresses (not hostnames) in the Add Printer dialog, so the LE cert's hostname validation doesn't apply — users still need the Bambuddy CA imported into the slicer, same as LAN mode. The practical benefit here is the private tunnel (remote access without DDNS / port forwarding / public exposure), not cert-import elimination. Default is opt-out (toggle off) so users without Tailscale don't see cert-provisioning attempts or log noise. When a user flips the toggle on a host without a working Tailscale binary, the backend returns `409 tailscale_not_available` and the UI reverts + surfaces a specific toast pointing at the setup steps (install Tailscale → `tailscale up``tailscale set --operator=<user>` → enable HTTPS in the tailnet admin console). Docker image now ships the `tailscale` CLI pre-installed; users wire up by uncommenting the `/var/run/tailscale/tailscaled.sock` volume mount in `docker-compose.yml`. The MagicDNS hostname is surfaced on the VP card with a copy-to-clipboard button (modern `navigator.clipboard` in secure contexts, `document.execCommand` fallback for plain-HTTP contexts with textarea cleanup in `finally`). Cert renewal runs daily in-process and restarts only the affected VP's TLS listeners. New i18n keys `virtualPrinter.tailscaleDisabled.{title,description}` + `virtualPrinter.toast.{tailscaleNotAvailable,copyFailed}` across all 8 locales with full translations. 3 new backend integration tests for the 409 guard, 2 unit tests for the `_cancel_restart_task` self-await guard, 4 unit tests for the settings-dedupe migration, and 3 new frontend tests for the clipboard fallback path. Thanks to @legend813 for the original opt-out toggle PR that this was built on top of.
- **Library Trash Bin + Admin Bulk Purge + Auto-Purge** ([#1008](https://github.com/maziggy/bambuddy/issues/1008)) — Library files now move to a trash bin on delete instead of being hard-deleted from disk, with a configurable retention window (default 30 days) before a background sweeper permanently removes them. Admins get a new "Purge old" action on the File Manager that shows a live preview of count + total size before moving every file older than *N* days (with an opt-in toggle for never-printed files, on by default) into the trash in one shot. A new **Auto-purge** setting in Settings → File Manager runs the same purge automatically on a 24-hour cadence when enabled — files still go to Trash first so the retention window remains the safety net; default-off so existing installs don't surprise anyone. Both the per-user delete flow and the admin bulk purge go through the same trash — regular users see and manage their own trashed files; admins see everyone's. External (linked) files bypass trash and keep the original hard-delete behaviour since their bytes aren't under Bambuddy's control. New `library:purge` permission gates the admin operations; retention is adjustable inline on the Trash page for admins. Adds nullable `deleted_at` column on `library_files` with an index (dialect-aware migration: `DATETIME` on SQLite, `TIMESTAMP` on PostgreSQL, since raw `DATETIME` is SQLite-only syntax); every `LibraryFile` query site now routes through a new `LibraryFile.active()` classmethod so trashed rows can't leak into listings, print dispatch, MakerWorld dedupe, or stats. 17 new backend integration tests + 8 new frontend component/page tests; localised across all 8 UI languages. Thanks to @cadtoolbox for the proposal and the follow-up answers that tightened the spec.

View file

@ -100,7 +100,8 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
### 📊 Monitoring & Control
- Real-time printer status via WebSocket
- Live camera streaming (MJPEG) & snapshots with multi-viewer support
- Live camera streaming (MJPEG) & snapshots with multi-viewer support — most Bambu printers only allow one upstream connection, so Bambuddy fans out a single shared stream to all browser tabs / cards / overlays
- **Long-lived camera tokens** for Home Assistant / Frigate / kiosks — mint a token from Settings → API Keys, paste it once, capped at 365 days, revocable at any time (no infinite tokens — leaked permanent tokens are unsafe by design)
- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`), configurable FPS (`?fps=30`), status-only mode (`?camera=false`)
- External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
- **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)

View file

@ -1277,3 +1277,199 @@ async def get_ldap_status(db: AsyncSession = Depends(get_db)):
"ldap_enabled": settings.get("ldap_enabled", "false").lower() == "true",
"ldap_configured": bool(settings.get("ldap_server_url")),
}
# =============================================================================
# Long-lived camera-stream tokens (#1108)
# =============================================================================
# Camera-only V1. Issue scope: a token a user can paste into Home Assistant /
# Frigate / a kiosk and have it keep working for days/weeks rather than
# refreshing the 60-minute ephemeral token. Permission gate: CAMERA_VIEW
# (same blast radius as the existing 60-min token-mint endpoint).
def _long_lived_token_to_response(record, *, plaintext: str | None = None) -> dict:
"""Serialise a LongLivedToken row for the SPA. Plaintext is included
only at create time (and then never again), per the issue's "shown once"
contract.
"""
return {
"id": record.id,
"user_id": record.user_id,
"name": record.name,
"scope": record.scope,
"lookup_prefix": record.lookup_prefix,
"created_at": record.created_at.isoformat() if record.created_at else None,
"expires_at": record.expires_at.isoformat() if record.expires_at else None,
"last_used_at": record.last_used_at.isoformat() if record.last_used_at else None,
# Plaintext is the ONLY field the user ever sees in full — copied once
# to a clipboard / kiosk config and then forgotten.
"token": plaintext,
}
@router.post("/tokens", response_model=dict, status_code=status.HTTP_201_CREATED)
async def create_long_lived_camera_token(
payload: dict,
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
db: AsyncSession = Depends(get_db),
):
"""Mint a long-lived camera-stream token (#1108).
Body: ``{"name": str, "expires_in_days": int, "scope": "camera_stream"}``.
The plaintext token is returned **exactly once** in the response. The DB
only ever stores a pbkdf2 hash, so a leaked DB dump cannot replay the
token. Hard cap of 365 days; the issue's ``expire_in: 0`` (never) is
explicitly rejected.
"""
from backend.app.services.long_lived_tokens import (
ALLOWED_SCOPES,
MAX_TOKEN_LIFETIME_DAYS,
create_token,
)
# Auth-disabled path: tokens are user-owned, but if auth is off there is
# no user to own them. Refuse rather than silently picking a random user.
if current_user is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Long-lived tokens require authentication to be enabled",
)
name = payload.get("name")
if not isinstance(name, str) or not name.strip():
raise HTTPException(status_code=400, detail="name is required")
expires_in_days = payload.get("expires_in_days")
if not isinstance(expires_in_days, int) or expires_in_days <= 0:
raise HTTPException(
status_code=400,
detail=(
f"expires_in_days must be a positive integer (max {MAX_TOKEN_LIFETIME_DAYS}; #1108: no infinite tokens)"
),
)
scope = payload.get("scope", "camera_stream")
if scope not in ALLOWED_SCOPES:
raise HTTPException(status_code=400, detail=f"unsupported scope: {scope!r}")
try:
created = await create_token(
db,
user_id=current_user.id,
name=name,
expires_in_days=expires_in_days,
scope=scope,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
_logger.info(
"Long-lived camera token created: user=%s name=%r scope=%s expires=%s",
current_user.username,
name,
scope,
created.record.expires_at.isoformat(),
)
return _long_lived_token_to_response(created.record, plaintext=created.plaintext)
@router.get("/tokens", response_model=list[dict])
async def list_long_lived_tokens(
user_id: int | None = None,
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
db: AsyncSession = Depends(get_db),
):
"""List long-lived tokens.
Default: caller's own tokens.
Admins can pass ``?user_id=N`` to see another user's tokens, or omit it
to see everything (handy for leak triage).
"""
from backend.app.services.long_lived_tokens import list_user_tokens
# Auth-disabled installs don't have a notion of "my tokens" — refuse so
# we don't leak a global list to whoever can hit the API.
if current_user is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Long-lived tokens require authentication to be enabled",
)
# Reload with groups so is_admin reflects group membership reliably.
user_with_groups = (
await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
).scalar_one()
if user_id is None or user_id == current_user.id:
records = await list_user_tokens(db, current_user.id)
elif user_with_groups.is_admin:
records = await list_user_tokens(db, user_id)
else:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can list other users' tokens",
)
return [_long_lived_token_to_response(r) for r in records]
@router.get("/tokens/all", response_model=list[dict])
async def list_all_long_lived_tokens(
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
db: AsyncSession = Depends(get_db),
):
"""Admin-only: every active long-lived token in the system, newest first.
Used by the leak-triage view in admin settings.
"""
from backend.app.services.long_lived_tokens import list_all_tokens
if current_user is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Auth required")
user_with_groups = (
await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
).scalar_one()
if not user_with_groups.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin only",
)
records = await list_all_tokens(db)
return [_long_lived_token_to_response(r) for r in records]
@router.delete("/tokens/{token_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_long_lived_token(
token_id: int,
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
db: AsyncSession = Depends(get_db),
):
"""Revoke a long-lived token. Owners can revoke their own; admins any."""
from backend.app.models.long_lived_token import LongLivedToken
from backend.app.services.long_lived_tokens import revoke_token
if current_user is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Auth required")
record = (await db.execute(select(LongLivedToken).where(LongLivedToken.id == token_id))).scalar_one_or_none()
if record is None:
raise HTTPException(status_code=404, detail="Token not found")
if record.user_id != current_user.id:
# Reload for is_admin so admins can revoke any user's token (leak response).
user_with_groups = (
await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
).scalar_one()
if not user_with_groups.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only revoke your own tokens",
)
revoked = await revoke_token(db, token_id)
if not revoked:
# Already revoked is treated as 404 for idempotency from the UI side.
raise HTTPException(status_code=404, detail="Token not found or already revoked")
_logger.info(
"Long-lived camera token revoked: id=%d by user=%s",
token_id,
current_user.username,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)

View file

@ -195,7 +195,12 @@ async def create_camera_stream_token() -> str:
async def verify_camera_stream_token(token: str) -> bool:
"""Verify a camera stream token is valid (reusable — does not consume it)."""
"""Verify a camera stream token is valid (reusable — does not consume it).
Tries the ephemeral 60-minute token first (the common, browser-bound case)
and falls through to long-lived tokens (#1108) for HA / kiosk integrations
that paste a token once and expect it to keep working for days.
"""
now = datetime.now(timezone.utc)
async with async_session() as db:
result = await db.execute(
@ -205,7 +210,15 @@ async def verify_camera_stream_token(token: str) -> bool:
AuthEphemeralToken.expires_at > now,
)
)
return result.scalar_one_or_none() is not None
if result.scalar_one_or_none() is not None:
return True
# Long-lived path. Imported lazily so the auth module stays importable
# at startup before the long_lived_tokens model is registered.
from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
record = await verify_long_lived(db, token, scope="camera_stream")
return record is not None
def verify_password(plain_password: str, hashed_password: str) -> bool:

View file

@ -166,6 +166,7 @@ async def init_db():
kprofile_note,
library,
local_preset,
long_lived_token,
maintenance,
notification,
notification_template,

View file

@ -10,6 +10,7 @@ from backend.app.models.group import Group, user_groups
from backend.app.models.kprofile_note import KProfileNote
from backend.app.models.library import LibraryFile, LibraryFolder
from backend.app.models.local_preset import LocalPreset
from backend.app.models.long_lived_token import LongLivedToken
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
from backend.app.models.notification import NotificationLog
from backend.app.models.notification_template import NotificationTemplate
@ -75,4 +76,5 @@ __all__ = [
"UserTOTP",
"AuthEphemeralToken",
"AuthRateLimitEvent",
"LongLivedToken",
]

View file

@ -0,0 +1,77 @@
"""Long-lived camera-stream tokens (#1108).
Issue #1108: the existing 60-minute ``camera_stream`` ephemeral tokens are
too short-lived for home-automation integrations (Home Assistant cards,
Frigate, kiosks), which expect a token they can paste once and forget.
Why a separate table from ``AuthEphemeralToken``:
- These are user-owned, named, and revocable from the UI different
lifecycle from ephemeral / single-use tokens.
- Hashed at rest (bcrypt). Ephemeral tokens are stored as raw strings
because their short TTL caps the impact of a DB read; a long-lived
token must survive a DB dump unscathed.
Why a separate table from ``api_keys``:
- ``api_keys`` is for webhook integrations and has no ``user_id`` FK
(the keys are global). Long-lived camera tokens are explicitly per-user
so the UI can show "your tokens" and so a leak can be traced to one user.
- Different permission shape (``api_keys`` carries can_queue / can_control
flags; long-lived tokens are pure read-only camera streaming).
V1 hard rules:
- ``expires_at`` is required (the issue's ``expire_in: 0 = never`` was
rejected irrevocable infinite tokens are a footgun).
- 365-day max enforced in the create route, not the DB, so a future
policy change is just a config bump.
- Scope column exists today ("camera_stream" is the only valid value)
to keep the door open for other long-lived scopes later without a
schema migration.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class LongLivedToken(Base):
"""Per-user, hashed-at-rest, revocable token for long-running camera viewers."""
__tablename__ = "long_lived_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# User-given label — "Home Assistant", "Kitchen kiosk", etc.
name: Mapped[str] = mapped_column(String(100), nullable=False)
# Public lookup prefix — first 8 chars of the secret part. Indexed so
# verify() can fetch one row instead of scanning + bcrypting all rows.
# Format: ``bblt_<8-char-prefix>_<32-char-secret>``.
lookup_prefix: Mapped[str] = mapped_column(String(8), nullable=False, index=True)
# bcrypt hash of the 32-char secret part. Never stored or returned in plaintext.
secret_hash: Mapped[str] = mapped_column(String(255), nullable=False)
# V1: only "camera_stream" is accepted. Column exists so future scopes
# don't need a schema migration.
scope: Mapped[str] = mapped_column(String(32), nullable=False, default="camera_stream")
# Required — no infinite tokens. Capped at 365 days at create time.
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
# Updated on successful verify (rate-limited to once per minute per token
# to avoid thrashing the DB on every MJPEG keep-alive read).
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Set when the user (or an admin) revokes; verify treats revoked == invalid.
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
def __repr__(self) -> str:
return f"<LongLivedToken id={self.id} user_id={self.user_id} name={self.name!r} scope={self.scope}>"

View file

@ -0,0 +1,214 @@
"""Service layer for long-lived camera-stream tokens (#1108).
Token format: ``bblt_<8-char-prefix>_<32-char-secret>``.
- The full token is shown to the user **exactly once** at create time.
- ``lookup_prefix`` (the 8-char middle part) is indexed and used to cheaply
fetch the candidate row at most one in practice without scanning the
whole table on every request.
- ``secret_hash`` is a pbkdf2_sha256 hash of the full token (matching the
rest of the codebase's password hashing). Even a DB dump can't be replayed
against the camera endpoint.
- ``last_used_at`` is updated on successful verify, but rate-limited to once
per minute per token so an MJPEG keep-alive doesn't write to the DB on
every chunk.
- ``revoked_at`` set verify returns False; admins or the owning user can
flip it.
Maximum lifetime is 365 days (issue #1108 explicitly rejected "infinite"
tokens a leaked permanent token would be irrevocable footgun-by-design).
"""
from __future__ import annotations
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import get_password_hash, verify_password
from backend.app.models.long_lived_token import LongLivedToken
# Issue #1108 hard cap. Bump here if policy changes — UI default is shorter
# (90 days) and the create route enforces this ceiling.
MAX_TOKEN_LIFETIME_DAYS = 365
# Only V1 scope. Adding "snapshot" or "control" later means adding a value
# to this tuple and an `if scope == ...` branch in the route, no schema work.
ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream"})
# Don't write to last_used_at more than once per minute per token. MJPEG
# streams call verify() at most once per fetch (the browser holds the
# connection open), but snapshots may rapid-fire — this caps DB churn.
_LAST_USED_DEBOUNCE = timedelta(minutes=1)
# Token format constants — kept in one place so format changes are localized.
_TOKEN_PREFIX = "bblt_"
_LOOKUP_LEN = 8
_SECRET_LEN = 32 # urlsafe characters → ~190 bits of entropy
@dataclass(frozen=True)
class CreatedToken:
"""Returned to the route on create. ``plaintext`` is shown to the user
exactly once and never persisted; only ``record`` survives in the DB.
"""
record: LongLivedToken
plaintext: str
def _generate_token_parts() -> tuple[str, str, str]:
"""Return ``(plaintext, lookup_prefix, hash_input)``.
``hash_input`` is the same string we hand to pbkdf2 so verify() can
produce a matching hash from the user-submitted token.
The prefix is hex on purpose ``token_urlsafe`` can emit ``_`` which
would collide with the ``bblt_<prefix>_<secret>`` format separator and
break the parser. Hex is fine for a non-secret indexed lookup column;
the security comes from the 32-char ``token_urlsafe`` secret part.
"""
lookup_prefix = secrets.token_hex(_LOOKUP_LEN // 2) # 4 bytes → 8 hex chars
secret_part = secrets.token_urlsafe(48).replace("_", "").replace("-", "")[:_SECRET_LEN]
plaintext = f"{_TOKEN_PREFIX}{lookup_prefix}_{secret_part}"
return plaintext, lookup_prefix, plaintext
def _parse_token(token: str) -> tuple[str, str] | None:
"""Pull ``(lookup_prefix, full_token)`` from a submitted string.
Returns None if the format doesn't match — short-circuits the DB lookup
on garbage / wrong-format inputs.
"""
if not token.startswith(_TOKEN_PREFIX):
return None
rest = token[len(_TOKEN_PREFIX) :]
sep = rest.find("_")
if sep != _LOOKUP_LEN:
return None
lookup_prefix = rest[:_LOOKUP_LEN]
return lookup_prefix, token
def _is_expired(record: LongLivedToken, now: datetime) -> bool:
expires = record.expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
return expires <= now
async def create_token(
db: AsyncSession,
*,
user_id: int,
name: str,
expires_in_days: int,
scope: str = "camera_stream",
) -> CreatedToken:
"""Mint a new long-lived token. Caller is responsible for permission checks.
Raises ValueError if ``expires_in_days`` exceeds the policy cap or
``scope`` is not in ``ALLOWED_SCOPES``. The route translates these into
a 400 with the offending field.
"""
if scope not in ALLOWED_SCOPES:
raise ValueError(f"unsupported scope: {scope!r}")
if expires_in_days <= 0:
raise ValueError("expires_in_days must be positive (#1108: no infinite tokens)")
if expires_in_days > MAX_TOKEN_LIFETIME_DAYS:
raise ValueError(f"expires_in_days exceeds policy maximum of {MAX_TOKEN_LIFETIME_DAYS}")
name = name.strip()
if not name:
raise ValueError("name is required")
if len(name) > 100:
raise ValueError("name must be 100 chars or fewer")
plaintext, lookup_prefix, hash_input = _generate_token_parts()
now = datetime.now(timezone.utc)
record = LongLivedToken(
user_id=user_id,
name=name,
lookup_prefix=lookup_prefix,
secret_hash=get_password_hash(hash_input),
scope=scope,
expires_at=now + timedelta(days=expires_in_days),
)
db.add(record)
await db.commit()
await db.refresh(record)
return CreatedToken(record=record, plaintext=plaintext)
async def verify_token(db: AsyncSession, token: str, *, scope: str = "camera_stream") -> LongLivedToken | None:
"""Validate a token. Returns the matching record on success, None otherwise.
The bcrypt-style verify is the slow step (intentional pbkdf2 by design),
so we pre-filter by the indexed ``lookup_prefix`` to ensure the verify
runs against at most one or two candidate rows.
"""
parsed = _parse_token(token)
if parsed is None:
return None
lookup_prefix, full_token = parsed
now = datetime.now(timezone.utc)
result = await db.execute(
select(LongLivedToken).where(
LongLivedToken.lookup_prefix == lookup_prefix,
LongLivedToken.scope == scope,
LongLivedToken.revoked_at.is_(None),
)
)
candidates = result.scalars().all()
for record in candidates:
if _is_expired(record, now):
continue
if not verify_password(full_token, record.secret_hash):
continue
# Record use, but rate-limit DB writes to keep MJPEG-keepalive cheap.
last = record.last_used_at
if last is None or _coerce_utc(last) + _LAST_USED_DEBOUNCE <= now:
record.last_used_at = now
await db.commit()
return record
return None
def _coerce_utc(dt: datetime) -> datetime:
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
async def list_user_tokens(db: AsyncSession, user_id: int) -> list[LongLivedToken]:
"""All non-revoked tokens for a user, newest first. Includes expired ones
(the UI shows them so the user can clean them up).
"""
result = await db.execute(
select(LongLivedToken)
.where(LongLivedToken.user_id == user_id, LongLivedToken.revoked_at.is_(None))
.order_by(LongLivedToken.created_at.desc())
)
return list(result.scalars().all())
async def list_all_tokens(db: AsyncSession) -> list[LongLivedToken]:
"""Admin view of every non-revoked token in the system, newest first."""
result = await db.execute(
select(LongLivedToken).where(LongLivedToken.revoked_at.is_(None)).order_by(LongLivedToken.created_at.desc())
)
return list(result.scalars().all())
async def revoke_token(db: AsyncSession, token_id: int) -> bool:
"""Mark a token revoked. Returns True if a row was updated, False if the
id didn't exist or was already revoked.
"""
result = await db.execute(select(LongLivedToken).where(LongLivedToken.id == token_id))
record = result.scalar_one_or_none()
if record is None or record.revoked_at is not None:
return False
record.revoked_at = datetime.now(timezone.utc)
await db.commit()
return True

View file

@ -0,0 +1,321 @@
"""Integration tests for long-lived camera-stream token routes (#1108).
Cover the auth gates, ownership rules, max-lifetime cap, token-shown-once
contract, and the camera-stream auth fall-through (the existing 60-min
ephemeral path still works AND a long-lived token is also accepted).
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _setup_admin(async_client: AsyncClient, *, suffix: str = "") -> str:
"""Create the first admin and return their JWT."""
await async_client.post(
"/api/v1/auth/setup",
json={
"auth_enabled": True,
"admin_username": f"tokenadmin{suffix}",
"admin_password": "AdminPass1!",
},
)
login = await async_client.post(
"/api/v1/auth/login",
json={"username": f"tokenadmin{suffix}", "password": "AdminPass1!"},
)
return login.json()["access_token"]
async def _create_user(async_client: AsyncClient, admin_token: str, username: str, *, role: str = "user") -> int:
"""Create a non-admin user via the admin API and return their id.
The user is assigned to the seeded "Viewers" group so they hold
``CAMERA_VIEW`` without that, regular users cannot create their own
long-lived tokens (which is the same gate the existing 60-min ephemeral
flow uses).
"""
# Fetch Viewers group id so the new user inherits CAMERA_VIEW.
groups_resp = await async_client.get("/api/v1/groups/", headers={"Authorization": f"Bearer {admin_token}"})
viewers = next((g for g in groups_resp.json() if g["name"] == "Viewers"), None)
assert viewers is not None, f"Viewers group not seeded: {groups_resp.text}"
response = await async_client.post(
"/api/v1/users/",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"username": username,
"password": "UserPass1!",
"role": role,
"group_ids": [viewers["id"]],
},
)
assert response.status_code in (200, 201), response.text
return response.json()["id"]
async def _login(async_client: AsyncClient, username: str) -> str:
response = await async_client.post(
"/api/v1/auth/login",
json={"username": username, "password": "UserPass1!"},
)
body = response.json()
token = body.get("access_token")
assert token, f"login for {username!r} returned no access_token: {body}"
return token
# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------
class TestCreateLongLivedToken:
async def test_create_returns_plaintext_token_exactly_once(self, async_client: AsyncClient):
token = await _setup_admin(async_client, suffix="_create_once")
response = await async_client.post(
"/api/v1/auth/tokens",
headers={"Authorization": f"Bearer {token}"},
json={"name": "Home Assistant", "expires_in_days": 30},
)
assert response.status_code == 201, response.text
body = response.json()
assert body["token"].startswith("bblt_")
assert body["name"] == "Home Assistant"
assert body["scope"] == "camera_stream"
assert body["lookup_prefix"]
token_id = body["id"]
# Listing must NOT include the plaintext (shown-once contract).
listing = await async_client.get(
"/api/v1/auth/tokens",
headers={"Authorization": f"Bearer {token}"},
)
assert listing.status_code == 200
listed = next((t for t in listing.json() if t["id"] == token_id), None)
assert listed is not None
assert listed["token"] is None # plaintext gone forever
async def test_create_rejects_expires_in_zero(self, async_client: AsyncClient):
"""Issue #1108: ``expire_in: 0`` (never) is explicitly forbidden."""
token = await _setup_admin(async_client, suffix="_zero_expire")
response = await async_client.post(
"/api/v1/auth/tokens",
headers={"Authorization": f"Bearer {token}"},
json={"name": "x", "expires_in_days": 0},
)
assert response.status_code == 400
assert "positive" in response.json()["detail"].lower()
async def test_create_rejects_above_max(self, async_client: AsyncClient):
token = await _setup_admin(async_client, suffix="_above_max")
response = await async_client.post(
"/api/v1/auth/tokens",
headers={"Authorization": f"Bearer {token}"},
json={"name": "x", "expires_in_days": 366},
)
assert response.status_code == 400
assert "365" in response.json()["detail"]
async def test_create_requires_auth(self, async_client: AsyncClient):
await _setup_admin(async_client, suffix="_unauth")
response = await async_client.post(
"/api/v1/auth/tokens",
json={"name": "x", "expires_in_days": 7},
)
assert response.status_code == 401
# ---------------------------------------------------------------------------
# List
# ---------------------------------------------------------------------------
class TestListLongLivedTokens:
async def test_list_returns_only_callers_tokens_by_default(self, async_client: AsyncClient):
admin_token = await _setup_admin(async_client, suffix="_list_default")
bob_id = await _create_user(async_client, admin_token, "bob_list")
bob_token = await _login(async_client, "bob_list")
# Each user creates one token.
await async_client.post(
"/api/v1/auth/tokens",
headers={"Authorization": f"Bearer {admin_token}"},
json={"name": "admins", "expires_in_days": 7},
)
await async_client.post(
"/api/v1/auth/tokens",
headers={"Authorization": f"Bearer {bob_token}"},
json={"name": "bobs", "expires_in_days": 7},
)
# Bob's listing should see only his.
bob_listing = await async_client.get(
"/api/v1/auth/tokens",
headers={"Authorization": f"Bearer {bob_token}"},
)
names = {t["name"] for t in bob_listing.json()}
assert names == {"bobs"}
assert bob_id == bob_listing.json()[0]["user_id"]
async def test_admin_can_filter_by_user_id(self, async_client: AsyncClient):
admin_token = await _setup_admin(async_client, suffix="_admin_filter")
bob_id = await _create_user(async_client, admin_token, "bob_filter")
bob_token = await _login(async_client, "bob_filter")
await async_client.post(
"/api/v1/auth/tokens",
headers={"Authorization": f"Bearer {bob_token}"},
json={"name": "bobs", "expires_in_days": 7},
)
admin_view = await async_client.get(
f"/api/v1/auth/tokens?user_id={bob_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert admin_view.status_code == 200
names = {t["name"] for t in admin_view.json()}
assert names == {"bobs"}
async def test_non_admin_cannot_see_other_users_tokens(self, async_client: AsyncClient):
admin_token = await _setup_admin(async_client, suffix="_non_admin")
await _create_user(async_client, admin_token, "alice_see")
bob_id = await _create_user(async_client, admin_token, "bob_see")
alice_token = await _login(async_client, "alice_see")
forbidden = await async_client.get(
f"/api/v1/auth/tokens?user_id={bob_id}",
headers={"Authorization": f"Bearer {alice_token}"},
)
assert forbidden.status_code == 403
# ---------------------------------------------------------------------------
# Revoke
# ---------------------------------------------------------------------------
class TestRevokeLongLivedToken:
async def test_owner_can_revoke_own_token(self, async_client: AsyncClient):
token = await _setup_admin(async_client, suffix="_revoke_own")
created = await async_client.post(
"/api/v1/auth/tokens",
headers={"Authorization": f"Bearer {token}"},
json={"name": "x", "expires_in_days": 7},
)
token_id = created.json()["id"]
revoke = await async_client.delete(
f"/api/v1/auth/tokens/{token_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert revoke.status_code == 204
# Now gone from the listing.
listing = await async_client.get("/api/v1/auth/tokens", headers={"Authorization": f"Bearer {token}"})
assert all(t["id"] != token_id for t in listing.json())
async def test_admin_can_revoke_any_users_token(self, async_client: AsyncClient):
admin_token = await _setup_admin(async_client, suffix="_revoke_any")
await _create_user(async_client, admin_token, "bob_revoke")
bob_token = await _login(async_client, "bob_revoke")
created = await async_client.post(
"/api/v1/auth/tokens",
headers={"Authorization": f"Bearer {bob_token}"},
json={"name": "bobs", "expires_in_days": 7},
)
token_id = created.json()["id"]
admin_revoke = await async_client.delete(
f"/api/v1/auth/tokens/{token_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert admin_revoke.status_code == 204
async def test_non_owner_non_admin_cannot_revoke(self, async_client: AsyncClient):
admin_token = await _setup_admin(async_client, suffix="_revoke_other")
await _create_user(async_client, admin_token, "alice_attack")
await _create_user(async_client, admin_token, "bob_target")
bob_token = await _login(async_client, "bob_target")
alice_token = await _login(async_client, "alice_attack")
created = await async_client.post(
"/api/v1/auth/tokens",
headers={"Authorization": f"Bearer {bob_token}"},
json={"name": "bobs", "expires_in_days": 7},
)
token_id = created.json()["id"]
forbidden = await async_client.delete(
f"/api/v1/auth/tokens/{token_id}",
headers={"Authorization": f"Bearer {alice_token}"},
)
assert forbidden.status_code == 403
async def test_revoke_unknown_id_404(self, async_client: AsyncClient):
token = await _setup_admin(async_client, suffix="_revoke_unknown")
response = await async_client.delete(
"/api/v1/auth/tokens/99999",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Auth fall-through: ``verify_camera_stream_token`` accepts both kinds
# ---------------------------------------------------------------------------
# The full /camera/stream HTTP integration would need a real ffmpeg / printer
# socket to keep the StreamingResponse alive. Verifying the auth dependency
# directly is a stronger check anyway: the route's only auth job is to call
# ``verify_camera_stream_token``, which is what these tests exercise.
class TestCameraStreamTokenVerification:
async def test_long_lived_token_verifies_via_camera_stream_path(self, async_client: AsyncClient):
"""A freshly minted long-lived token must pass the same dependency
the camera-stream route uses, after the ephemeral path would have
rejected it.
"""
from backend.app.core.auth import verify_camera_stream_token
token = await _setup_admin(async_client, suffix="_verify_long")
created = await async_client.post(
"/api/v1/auth/tokens",
headers={"Authorization": f"Bearer {token}"},
json={"name": "kiosk", "expires_in_days": 90},
)
long_lived = created.json()["token"]
assert await verify_camera_stream_token(long_lived) is True
async def test_revoked_long_lived_token_fails_camera_stream_check(self, async_client: AsyncClient):
from backend.app.core.auth import verify_camera_stream_token
token = await _setup_admin(async_client, suffix="_verify_revoke")
created = await async_client.post(
"/api/v1/auth/tokens",
headers={"Authorization": f"Bearer {token}"},
json={"name": "kiosk", "expires_in_days": 30},
)
long_lived = created.json()["token"]
token_id = created.json()["id"]
await async_client.delete(
f"/api/v1/auth/tokens/{token_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert await verify_camera_stream_token(long_lived) is False
async def test_garbage_token_fails_camera_stream_check(self, async_client: AsyncClient):
from backend.app.core.auth import verify_camera_stream_token
await _setup_admin(async_client, suffix="_verify_garbage")
assert await verify_camera_stream_token("bblt_aaaaaaaa_garbage") is False
assert await verify_camera_stream_token("not-a-real-token") is False

View file

@ -0,0 +1,243 @@
"""Unit tests for the long-lived camera-token service (#1108).
Drives the service directly against a real SQLAlchemy session so the
hash/lookup/expiry/revoke logic is exercised end-to-end with no HTTP.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from backend.app.models.long_lived_token import LongLivedToken
from backend.app.models.user import User
from backend.app.services.long_lived_tokens import (
ALLOWED_SCOPES,
MAX_TOKEN_LIFETIME_DAYS,
create_token,
list_all_tokens,
list_user_tokens,
revoke_token,
verify_token,
)
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def alice(db_session) -> User:
user = User(
username="alice",
email="alice@example.test",
password_hash="x",
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def bob(db_session) -> User:
user = User(
username="bob",
email="bob@example.test",
password_hash="x",
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------
async def test_create_returns_plaintext_once_and_stores_hash(db_session, alice: User):
"""Create returns the plaintext token; the DB only stores its hash."""
created = await create_token(
db_session,
user_id=alice.id,
name="Home Assistant",
expires_in_days=30,
)
assert created.plaintext.startswith("bblt_")
assert created.record.id is not None
assert created.record.user_id == alice.id
assert created.record.name == "Home Assistant"
assert created.record.scope == "camera_stream"
assert created.record.lookup_prefix in created.plaintext
# Hash never matches plaintext.
assert created.record.secret_hash != created.plaintext
# Expiry roughly 30 days from now (allow a few seconds of clock drift).
delta = created.record.expires_at - datetime.utcnow()
assert timedelta(days=29, hours=23) < delta < timedelta(days=30, minutes=1)
async def test_create_rejects_zero_or_negative_expiry(db_session, alice: User):
"""Issue #1108 explicitly forbids ``expire_in: 0``."""
with pytest.raises(ValueError, match="positive"):
await create_token(db_session, user_id=alice.id, name="x", expires_in_days=0)
with pytest.raises(ValueError, match="positive"):
await create_token(db_session, user_id=alice.id, name="x", expires_in_days=-5)
async def test_create_rejects_expiry_above_policy_cap(db_session, alice: User):
"""Above the 365-day ceiling → reject. UI layer also clamps but the
service is the canonical guard.
"""
with pytest.raises(ValueError, match="exceeds policy maximum"):
await create_token(
db_session,
user_id=alice.id,
name="x",
expires_in_days=MAX_TOKEN_LIFETIME_DAYS + 1,
)
async def test_create_rejects_unsupported_scope(db_session, alice: User):
"""V1 only allows ``camera_stream``."""
assert {"camera_stream"} == set(ALLOWED_SCOPES)
with pytest.raises(ValueError, match="unsupported scope"):
await create_token(
db_session,
user_id=alice.id,
name="x",
expires_in_days=7,
scope="anything_else",
)
async def test_create_rejects_blank_or_oversize_name(db_session, alice: User):
with pytest.raises(ValueError, match="name is required"):
await create_token(db_session, user_id=alice.id, name=" ", expires_in_days=7)
with pytest.raises(ValueError, match="100"):
await create_token(db_session, user_id=alice.id, name="x" * 101, expires_in_days=7)
# ---------------------------------------------------------------------------
# Verify
# ---------------------------------------------------------------------------
async def test_verify_happy_path_returns_record_and_updates_last_used(db_session, alice: User):
created = await create_token(db_session, user_id=alice.id, name="Frigate", expires_in_days=7)
assert created.record.last_used_at is None
record = await verify_token(db_session, created.plaintext)
assert record is not None
assert record.id == created.record.id
assert record.last_used_at is not None
async def test_verify_returns_none_for_garbage_token(db_session, alice: User):
await create_token(db_session, user_id=alice.id, name="x", expires_in_days=7)
assert await verify_token(db_session, "not-a-real-token") is None
assert await verify_token(db_session, "bblt_short") is None
# Wrong prefix entirely.
assert await verify_token(db_session, "abc_12345678_zzz") is None
async def test_verify_returns_none_for_expired_token(db_session, alice: User):
created = await create_token(db_session, user_id=alice.id, name="x", expires_in_days=1)
# Force expiry into the past.
created.record.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
await db_session.commit()
assert await verify_token(db_session, created.plaintext) is None
async def test_verify_returns_none_for_revoked_token(db_session, alice: User):
created = await create_token(db_session, user_id=alice.id, name="x", expires_in_days=7)
revoked = await revoke_token(db_session, created.record.id)
assert revoked is True
assert await verify_token(db_session, created.plaintext) is None
async def test_verify_returns_none_when_scope_mismatched(db_session, alice: User):
"""A camera_stream-scoped token must NOT validate against any other scope.
No other scopes exist today, but if/when they do, this guard prevents a
camera token from being accepted by, say, a control endpoint.
"""
created = await create_token(db_session, user_id=alice.id, name="x", expires_in_days=7)
assert await verify_token(db_session, created.plaintext, scope="other") is None
async def test_verify_does_not_collide_across_users_with_same_prefix(db_session, alice: User, bob: User, monkeypatch):
"""If two tokens happened to land on the same lookup_prefix, only the
one whose hash matches must verify. We force the collision by patching
the token-part generator and asserting verify returns the right record.
"""
from backend.app.services import long_lived_tokens
real = long_lived_tokens._generate_token_parts
sequence = iter(["aliceaaa", "bobbbbbb"])
def _fixed_prefix():
# First call (alice's token) gets the real generator output but with
# the prefix forced to a known value.
plaintext, _, hash_input = real()
prefix = next(sequence)
# Splice the forced prefix into the plaintext + hash_input.
new_plaintext = "bblt_" + prefix + plaintext[len("bblt_") + 8 :]
return new_plaintext, prefix, new_plaintext
monkeypatch.setattr(long_lived_tokens, "_generate_token_parts", _fixed_prefix)
a = await create_token(db_session, user_id=alice.id, name="a", expires_in_days=7)
b = await create_token(db_session, user_id=bob.id, name="b", expires_in_days=7)
assert a.record.lookup_prefix != b.record.lookup_prefix # sanity
# Cross-verify: alice's plaintext must only match alice's record.
assert (await verify_token(db_session, a.plaintext)).id == a.record.id
assert (await verify_token(db_session, b.plaintext)).id == b.record.id
# ---------------------------------------------------------------------------
# List + revoke
# ---------------------------------------------------------------------------
async def test_list_user_tokens_returns_only_owners_active_tokens(db_session, alice: User, bob: User):
a1 = await create_token(db_session, user_id=alice.id, name="a1", expires_in_days=7)
await create_token(db_session, user_id=alice.id, name="a2", expires_in_days=7)
await create_token(db_session, user_id=bob.id, name="b1", expires_in_days=7)
await revoke_token(db_session, a1.record.id)
alice_tokens = await list_user_tokens(db_session, alice.id)
names = {t.name for t in alice_tokens}
assert names == {"a2"} # a1 revoked, b1 belongs to bob
async def test_list_all_tokens_returns_every_active_token(db_session, alice: User, bob: User):
await create_token(db_session, user_id=alice.id, name="a", expires_in_days=7)
b = await create_token(db_session, user_id=bob.id, name="b", expires_in_days=7)
await revoke_token(db_session, b.record.id)
all_tokens = await list_all_tokens(db_session)
names = {t.name for t in all_tokens}
assert "a" in names
assert "b" not in names # revoked excluded
async def test_revoke_is_idempotent(db_session, alice: User):
created = await create_token(db_session, user_id=alice.id, name="x", expires_in_days=7)
assert await revoke_token(db_session, created.record.id) is True
# Second revoke is a no-op (returns False, never raises).
assert await revoke_token(db_session, created.record.id) is False
async def test_revoke_unknown_id_returns_false(db_session):
assert await revoke_token(db_session, 99_999) is False

View file

@ -205,6 +205,7 @@ function App() {
<Route path="notifications" element={<NotificationsPage />} />
<Route path="gcode-viewer" element={<GCodeViewerPage />} />
<Route path="external/:id" element={<ExternalLinkPage />} />
<Route path="camera-tokens" element={<Navigate to="/settings?tab=apikeys#card-camera-tokens" replace />} />
</Route>
</Routes>
</BrowserRouter>

View file

@ -0,0 +1,203 @@
/**
* Frontend tests for the Camera API Tokens page (#1108).
*
* Coverage:
* - List populates the "My tokens" table.
* - Create flow shows the plaintext exactly once in a copy modal.
* - Days input is clamped to the 365-day cap.
* - Revoke triggers the confirm prompt and refreshes the list.
* - Listing endpoints never return the plaintext (`token` field is null in the
* refreshed view; covered indirectly via the create-then-refresh flow).
*/
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { render } from '../utils';
import { server } from '../mocks/server';
import CameraTokensPage from '../../pages/CameraTokensPage';
function token(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 1,
user_id: 7,
name: 'Home Assistant',
scope: 'camera_stream',
lookup_prefix: 'abcd1234',
created_at: '2026-04-01T10:00:00Z',
expires_at: '2026-07-01T10:00:00Z',
last_used_at: null,
token: null,
...overrides,
};
}
afterEach(() => {
server.resetHandlers();
vi.restoreAllMocks();
});
describe('CameraTokensPage', () => {
it('renders the user\'s tokens', async () => {
server.use(
http.get('*/api/v1/auth/tokens', ({ request }) => {
// No `user_id` query → caller's own tokens.
const url = new URL(request.url);
if (url.searchParams.has('user_id')) {
return HttpResponse.json([]);
}
return HttpResponse.json([token({ id: 1, name: 'Home Assistant' })]);
}),
);
render(<CameraTokensPage />);
expect(await screen.findByText('Home Assistant')).toBeInTheDocument();
expect(screen.getByText('abcd1234…')).toBeInTheDocument();
});
it('shows the empty state when the user has no tokens', async () => {
server.use(
http.get('*/api/v1/auth/tokens', () => HttpResponse.json([])),
http.get('*/api/v1/auth/tokens/all', () => HttpResponse.json([])),
http.get('*/api/v1/users/', () => HttpResponse.json([])),
);
render(<CameraTokensPage />);
// Auth-disabled test environment treats user as admin → "No tokens yet"
// renders once per panel (My tokens + admin view).
await waitFor(() =>
expect(screen.getAllByText(/no tokens yet/i).length).toBeGreaterThan(0),
);
});
it('creates a token and displays the plaintext exactly once', async () => {
let getCount = 0;
server.use(
http.get('*/api/v1/auth/tokens', () => {
getCount += 1;
// First load = empty, post-create reload = the new row WITHOUT the
// plaintext (the listing API never returns it).
return HttpResponse.json(
getCount === 1 ? [] : [token({ id: 42, name: 'My Frigate', token: null })],
);
}),
http.post('*/api/v1/auth/tokens', async ({ request }) => {
const body = await request.json();
expect(body).toMatchObject({
name: 'My Frigate',
expires_in_days: 90,
scope: 'camera_stream',
});
return HttpResponse.json(
token({ id: 42, name: 'My Frigate', token: 'bblt_abcd1234_secretsecretsecretsecretsecret' }),
{ status: 201 },
);
}),
);
const user = userEvent.setup();
render(<CameraTokensPage />);
await screen.findByText(/no tokens yet/i);
await user.type(screen.getByLabelText(/token name/i), 'My Frigate');
await user.click(screen.getByRole('button', { name: /^create$/i }));
// Plaintext shown once in the modal.
expect(
await screen.findByText('bblt_abcd1234_secretsecretsecretsecretsecret'),
).toBeInTheDocument();
expect(screen.getByText(/only time this token will be visible/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /i've saved it/i }));
// After dismissing, the listing reload shows the row but NOT the plaintext.
expect(await screen.findByText('My Frigate')).toBeInTheDocument();
expect(
screen.queryByText('bblt_abcd1234_secretsecretsecretsecretsecret'),
).not.toBeInTheDocument();
});
it('clamps the days input to the 365-day policy cap', async () => {
server.use(
http.get('*/api/v1/auth/tokens', () => HttpResponse.json([])),
);
const user = userEvent.setup();
render(<CameraTokensPage />);
await screen.findByText(/no tokens yet/i);
const daysInput = screen.getByLabelText(/days until expiry/i) as HTMLInputElement;
await user.clear(daysInput);
await user.type(daysInput, '500');
expect(Number(daysInput.value)).toBe(365);
});
it('revokes a token after confirming in the styled modal', async () => {
let revoked = false;
server.use(
http.get('*/api/v1/auth/tokens', () =>
HttpResponse.json(revoked ? [] : [token({ id: 9, name: 'kiosk' })]),
),
// Auth-disabled test env treats the user as admin, so the page also
// calls /tokens/all and /users/. Stub them out so the refresh path
// doesn't try to hit unmocked endpoints.
http.get('*/api/v1/auth/tokens/all', () => HttpResponse.json([])),
http.get('*/api/v1/users/', () => HttpResponse.json([])),
http.delete('*/api/v1/auth/tokens/9', () => {
revoked = true;
return new HttpResponse(null, { status: 204 });
}),
);
const user = userEvent.setup();
render(<CameraTokensPage />);
await screen.findByText('kiosk');
// Open the confirm modal.
await user.click(screen.getByRole('button', { name: /revoke/i }));
// Modal shows the token name and a Cancel + Revoke pair.
const dialog = await screen.findByRole('dialog');
expect(dialog).toHaveTextContent(/kiosk/);
// Confirm — scope to the dialog so we don't match the row's revoke
// button still rendered in the table behind the modal.
await user.click(within(dialog).getByRole('button', { name: /^revoke$/i }));
await waitFor(() => {
expect(screen.queryByText('kiosk')).not.toBeInTheDocument();
// "No tokens yet" appears once for "My tokens" and (in admin mode) once
// for the all-users panel — at least one match is sufficient.
expect(screen.getAllByText(/no tokens yet/i).length).toBeGreaterThan(0);
});
});
it('does not revoke when the user cancels in the modal', async () => {
let revokeCalled = false;
server.use(
http.get('*/api/v1/auth/tokens', () =>
HttpResponse.json([token({ id: 9, name: 'kiosk' })]),
),
http.get('*/api/v1/auth/tokens/all', () => HttpResponse.json([])),
http.get('*/api/v1/users/', () => HttpResponse.json([])),
http.delete('*/api/v1/auth/tokens/9', () => {
revokeCalled = true;
return new HttpResponse(null, { status: 204 });
}),
);
const user = userEvent.setup();
render(<CameraTokensPage />);
await screen.findByText('kiosk');
await user.click(screen.getByRole('button', { name: /revoke/i }));
const dialog = await screen.findByRole('dialog');
await user.click(within(dialog).getByRole('button', { name: /cancel/i }));
// Modal closed, listing untouched, DELETE never sent.
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(screen.getByText('kiosk')).toBeInTheDocument();
expect(revokeCalled).toBe(false);
});
});

View file

@ -112,6 +112,21 @@ async function request<T>(
return await response.json();
}
// Long-lived camera-stream tokens (#1108). The `token` field is populated
// only on the create response — listing endpoints set it to null because
// the plaintext value is shown to the user exactly once.
export interface LongLivedCameraToken {
id: number;
user_id: number;
name: string;
scope: 'camera_stream';
lookup_prefix: string;
created_at: string;
expires_at: string;
last_used_at: string | null;
token: string | null;
}
// Printer types
export interface Printer {
id: number;
@ -4275,6 +4290,21 @@ export const api = {
// Camera
getCameraStreamToken: () =>
request<{ token: string }>('/printers/camera/stream-token', { method: 'POST' }),
// Long-lived camera-stream tokens (#1108)
createLongLivedCameraToken: (payload: { name: string; expires_in_days: number }) =>
request<LongLivedCameraToken>('/auth/tokens', {
method: 'POST',
body: JSON.stringify({ ...payload, scope: 'camera_stream' }),
}),
listMyLongLivedCameraTokens: () =>
request<LongLivedCameraToken[]>('/auth/tokens'),
listAllLongLivedCameraTokens: () =>
request<LongLivedCameraToken[]>('/auth/tokens/all'),
listLongLivedCameraTokensForUser: (userId: number) =>
request<LongLivedCameraToken[]>(`/auth/tokens?user_id=${userId}`),
revokeLongLivedCameraToken: (tokenId: number) =>
request<void>(`/auth/tokens/${tokenId}`, { method: 'DELETE' }),
getCameraStreamUrl: (printerId: number, fps = 10) =>
withStreamToken(`${API_BASE}/printers/${printerId}/camera/stream?fps=${fps}`),
getCameraSnapshotUrl: (printerId: number) =>

View file

@ -5265,4 +5265,55 @@ export default {
runNow: 'Archive jetzt löschen',
saveFailed: 'Einstellungen konnten nicht gespeichert werden.',
},
cameraTokens: {
title: 'Camera API Tokens',
navTitle: 'Camera API tokens',
description:
'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.',
loading: 'Loading…',
confirmRevoke: {
title: 'Revoke this token?',
body: 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
cancel: 'Cancel',
confirm: 'Revoke',
},
create: {
title: 'Create new token',
nameLabel: 'Token name',
namePlaceholder: 'e.g. Home Assistant',
daysLabel: 'Days until expiry',
submit: 'Create',
hint:
'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
},
created: {
title: 'Token created — copy it now',
warning:
'This is the only time this token will be visible. After you close this dialog you can never view it again.',
copy: 'Copy',
dismiss: "I've saved it",
},
list: {
myTitle: 'My tokens',
allTitle: 'All users (admin view)',
empty: 'No tokens yet.',
name: 'Name',
owner: 'Owner',
prefix: 'Prefix',
created: 'Created',
expires: 'Expires',
lastUsed: 'Last used',
revoke: 'Revoke',
expired: 'Expired',
},
toast: {
created: 'Token created',
createFailed: 'Failed to create token',
revoked: 'Token revoked',
revokeFailed: 'Failed to revoke token',
loadFailed: 'Failed to load tokens',
copied: 'Copied to clipboard',
copyFailed: 'Copy failed — select and copy manually',
},
},
};

View file

@ -5273,4 +5273,55 @@ export default {
runNow: 'Purge archives now',
saveFailed: 'Could not save auto-purge settings.',
},
cameraTokens: {
title: 'Camera API Tokens',
navTitle: 'Camera API tokens',
description:
'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.',
loading: 'Loading…',
confirmRevoke: {
title: 'Revoke this token?',
body: 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
cancel: 'Cancel',
confirm: 'Revoke',
},
create: {
title: 'Create new token',
nameLabel: 'Token name',
namePlaceholder: 'e.g. Home Assistant',
daysLabel: 'Days until expiry',
submit: 'Create',
hint:
'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
},
created: {
title: 'Token created — copy it now',
warning:
'This is the only time this token will be visible. After you close this dialog you can never view it again.',
copy: 'Copy',
dismiss: "I've saved it",
},
list: {
myTitle: 'My tokens',
allTitle: 'All users (admin view)',
empty: 'No tokens yet.',
name: 'Name',
owner: 'Owner',
prefix: 'Prefix',
created: 'Created',
expires: 'Expires',
lastUsed: 'Last used',
revoke: 'Revoke',
expired: 'Expired',
},
toast: {
created: 'Token created',
createFailed: 'Failed to create token',
revoked: 'Token revoked',
revokeFailed: 'Failed to revoke token',
loadFailed: 'Failed to load tokens',
copied: 'Copied to clipboard',
copyFailed: 'Copy failed — select and copy manually',
},
},
};

View file

@ -5177,4 +5177,55 @@ export default {
runNow: 'Purge archives now',
saveFailed: 'Could not save auto-purge settings.',
},
cameraTokens: {
title: 'Camera API Tokens',
navTitle: 'Camera API tokens',
description:
'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.',
loading: 'Loading…',
confirmRevoke: {
title: 'Revoke this token?',
body: 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
cancel: 'Cancel',
confirm: 'Revoke',
},
create: {
title: 'Create new token',
nameLabel: 'Token name',
namePlaceholder: 'e.g. Home Assistant',
daysLabel: 'Days until expiry',
submit: 'Create',
hint:
'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
},
created: {
title: 'Token created — copy it now',
warning:
'This is the only time this token will be visible. After you close this dialog you can never view it again.',
copy: 'Copy',
dismiss: "I've saved it",
},
list: {
myTitle: 'My tokens',
allTitle: 'All users (admin view)',
empty: 'No tokens yet.',
name: 'Name',
owner: 'Owner',
prefix: 'Prefix',
created: 'Created',
expires: 'Expires',
lastUsed: 'Last used',
revoke: 'Revoke',
expired: 'Expired',
},
toast: {
created: 'Token created',
createFailed: 'Failed to create token',
revoked: 'Token revoked',
revokeFailed: 'Failed to revoke token',
loadFailed: 'Failed to load tokens',
copied: 'Copied to clipboard',
copyFailed: 'Copy failed — select and copy manually',
},
},
};

View file

@ -5176,4 +5176,55 @@ export default {
runNow: 'Purge archives now',
saveFailed: 'Could not save auto-purge settings.',
},
cameraTokens: {
title: 'Camera API Tokens',
navTitle: 'Camera API tokens',
description:
'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.',
loading: 'Loading…',
confirmRevoke: {
title: 'Revoke this token?',
body: 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
cancel: 'Cancel',
confirm: 'Revoke',
},
create: {
title: 'Create new token',
nameLabel: 'Token name',
namePlaceholder: 'e.g. Home Assistant',
daysLabel: 'Days until expiry',
submit: 'Create',
hint:
'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
},
created: {
title: 'Token created — copy it now',
warning:
'This is the only time this token will be visible. After you close this dialog you can never view it again.',
copy: 'Copy',
dismiss: "I've saved it",
},
list: {
myTitle: 'My tokens',
allTitle: 'All users (admin view)',
empty: 'No tokens yet.',
name: 'Name',
owner: 'Owner',
prefix: 'Prefix',
created: 'Created',
expires: 'Expires',
lastUsed: 'Last used',
revoke: 'Revoke',
expired: 'Expired',
},
toast: {
created: 'Token created',
createFailed: 'Failed to create token',
revoked: 'Token revoked',
revokeFailed: 'Failed to revoke token',
loadFailed: 'Failed to load tokens',
copied: 'Copied to clipboard',
copyFailed: 'Copy failed — select and copy manually',
},
},
};

View file

@ -5215,4 +5215,55 @@ export default {
runNow: 'Purge archives now',
saveFailed: 'Could not save auto-purge settings.',
},
cameraTokens: {
title: 'Camera API Tokens',
navTitle: 'Camera API tokens',
description:
'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.',
loading: 'Loading…',
confirmRevoke: {
title: 'Revoke this token?',
body: 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
cancel: 'Cancel',
confirm: 'Revoke',
},
create: {
title: 'Create new token',
nameLabel: 'Token name',
namePlaceholder: 'e.g. Home Assistant',
daysLabel: 'Days until expiry',
submit: 'Create',
hint:
'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
},
created: {
title: 'Token created — copy it now',
warning:
'This is the only time this token will be visible. After you close this dialog you can never view it again.',
copy: 'Copy',
dismiss: "I've saved it",
},
list: {
myTitle: 'My tokens',
allTitle: 'All users (admin view)',
empty: 'No tokens yet.',
name: 'Name',
owner: 'Owner',
prefix: 'Prefix',
created: 'Created',
expires: 'Expires',
lastUsed: 'Last used',
revoke: 'Revoke',
expired: 'Expired',
},
toast: {
created: 'Token created',
createFailed: 'Failed to create token',
revoked: 'Token revoked',
revokeFailed: 'Failed to revoke token',
loadFailed: 'Failed to load tokens',
copied: 'Copied to clipboard',
copyFailed: 'Copy failed — select and copy manually',
},
},
};

View file

@ -5190,4 +5190,55 @@ export default {
runNow: 'Purge archives now',
saveFailed: 'Could not save auto-purge settings.',
},
cameraTokens: {
title: 'Camera API Tokens',
navTitle: 'Camera API tokens',
description:
'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.',
loading: 'Loading…',
confirmRevoke: {
title: 'Revoke this token?',
body: 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
cancel: 'Cancel',
confirm: 'Revoke',
},
create: {
title: 'Create new token',
nameLabel: 'Token name',
namePlaceholder: 'e.g. Home Assistant',
daysLabel: 'Days until expiry',
submit: 'Create',
hint:
'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
},
created: {
title: 'Token created — copy it now',
warning:
'This is the only time this token will be visible. After you close this dialog you can never view it again.',
copy: 'Copy',
dismiss: "I've saved it",
},
list: {
myTitle: 'My tokens',
allTitle: 'All users (admin view)',
empty: 'No tokens yet.',
name: 'Name',
owner: 'Owner',
prefix: 'Prefix',
created: 'Created',
expires: 'Expires',
lastUsed: 'Last used',
revoke: 'Revoke',
expired: 'Expired',
},
toast: {
created: 'Token created',
createFailed: 'Failed to create token',
revoked: 'Token revoked',
revokeFailed: 'Failed to revoke token',
loadFailed: 'Failed to load tokens',
copied: 'Copied to clipboard',
copyFailed: 'Copy failed — select and copy manually',
},
},
};

View file

@ -5254,4 +5254,55 @@ export default {
runNow: 'Purge archives now',
saveFailed: 'Could not save auto-purge settings.',
},
cameraTokens: {
title: 'Camera API Tokens',
navTitle: 'Camera API tokens',
description:
'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.',
loading: 'Loading…',
confirmRevoke: {
title: 'Revoke this token?',
body: 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
cancel: 'Cancel',
confirm: 'Revoke',
},
create: {
title: 'Create new token',
nameLabel: 'Token name',
namePlaceholder: 'e.g. Home Assistant',
daysLabel: 'Days until expiry',
submit: 'Create',
hint:
'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
},
created: {
title: 'Token created — copy it now',
warning:
'This is the only time this token will be visible. After you close this dialog you can never view it again.',
copy: 'Copy',
dismiss: "I've saved it",
},
list: {
myTitle: 'My tokens',
allTitle: 'All users (admin view)',
empty: 'No tokens yet.',
name: 'Name',
owner: 'Owner',
prefix: 'Prefix',
created: 'Created',
expires: 'Expires',
lastUsed: 'Last used',
revoke: 'Revoke',
expired: 'Expired',
},
toast: {
created: 'Token created',
createFailed: 'Failed to create token',
revoked: 'Token revoked',
revokeFailed: 'Failed to revoke token',
loadFailed: 'Failed to load tokens',
copied: 'Copied to clipboard',
copyFailed: 'Copy failed — select and copy manually',
},
},
};

View file

@ -5254,4 +5254,55 @@ export default {
runNow: 'Purge archives now',
saveFailed: 'Could not save auto-purge settings.',
},
cameraTokens: {
title: 'Camera API Tokens',
navTitle: 'Camera API tokens',
description:
'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.',
loading: 'Loading…',
confirmRevoke: {
title: 'Revoke this token?',
body: 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
cancel: 'Cancel',
confirm: 'Revoke',
},
create: {
title: 'Create new token',
nameLabel: 'Token name',
namePlaceholder: 'e.g. Home Assistant',
daysLabel: 'Days until expiry',
submit: 'Create',
hint:
'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
},
created: {
title: 'Token created — copy it now',
warning:
'This is the only time this token will be visible. After you close this dialog you can never view it again.',
copy: 'Copy',
dismiss: "I've saved it",
},
list: {
myTitle: 'My tokens',
allTitle: 'All users (admin view)',
empty: 'No tokens yet.',
name: 'Name',
owner: 'Owner',
prefix: 'Prefix',
created: 'Created',
expires: 'Expires',
lastUsed: 'Last used',
revoke: 'Revoke',
expired: 'Expired',
},
toast: {
created: 'Token created',
createFailed: 'Failed to create token',
revoked: 'Token revoked',
revokeFailed: 'Failed to revoke token',
loadFailed: 'Failed to load tokens',
copied: 'Copied to clipboard',
copyFailed: 'Copy failed — select and copy manually',
},
},
};

View file

@ -0,0 +1,492 @@
/**
* Long-lived camera-stream tokens (#1108).
*
* Exports two surfaces:
*
* - ``CameraTokensSection`` the actual list+create+revoke UI. Designed to
* drop into Settings API Keys (or any other host card) without page
* chrome of its own.
*
* - ``CameraTokensPage`` (default export) a thin wrapper that puts the
* section inside a standalone page layout. Kept around so direct
* navigation to ``/camera-tokens`` keeps working for anyone who has
* bookmarked it, but the canonical entry point is the Settings tab.
*
* The plaintext token is shown EXACTLY ONCE at create time inside a copy-
* to-clipboard modal. Listings only ever show metadata.
*/
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Copy, Plus, Trash2, AlertTriangle } from 'lucide-react';
import { api, type LongLivedCameraToken } from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
const DEFAULT_LIFETIME_DAYS = 90;
const MAX_LIFETIME_DAYS = 365;
function formatDate(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleString();
}
function isExpired(iso: string): boolean {
return new Date(iso).getTime() < Date.now();
}
interface CreateTokenFormProps {
onCreated: (token: LongLivedCameraToken) => void;
}
function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const [name, setName] = useState('');
const [days, setDays] = useState<number>(DEFAULT_LIFETIME_DAYS);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setSubmitting(true);
try {
const created = await api.createLongLivedCameraToken({
name: name.trim(),
expires_in_days: days,
});
onCreated(created);
setName('');
setDays(DEFAULT_LIFETIME_DAYS);
showToast(t('cameraTokens.toast.created', 'Token created'));
} catch (err) {
showToast(
err instanceof Error ? err.message : t('cameraTokens.toast.createFailed', 'Failed to create token'),
'error',
);
} finally {
setSubmitting(false);
}
};
return (
<form
onSubmit={handleSubmit}
className="bg-bambu-dark-secondary rounded-lg p-4 mb-6 border border-bambu-dark-tertiary"
>
<h3 className="text-base font-semibold text-white mb-3">
{t('cameraTokens.create.title', 'Create new token')}
</h3>
<div className="grid gap-3 md:grid-cols-[1fr_140px_auto]">
<input
type="text"
maxLength={100}
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('cameraTokens.create.namePlaceholder', 'e.g. Home Assistant')}
className="px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
aria-label={t('cameraTokens.create.nameLabel', 'Token name')}
/>
<input
type="number"
min={1}
max={MAX_LIFETIME_DAYS}
required
value={days}
onChange={(e) => {
const next = Number(e.target.value);
// Clamp client-side too — backend will also enforce, but a clear
// hard cap in the input matches the policy and avoids confusing
// 400s on submit.
setDays(Math.min(Math.max(next, 1), MAX_LIFETIME_DAYS));
}}
className="px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
aria-label={t('cameraTokens.create.daysLabel', 'Days until expiry')}
/>
<button
type="submit"
disabled={submitting || !name.trim()}
className="flex items-center gap-2 px-4 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4" />
{t('cameraTokens.create.submit', 'Create')}
</button>
</div>
<p className="text-xs text-bambu-gray mt-2">
{t(
'cameraTokens.create.hint',
'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
)}
</p>
</form>
);
}
interface ConfirmRevokeModalProps {
token: LongLivedCameraToken;
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmRevokeModal({ token, onConfirm, onCancel }: ConfirmRevokeModalProps) {
const { t } = useTranslation();
return (
<div
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
role="dialog"
aria-modal="true"
>
<div className="bg-bambu-dark-secondary rounded-lg p-6 max-w-md w-full border border-red-500/40">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="w-6 h-6 text-red-400 flex-shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-white">
{t('cameraTokens.confirmRevoke.title', 'Revoke this token?')}
</h2>
<p className="text-sm text-bambu-gray mt-1">
{t(
'cameraTokens.confirmRevoke.body',
'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
{ name: token.name },
)}
</p>
</div>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
>
{t('cameraTokens.confirmRevoke.cancel', 'Cancel')}
</button>
<button
type="button"
onClick={onConfirm}
className="px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600"
>
{t('cameraTokens.confirmRevoke.confirm', 'Revoke')}
</button>
</div>
</div>
</div>
);
}
interface JustCreatedModalProps {
token: LongLivedCameraToken;
onClose: () => void;
}
function JustCreatedModal({ token, onClose }: JustCreatedModalProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const plaintext = token.token ?? '';
const handleCopy = async () => {
if (!plaintext) return;
try {
// Modern clipboard API requires a secure context (HTTPS or localhost).
// Fall back to a hidden textarea + execCommand so users on plain HTTP
// (LAN deployments) can still copy the token.
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(plaintext);
} else {
const ta = document.createElement('textarea');
ta.value = plaintext;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
try {
ta.select();
document.execCommand('copy');
} finally {
document.body.removeChild(ta);
}
}
showToast(t('cameraTokens.toast.copied', 'Copied to clipboard'));
} catch {
showToast(t('cameraTokens.toast.copyFailed', 'Copy failed — select and copy manually'), 'error');
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-bambu-dark-secondary rounded-lg p-6 max-w-2xl w-full border border-bambu-green/40">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="w-6 h-6 text-yellow-400 flex-shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-white">
{t('cameraTokens.created.title', 'Token created — copy it now')}
</h2>
<p className="text-sm text-bambu-gray mt-1">
{t(
'cameraTokens.created.warning',
'This is the only time this token will be visible. After you close this dialog you can never view it again.',
)}
</p>
</div>
</div>
<div className="flex items-center gap-2 mb-4">
<code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
{plaintext}
</code>
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
>
<Copy className="w-4 h-4" />
{t('cameraTokens.created.copy', 'Copy')}
</button>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
>
{t('cameraTokens.created.dismiss', "I've saved it")}
</button>
</div>
</div>
</div>
);
}
interface TokenRowProps {
token: LongLivedCameraToken;
showOwner?: boolean;
ownerLabel?: string;
onRevoke: (id: number) => Promise<void>;
}
function TokenRow({ token, showOwner, ownerLabel, onRevoke }: TokenRowProps) {
const { t } = useTranslation();
const expired = isExpired(token.expires_at);
return (
<tr className="border-b border-bambu-dark-tertiary last:border-b-0">
<td className="py-3 px-3 text-white">{token.name}</td>
{showOwner && <td className="py-3 px-3 text-bambu-gray">{ownerLabel}</td>}
<td className="py-3 px-3 text-bambu-gray font-mono text-xs">{token.lookup_prefix}</td>
<td className="py-3 px-3 text-bambu-gray">{formatDate(token.created_at)}</td>
<td className={`py-3 px-3 ${expired ? 'text-red-400' : 'text-bambu-gray'}`}>
{formatDate(token.expires_at)}
{expired && (
<span className="ml-2 px-2 py-0.5 text-xs bg-red-500/20 text-red-300 rounded">
{t('cameraTokens.list.expired', 'Expired')}
</span>
)}
</td>
<td className="py-3 px-3 text-bambu-gray">{formatDate(token.last_used_at)}</td>
<td className="py-3 px-3 text-right">
<button
type="button"
onClick={() => onRevoke(token.id)}
className="inline-flex items-center gap-1 px-2 py-1 text-sm text-red-400 hover:text-red-300"
title={t('cameraTokens.list.revoke', 'Revoke')}
>
<Trash2 className="w-4 h-4" />
{t('cameraTokens.list.revoke', 'Revoke')}
</button>
</td>
</tr>
);
}
interface TokenTableProps {
tokens: LongLivedCameraToken[];
showOwner?: boolean;
userIdToName?: Map<number, string>;
onRevoke: (id: number) => Promise<void>;
emptyMessage: string;
}
function TokenTable({ tokens, showOwner, userIdToName, onRevoke, emptyMessage }: TokenTableProps) {
const { t } = useTranslation();
if (tokens.length === 0) {
return <p className="text-sm text-bambu-gray italic">{emptyMessage}</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-bambu-gray text-left border-b border-bambu-dark-tertiary">
<tr>
<th className="py-2 px-3 font-medium">{t('cameraTokens.list.name', 'Name')}</th>
{showOwner && <th className="py-2 px-3 font-medium">{t('cameraTokens.list.owner', 'Owner')}</th>}
<th className="py-2 px-3 font-medium">{t('cameraTokens.list.prefix', 'Prefix')}</th>
<th className="py-2 px-3 font-medium">{t('cameraTokens.list.created', 'Created')}</th>
<th className="py-2 px-3 font-medium">{t('cameraTokens.list.expires', 'Expires')}</th>
<th className="py-2 px-3 font-medium">{t('cameraTokens.list.lastUsed', 'Last used')}</th>
<th className="py-2 px-3" />
</tr>
</thead>
<tbody>
{tokens.map((tok) => (
<TokenRow
key={tok.id}
token={tok}
showOwner={showOwner}
ownerLabel={userIdToName?.get(tok.user_id) ?? `#${tok.user_id}`}
onRevoke={onRevoke}
/>
))}
</tbody>
</table>
</div>
);
}
/**
* The actual UI block: create form + my-tokens table + admin all-tokens table.
* Renders without any outer page chrome so it can be embedded inside
* Settings API Keys (the canonical home) or any other host card.
*/
export function CameraTokensSection() {
const { t } = useTranslation();
const { user, isAdmin } = useAuth();
const { showToast } = useToast();
const [myTokens, setMyTokens] = useState<LongLivedCameraToken[]>([]);
const [allTokens, setAllTokens] = useState<LongLivedCameraToken[]>([]);
const [userIdToName, setUserIdToName] = useState<Map<number, string>>(new Map());
const [loading, setLoading] = useState(true);
const [justCreated, setJustCreated] = useState<LongLivedCameraToken | null>(null);
const [pendingRevoke, setPendingRevoke] = useState<LongLivedCameraToken | null>(null);
const refresh = async () => {
setLoading(true);
try {
const mine = await api.listMyLongLivedCameraTokens();
setMyTokens(mine);
if (isAdmin) {
const all = await api.listAllLongLivedCameraTokens();
setAllTokens(all);
// Username lookup: best-effort from the users API. If it errors
// (e.g. permission missing for some reason), the table still renders
// with the numeric user_id as fallback.
try {
const users = await api.getUsers();
setUserIdToName(new Map(users.map((u: { id: number; username: string }) => [u.id, u.username])));
} catch {
setUserIdToName(new Map());
}
}
} catch (err) {
showToast(
err instanceof Error ? err.message : t('cameraTokens.toast.loadFailed', 'Failed to load tokens'),
'error',
);
} finally {
setLoading(false);
}
};
useEffect(() => {
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdmin]);
// Open the confirmation modal. The actual delete fires from
// ``confirmRevoke`` once the user clicks through.
const requestRevoke = async (id: number) => {
const target = [...myTokens, ...allTokens].find((tok) => tok.id === id);
if (target) {
setPendingRevoke(target);
}
};
const confirmRevoke = async () => {
if (!pendingRevoke) return;
const id = pendingRevoke.id;
setPendingRevoke(null);
try {
await api.revokeLongLivedCameraToken(id);
showToast(t('cameraTokens.toast.revoked', 'Token revoked'));
await refresh();
} catch (err) {
showToast(
err instanceof Error ? err.message : t('cameraTokens.toast.revokeFailed', 'Failed to revoke token'),
'error',
);
}
};
const otherUsersTokens = useMemo(
() => allTokens.filter((t) => t.user_id !== user?.id),
[allTokens, user?.id],
);
return (
<>
<p className="text-sm text-bambu-gray mb-4">
{t(
'cameraTokens.description',
'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.',
)}
</p>
<CreateTokenForm
onCreated={(token) => {
setJustCreated(token);
void refresh();
}}
/>
<div className="mb-6">
<h3 className="text-base font-semibold text-white mb-3">
{t('cameraTokens.list.myTitle', 'My tokens')}
</h3>
{loading ? (
<p className="text-sm text-bambu-gray">{t('cameraTokens.loading', 'Loading…')}</p>
) : (
<TokenTable
tokens={myTokens}
onRevoke={requestRevoke}
emptyMessage={t('cameraTokens.list.empty', 'No tokens yet.')}
/>
)}
</div>
{isAdmin && (
<div>
<h3 className="text-base font-semibold text-white mb-3">
{t('cameraTokens.list.allTitle', 'All users (admin view)')}
</h3>
<TokenTable
tokens={otherUsersTokens}
showOwner
userIdToName={userIdToName}
onRevoke={requestRevoke}
emptyMessage={t('cameraTokens.list.empty', 'No tokens yet.')}
/>
</div>
)}
{justCreated && (
<JustCreatedModal token={justCreated} onClose={() => setJustCreated(null)} />
)}
{pendingRevoke && (
<ConfirmRevokeModal
token={pendingRevoke}
onConfirm={() => void confirmRevoke()}
onCancel={() => setPendingRevoke(null)}
/>
)}
</>
);
}
export default function CameraTokensPage() {
const { t } = useTranslation();
return (
<div className="p-6 max-w-5xl mx-auto">
<h1 className="text-2xl font-bold text-white mb-2">
{t('cameraTokens.title', 'Camera API Tokens')}
</h1>
<CameraTokensSection />
</div>
);
}

View file

@ -8,6 +8,7 @@ import { formatDateOnly } from '../utils/date';
import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
import { CameraTokensSection } from './CameraTokensPage';
import { Collapsible } from '../components/Collapsible';
import { Button } from '../components/Button';
import { SmartPlugCard } from '../components/SmartPlugCard';
@ -75,6 +76,7 @@ registerSettingsSearch({ labelKey: 'settings.prometheusMetrics', tab: 'network',
registerSettingsSearch({ labelKey: 'settings.createNewApiKey', tab: 'apikeys', keywords: 'api key create permission scope', anchor: 'card-createapi' });
registerSettingsSearch({ labelKey: 'settings.webhookEndpoints', tab: 'apikeys', keywords: 'webhook endpoint post http', anchor: 'card-webhooks' });
registerSettingsSearch({ labelKey: 'settings.apiBrowser', tab: 'apikeys', keywords: 'api browser endpoint documentation test', anchor: 'card-apibrowser' });
registerSettingsSearch({ labelKey: 'cameraTokens.title', tab: 'apikeys', keywords: 'camera token long-lived home assistant frigate kiosk stream', anchor: 'card-camera-tokens' });
registerSettingsSearch({ labelKey: 'settings.tabs.virtualPrinter', tab: 'virtual-printer', keywords: 'virtual printer proxy archive slicer bambustudio orcaslicer ip bind', anchor: 'card-vp' });
registerSettingsSearch({ labelKey: 'settings.tabs.spoolbuddy', tab: 'spoolbuddy', keywords: 'spoolbuddy device scale nfc rfid kiosk unregister', anchor: 'card-spoolbuddy' });
registerSettingsSearch({ labelKey: 'settings.currentUser', tab: 'users', subTab: 'users', keywords: 'current user profile password change', anchor: 'card-currentuser' });
@ -3503,9 +3505,15 @@ export function SettingsPage() {
{/* API Keys Tab */}
{activeTab === 'apikeys' && (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{/* Left Column - API Keys Management */}
<div className={hasPermission('api_keys:read')
? 'grid grid-cols-1 xl:grid-cols-2 gap-4'
: 'grid grid-cols-1 gap-4'}>
{/* Left Column - API Keys Management. Admin-gated content
(webhook keys, webhook docs) is hidden from users without
api_keys:read; the Camera Tokens panel is always shown so
users with camera:view can self-manage their own tokens. */}
<div>
{hasPermission('api_keys:read') && <>
<div className="flex items-start justify-between gap-4 mb-6">
<div className="flex-1">
<h2 className="text-lg font-semibold text-white flex items-center gap-2" id="card-createapi">
@ -3774,10 +3782,27 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
</>}
{/* Long-lived camera-stream tokens (#1108) */}
<Card className="mt-6">
<CardHeader>
<h3 className="text-base font-semibold text-white flex items-center gap-2" id="card-camera-tokens">
<Video className="w-4 h-4 text-bambu-green" />
{t('cameraTokens.title', 'Camera API Tokens')}
</h3>
</CardHeader>
<CardContent>
<CameraTokensSection />
</CardContent>
</Card>
</div>
{/* Right Column - API Browser */}
<div>
{/* Right Column - API Browser. Hidden from users without
api_keys:read since the API Browser is the testing surface
for those keys; non-admins land in this tab only for the
Camera Tokens panel and don't need the browser. */}
{hasPermission('api_keys:read') && <div>
<div className="mb-6">
<h2 className="text-lg font-semibold text-white flex items-center gap-2" id="card-apibrowser">
<Globe className="w-5 h-5 text-bambu-green" />
@ -3806,7 +3831,7 @@ export function SettingsPage() {
</Card>
<APIBrowser apiKey={testApiKey} />
</div>
</div>}
</div>
)}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -26,8 +26,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-Dm505pTR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CgXLDG6B.css">
<script type="module" crossorigin src="/assets/index-Jv9LQKHt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-telVPl_h.css">
</head>
<body>
<div id="root"></div>