Quellcode durchsuchen

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.
maziggy vor 1 Monat
Ursprung
Commit
fcda728af4

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
CHANGELOG.md


+ 2 - 1
README.md

@@ -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)

+ 196 - 0
backend/app/api/routes/auth.py

@@ -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)

+ 15 - 2
backend/app/core/auth.py

@@ -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:

+ 1 - 0
backend/app/core/database.py

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

+ 2 - 0
backend/app/models/__init__.py

@@ -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",
 ]

+ 77 - 0
backend/app/models/long_lived_token.py

@@ -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}>"

+ 214 - 0
backend/app/services/long_lived_tokens.py

@@ -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

+ 321 - 0
backend/tests/integration/test_long_lived_tokens_api.py

@@ -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

+ 243 - 0
backend/tests/unit/services/test_long_lived_tokens.py

@@ -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

+ 1 - 0
frontend/src/App.tsx

@@ -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>

+ 203 - 0
frontend/src/__tests__/pages/CameraTokensPage.test.tsx

@@ -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);
+  });
+});

+ 30 - 0
frontend/src/api/client.ts

@@ -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) =>

+ 51 - 0
frontend/src/i18n/locales/de.ts

@@ -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',
+    },
+  },
 };

+ 51 - 0
frontend/src/i18n/locales/en.ts

@@ -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',
+    },
+  },
 };

+ 51 - 0
frontend/src/i18n/locales/fr.ts

@@ -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',
+    },
+  },
 };

+ 51 - 0
frontend/src/i18n/locales/it.ts

@@ -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',
+    },
+  },
 };

+ 51 - 0
frontend/src/i18n/locales/ja.ts

@@ -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',
+    },
+  },
 };

+ 51 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -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',
+    },
+  },
 };

+ 51 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -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',
+    },
+  },
 };

+ 51 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -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',
+    },
+  },
 };

+ 492 - 0
frontend/src/pages/CameraTokensPage.tsx

@@ -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>
+  );
+}

+ 30 - 5
frontend/src/pages/SettingsPage.tsx

@@ -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>
       )}
 

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
static/assets/index-Jv9LQKHt.js


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
static/assets/index-telVPl_h.css


+ 2 - 2
static/index.html

@@ -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>

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.