mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
#1108 — Long-lived camera-stream tokens for HA / Frigate / kiosks. Camera-only V1, hard 365-day cap (no infinite tokens), pbkdf2 hashed at rest, plaintext shown to user exactly once on creation. New "Camera API Tokens" panel under Settings → API Keys with self-service create/revoke, styled confirm modal, admin "All users" view for leak triage. Auth path: /camera/stream tries the existing 60-min ephemeral table first, falls through to the long-lived path. Indexed lookup_prefix keeps verify O(1) per token. Permission audit: gated the existing API-keys-CRUD + Webhook docs + API Browser content behind api_keys:read so non-admins with camera:view land on the API Keys tab and see only the Camera Tokens panel they actually have permission to use. Grid layout collapses to single column for non-admins. Tests: 29 new backend (15 service + 14 integration covering create/list/ revoke ownership rules, the auth fall-through, scope enforcement, prefix collisions) + 6 new frontend tests for the section UI including the new modal flow. All 77 backend tests + 21 frontend camera tests pass. Ruff clean (lint + format). Docs: README updated with fan-out + long-lived-token bullets. Wiki gets a new "Long-Lived Camera Tokens" section under features/camera.md (HA YAML example, security model, permission requirements, revoke flow). Website features.html gets the bullet under Camera Streaming. Also includes #1089 follow-up tweaks already merged in this branch: _stream_start_times.setdefault for accurate stream_uptime, subscribe() RuntimeError retry to close the grace-vs-subscribe race, atomic unsubscribe count via the iter_subscriber on_unsubscribe callback.
This commit is contained in:
parent
1e3ad697f2
commit
fcda728af4
26 changed files with 2660 additions and 431 deletions
|
|
@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
|
|||
## [0.2.4b1] - Unreleased
|
||||
|
||||
### Added
|
||||
- **Long-lived camera-stream tokens for HA / Frigate / kiosks** ([#1108](https://github.com/maziggy/bambuddy/issues/1108)) — The existing `?token=…` camera-stream tokens expire after 60 minutes which forced home-automation integrations (Home Assistant cards, Frigate, hallway kiosks) to either refresh on a cron or run with auth disabled. New self-service "Camera API Tokens" panel under **Settings → API Keys** (also reachable via the existing settings search box — type "camera token" / "frigate" / "home assistant") lets any user holding `camera:view` mint a long-lived token they can paste once and forget. Revoke uses Bambuddy's standard styled confirmation modal (no `window.confirm` browser default — same pattern as the rest of the app). Tokens are scoped strictly to camera streaming (no privilege escalation surface — no other endpoint accepts them), formatted `bblt_<8-char-prefix>_<32-char-secret>`, and stored as a pbkdf2 hash so even a DB dump can't replay them; the plaintext is shown to the user **exactly once** in a copy-to-clipboard modal (with a `document.execCommand('copy')` fallback for plain-HTTP LAN deployments where `navigator.clipboard` is gated by the secure-context requirement). Hard 365-day max — the issue's `expire_in: 0` (never) is explicitly rejected because an irrevocable infinite token is a footgun-by-design; UI defaults to 90 days, the cap is enforced both client-side (input clamp) and server-side (validation guard). Owners can revoke their own tokens; admins additionally see an "All users" view for leak triage and can revoke anyone's. The `/camera/stream?token=…` auth dependency tries the existing 60-min ephemeral row first (no behaviour change for the common browser case) and falls through to the long-lived path, so the SPA's existing camera flow is unaffected. Indexed `lookup_prefix` keeps verify O(1) per token even on large installs — pbkdf2 only runs against the one candidate row that matches the prefix, never the whole table. New `long_lived_tokens` table (separate from `auth_ephemeral_tokens` because the lifecycle is different — user-owned, named, revocable, hashed; and separate from `api_keys` because that one is for global webhooks with no user FK and a different permission shape). 15 unit tests covering create-validation/scope/expiry rules, verify happy/garbage/expired/revoked/scope-mismatch/prefix-collision paths, list-by-user vs list-all, idempotent revoke; 14 integration tests covering the create-once-then-listing-hides-plaintext contract, the 365-day cap, the auth gate, owner-vs-admin revoke ownership rules, and that the long-lived token verifies through the same camera-stream auth dependency the route uses (and that revoke immediately invalidates it). 6 frontend tests covering list render, empty state, create-then-shown-once flow, days-input clamp, revoke-with-confirm, and revoke-cancelled paths. New `cameraTokens.*` keys across all 8 locales (English fully translated; the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features).
|
||||
|
||||
- **Tailscale integration for virtual printers** (builds on [#1070](https://github.com/maziggy/bambuddy/issues/1070) by @legend813) — Opt-in per-VP Tailscale toggle brings each virtual printer into the tailnet, so it's reachable from any tailnet device over a private WireGuard tunnel without port forwarding or public exposure. When enabled, Bambuddy provisions a Let's Encrypt cert for the VP's MagicDNS hostname via `tailscale cert` and the MQTT/FTPS listeners serve it. **Slicer-side caveat worth knowing up front:** both Bambu Studio and OrcaSlicer only accept IP addresses (not hostnames) in the Add Printer dialog, so the LE cert's hostname validation doesn't apply — users still need the Bambuddy CA imported into the slicer, same as LAN mode. The practical benefit here is the private tunnel (remote access without DDNS / port forwarding / public exposure), not cert-import elimination. Default is opt-out (toggle off) so users without Tailscale don't see cert-provisioning attempts or log noise. When a user flips the toggle on a host without a working Tailscale binary, the backend returns `409 tailscale_not_available` and the UI reverts + surfaces a specific toast pointing at the setup steps (install Tailscale → `tailscale up` → `tailscale set --operator=<user>` → enable HTTPS in the tailnet admin console). Docker image now ships the `tailscale` CLI pre-installed; users wire up by uncommenting the `/var/run/tailscale/tailscaled.sock` volume mount in `docker-compose.yml`. The MagicDNS hostname is surfaced on the VP card with a copy-to-clipboard button (modern `navigator.clipboard` in secure contexts, `document.execCommand` fallback for plain-HTTP contexts with textarea cleanup in `finally`). Cert renewal runs daily in-process and restarts only the affected VP's TLS listeners. New i18n keys `virtualPrinter.tailscaleDisabled.{title,description}` + `virtualPrinter.toast.{tailscaleNotAvailable,copyFailed}` across all 8 locales with full translations. 3 new backend integration tests for the 409 guard, 2 unit tests for the `_cancel_restart_task` self-await guard, 4 unit tests for the settings-dedupe migration, and 3 new frontend tests for the clipboard fallback path. Thanks to @legend813 for the original opt-out toggle PR that this was built on top of.
|
||||
|
||||
- **Library Trash Bin + Admin Bulk Purge + Auto-Purge** ([#1008](https://github.com/maziggy/bambuddy/issues/1008)) — Library files now move to a trash bin on delete instead of being hard-deleted from disk, with a configurable retention window (default 30 days) before a background sweeper permanently removes them. Admins get a new "Purge old" action on the File Manager that shows a live preview of count + total size before moving every file older than *N* days (with an opt-in toggle for never-printed files, on by default) into the trash in one shot. A new **Auto-purge** setting in Settings → File Manager runs the same purge automatically on a 24-hour cadence when enabled — files still go to Trash first so the retention window remains the safety net; default-off so existing installs don't surprise anyone. Both the per-user delete flow and the admin bulk purge go through the same trash — regular users see and manage their own trashed files; admins see everyone's. External (linked) files bypass trash and keep the original hard-delete behaviour since their bytes aren't under Bambuddy's control. New `library:purge` permission gates the admin operations; retention is adjustable inline on the Trash page for admins. Adds nullable `deleted_at` column on `library_files` with an index (dialect-aware migration: `DATETIME` on SQLite, `TIMESTAMP` on PostgreSQL, since raw `DATETIME` is SQLite-only syntax); every `LibraryFile` query site now routes through a new `LibraryFile.active()` classmethod so trashed rows can't leak into listings, print dispatch, MakerWorld dedupe, or stats. 17 new backend integration tests + 8 new frontend component/page tests; localised across all 8 UI languages. Thanks to @cadtoolbox for the proposal and the follow-up answers that tightened the spec.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ async def init_db():
|
|||
kprofile_note,
|
||||
library,
|
||||
local_preset,
|
||||
long_lived_token,
|
||||
maintenance,
|
||||
notification,
|
||||
notification_template,
|
||||
|
|
|
|||
|
|
@ -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
backend/app/models/long_lived_token.py
Normal file
77
backend/app/models/long_lived_token.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Long-lived camera-stream tokens (#1108).
|
||||
|
||||
Issue #1108: the existing 60-minute ``camera_stream`` ephemeral tokens are
|
||||
too short-lived for home-automation integrations (Home Assistant cards,
|
||||
Frigate, kiosks), which expect a token they can paste once and forget.
|
||||
|
||||
Why a separate table from ``AuthEphemeralToken``:
|
||||
|
||||
- These are user-owned, named, and revocable from the UI — different
|
||||
lifecycle from ephemeral / single-use tokens.
|
||||
- Hashed at rest (bcrypt). Ephemeral tokens are stored as raw strings
|
||||
because their short TTL caps the impact of a DB read; a long-lived
|
||||
token must survive a DB dump unscathed.
|
||||
|
||||
Why a separate table from ``api_keys``:
|
||||
|
||||
- ``api_keys`` is for webhook integrations and has no ``user_id`` FK
|
||||
(the keys are global). Long-lived camera tokens are explicitly per-user
|
||||
so the UI can show "your tokens" and so a leak can be traced to one user.
|
||||
- Different permission shape (``api_keys`` carries can_queue / can_control
|
||||
flags; long-lived tokens are pure read-only camera streaming).
|
||||
|
||||
V1 hard rules:
|
||||
|
||||
- ``expires_at`` is required (the issue's ``expire_in: 0 = never`` was
|
||||
rejected — irrevocable infinite tokens are a footgun).
|
||||
- 365-day max — enforced in the create route, not the DB, so a future
|
||||
policy change is just a config bump.
|
||||
- Scope column exists today ("camera_stream" is the only valid value)
|
||||
to keep the door open for other long-lived scopes later without a
|
||||
schema migration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class LongLivedToken(Base):
|
||||
"""Per-user, hashed-at-rest, revocable token for long-running camera viewers."""
|
||||
|
||||
__tablename__ = "long_lived_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# User-given label — "Home Assistant", "Kitchen kiosk", etc.
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
# Public lookup prefix — first 8 chars of the secret part. Indexed so
|
||||
# verify() can fetch one row instead of scanning + bcrypting all rows.
|
||||
# Format: ``bblt_<8-char-prefix>_<32-char-secret>``.
|
||||
lookup_prefix: Mapped[str] = mapped_column(String(8), nullable=False, index=True)
|
||||
# bcrypt hash of the 32-char secret part. Never stored or returned in plaintext.
|
||||
secret_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
# V1: only "camera_stream" is accepted. Column exists so future scopes
|
||||
# don't need a schema migration.
|
||||
scope: Mapped[str] = mapped_column(String(32), nullable=False, default="camera_stream")
|
||||
# Required — no infinite tokens. Capped at 365 days at create time.
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
# Updated on successful verify (rate-limited to once per minute per token
|
||||
# to avoid thrashing the DB on every MJPEG keep-alive read).
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
# Set when the user (or an admin) revokes; verify treats revoked == invalid.
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<LongLivedToken id={self.id} user_id={self.user_id} name={self.name!r} scope={self.scope}>"
|
||||
214
backend/app/services/long_lived_tokens.py
Normal file
214
backend/app/services/long_lived_tokens.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""Service layer for long-lived camera-stream tokens (#1108).
|
||||
|
||||
Token format: ``bblt_<8-char-prefix>_<32-char-secret>``.
|
||||
|
||||
- The full token is shown to the user **exactly once** at create time.
|
||||
- ``lookup_prefix`` (the 8-char middle part) is indexed and used to cheaply
|
||||
fetch the candidate row — at most one in practice — without scanning the
|
||||
whole table on every request.
|
||||
- ``secret_hash`` is a pbkdf2_sha256 hash of the full token (matching the
|
||||
rest of the codebase's password hashing). Even a DB dump can't be replayed
|
||||
against the camera endpoint.
|
||||
- ``last_used_at`` is updated on successful verify, but rate-limited to once
|
||||
per minute per token so an MJPEG keep-alive doesn't write to the DB on
|
||||
every chunk.
|
||||
- ``revoked_at`` set → verify returns False; admins or the owning user can
|
||||
flip it.
|
||||
|
||||
Maximum lifetime is 365 days (issue #1108 explicitly rejected "infinite"
|
||||
tokens — a leaked permanent token would be irrevocable footgun-by-design).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import get_password_hash, verify_password
|
||||
from backend.app.models.long_lived_token import LongLivedToken
|
||||
|
||||
# Issue #1108 hard cap. Bump here if policy changes — UI default is shorter
|
||||
# (90 days) and the create route enforces this ceiling.
|
||||
MAX_TOKEN_LIFETIME_DAYS = 365
|
||||
|
||||
# Only V1 scope. Adding "snapshot" or "control" later means adding a value
|
||||
# to this tuple and an `if scope == ...` branch in the route, no schema work.
|
||||
ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream"})
|
||||
|
||||
# Don't write to last_used_at more than once per minute per token. MJPEG
|
||||
# streams call verify() at most once per fetch (the browser holds the
|
||||
# connection open), but snapshots may rapid-fire — this caps DB churn.
|
||||
_LAST_USED_DEBOUNCE = timedelta(minutes=1)
|
||||
|
||||
# Token format constants — kept in one place so format changes are localized.
|
||||
_TOKEN_PREFIX = "bblt_"
|
||||
_LOOKUP_LEN = 8
|
||||
_SECRET_LEN = 32 # urlsafe characters → ~190 bits of entropy
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreatedToken:
|
||||
"""Returned to the route on create. ``plaintext`` is shown to the user
|
||||
exactly once and never persisted; only ``record`` survives in the DB.
|
||||
"""
|
||||
|
||||
record: LongLivedToken
|
||||
plaintext: str
|
||||
|
||||
|
||||
def _generate_token_parts() -> tuple[str, str, str]:
|
||||
"""Return ``(plaintext, lookup_prefix, hash_input)``.
|
||||
|
||||
``hash_input`` is the same string we hand to pbkdf2 so verify() can
|
||||
produce a matching hash from the user-submitted token.
|
||||
|
||||
The prefix is hex on purpose — ``token_urlsafe`` can emit ``_`` which
|
||||
would collide with the ``bblt_<prefix>_<secret>`` format separator and
|
||||
break the parser. Hex is fine for a non-secret indexed lookup column;
|
||||
the security comes from the 32-char ``token_urlsafe`` secret part.
|
||||
"""
|
||||
lookup_prefix = secrets.token_hex(_LOOKUP_LEN // 2) # 4 bytes → 8 hex chars
|
||||
secret_part = secrets.token_urlsafe(48).replace("_", "").replace("-", "")[:_SECRET_LEN]
|
||||
plaintext = f"{_TOKEN_PREFIX}{lookup_prefix}_{secret_part}"
|
||||
return plaintext, lookup_prefix, plaintext
|
||||
|
||||
|
||||
def _parse_token(token: str) -> tuple[str, str] | None:
|
||||
"""Pull ``(lookup_prefix, full_token)`` from a submitted string.
|
||||
|
||||
Returns None if the format doesn't match — short-circuits the DB lookup
|
||||
on garbage / wrong-format inputs.
|
||||
"""
|
||||
if not token.startswith(_TOKEN_PREFIX):
|
||||
return None
|
||||
rest = token[len(_TOKEN_PREFIX) :]
|
||||
sep = rest.find("_")
|
||||
if sep != _LOOKUP_LEN:
|
||||
return None
|
||||
lookup_prefix = rest[:_LOOKUP_LEN]
|
||||
return lookup_prefix, token
|
||||
|
||||
|
||||
def _is_expired(record: LongLivedToken, now: datetime) -> bool:
|
||||
expires = record.expires_at
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
return expires <= now
|
||||
|
||||
|
||||
async def create_token(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
name: str,
|
||||
expires_in_days: int,
|
||||
scope: str = "camera_stream",
|
||||
) -> CreatedToken:
|
||||
"""Mint a new long-lived token. Caller is responsible for permission checks.
|
||||
|
||||
Raises ValueError if ``expires_in_days`` exceeds the policy cap or
|
||||
``scope`` is not in ``ALLOWED_SCOPES``. The route translates these into
|
||||
a 400 with the offending field.
|
||||
"""
|
||||
if scope not in ALLOWED_SCOPES:
|
||||
raise ValueError(f"unsupported scope: {scope!r}")
|
||||
if expires_in_days <= 0:
|
||||
raise ValueError("expires_in_days must be positive (#1108: no infinite tokens)")
|
||||
if expires_in_days > MAX_TOKEN_LIFETIME_DAYS:
|
||||
raise ValueError(f"expires_in_days exceeds policy maximum of {MAX_TOKEN_LIFETIME_DAYS}")
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise ValueError("name is required")
|
||||
if len(name) > 100:
|
||||
raise ValueError("name must be 100 chars or fewer")
|
||||
|
||||
plaintext, lookup_prefix, hash_input = _generate_token_parts()
|
||||
now = datetime.now(timezone.utc)
|
||||
record = LongLivedToken(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
lookup_prefix=lookup_prefix,
|
||||
secret_hash=get_password_hash(hash_input),
|
||||
scope=scope,
|
||||
expires_at=now + timedelta(days=expires_in_days),
|
||||
)
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return CreatedToken(record=record, plaintext=plaintext)
|
||||
|
||||
|
||||
async def verify_token(db: AsyncSession, token: str, *, scope: str = "camera_stream") -> LongLivedToken | None:
|
||||
"""Validate a token. Returns the matching record on success, None otherwise.
|
||||
|
||||
The bcrypt-style verify is the slow step (intentional — pbkdf2 by design),
|
||||
so we pre-filter by the indexed ``lookup_prefix`` to ensure the verify
|
||||
runs against at most one or two candidate rows.
|
||||
"""
|
||||
parsed = _parse_token(token)
|
||||
if parsed is None:
|
||||
return None
|
||||
lookup_prefix, full_token = parsed
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await db.execute(
|
||||
select(LongLivedToken).where(
|
||||
LongLivedToken.lookup_prefix == lookup_prefix,
|
||||
LongLivedToken.scope == scope,
|
||||
LongLivedToken.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
candidates = result.scalars().all()
|
||||
for record in candidates:
|
||||
if _is_expired(record, now):
|
||||
continue
|
||||
if not verify_password(full_token, record.secret_hash):
|
||||
continue
|
||||
# Record use, but rate-limit DB writes to keep MJPEG-keepalive cheap.
|
||||
last = record.last_used_at
|
||||
if last is None or _coerce_utc(last) + _LAST_USED_DEBOUNCE <= now:
|
||||
record.last_used_at = now
|
||||
await db.commit()
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_utc(dt: datetime) -> datetime:
|
||||
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
|
||||
|
||||
|
||||
async def list_user_tokens(db: AsyncSession, user_id: int) -> list[LongLivedToken]:
|
||||
"""All non-revoked tokens for a user, newest first. Includes expired ones
|
||||
(the UI shows them so the user can clean them up).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(LongLivedToken)
|
||||
.where(LongLivedToken.user_id == user_id, LongLivedToken.revoked_at.is_(None))
|
||||
.order_by(LongLivedToken.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def list_all_tokens(db: AsyncSession) -> list[LongLivedToken]:
|
||||
"""Admin view of every non-revoked token in the system, newest first."""
|
||||
result = await db.execute(
|
||||
select(LongLivedToken).where(LongLivedToken.revoked_at.is_(None)).order_by(LongLivedToken.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def revoke_token(db: AsyncSession, token_id: int) -> bool:
|
||||
"""Mark a token revoked. Returns True if a row was updated, False if the
|
||||
id didn't exist or was already revoked.
|
||||
"""
|
||||
result = await db.execute(select(LongLivedToken).where(LongLivedToken.id == token_id))
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None or record.revoked_at is not None:
|
||||
return False
|
||||
record.revoked_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return True
|
||||
321
backend/tests/integration/test_long_lived_tokens_api.py
Normal file
321
backend/tests/integration/test_long_lived_tokens_api.py
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
"""Integration tests for long-lived camera-stream token routes (#1108).
|
||||
|
||||
Cover the auth gates, ownership rules, max-lifetime cap, token-shown-once
|
||||
contract, and the camera-stream auth fall-through (the existing 60-min
|
||||
ephemeral path still works AND a long-lived token is also accepted).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _setup_admin(async_client: AsyncClient, *, suffix: str = "") -> str:
|
||||
"""Create the first admin and return their JWT."""
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": f"tokenadmin{suffix}",
|
||||
"admin_password": "AdminPass1!",
|
||||
},
|
||||
)
|
||||
login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": f"tokenadmin{suffix}", "password": "AdminPass1!"},
|
||||
)
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
async def _create_user(async_client: AsyncClient, admin_token: str, username: str, *, role: str = "user") -> int:
|
||||
"""Create a non-admin user via the admin API and return their id.
|
||||
|
||||
The user is assigned to the seeded "Viewers" group so they hold
|
||||
``CAMERA_VIEW`` — without that, regular users cannot create their own
|
||||
long-lived tokens (which is the same gate the existing 60-min ephemeral
|
||||
flow uses).
|
||||
"""
|
||||
# Fetch Viewers group id so the new user inherits CAMERA_VIEW.
|
||||
groups_resp = await async_client.get("/api/v1/groups/", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
viewers = next((g for g in groups_resp.json() if g["name"] == "Viewers"), None)
|
||||
assert viewers is not None, f"Viewers group not seeded: {groups_resp.text}"
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"username": username,
|
||||
"password": "UserPass1!",
|
||||
"role": role,
|
||||
"group_ids": [viewers["id"]],
|
||||
},
|
||||
)
|
||||
assert response.status_code in (200, 201), response.text
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
async def _login(async_client: AsyncClient, username: str) -> str:
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": username, "password": "UserPass1!"},
|
||||
)
|
||||
body = response.json()
|
||||
token = body.get("access_token")
|
||||
assert token, f"login for {username!r} returned no access_token: {body}"
|
||||
return token
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateLongLivedToken:
|
||||
async def test_create_returns_plaintext_token_exactly_once(self, async_client: AsyncClient):
|
||||
token = await _setup_admin(async_client, suffix="_create_once")
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"name": "Home Assistant", "expires_in_days": 30},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
body = response.json()
|
||||
assert body["token"].startswith("bblt_")
|
||||
assert body["name"] == "Home Assistant"
|
||||
assert body["scope"] == "camera_stream"
|
||||
assert body["lookup_prefix"]
|
||||
token_id = body["id"]
|
||||
|
||||
# Listing must NOT include the plaintext (shown-once contract).
|
||||
listing = await async_client.get(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert listing.status_code == 200
|
||||
listed = next((t for t in listing.json() if t["id"] == token_id), None)
|
||||
assert listed is not None
|
||||
assert listed["token"] is None # plaintext gone forever
|
||||
|
||||
async def test_create_rejects_expires_in_zero(self, async_client: AsyncClient):
|
||||
"""Issue #1108: ``expire_in: 0`` (never) is explicitly forbidden."""
|
||||
token = await _setup_admin(async_client, suffix="_zero_expire")
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"name": "x", "expires_in_days": 0},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "positive" in response.json()["detail"].lower()
|
||||
|
||||
async def test_create_rejects_above_max(self, async_client: AsyncClient):
|
||||
token = await _setup_admin(async_client, suffix="_above_max")
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"name": "x", "expires_in_days": 366},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "365" in response.json()["detail"]
|
||||
|
||||
async def test_create_requires_auth(self, async_client: AsyncClient):
|
||||
await _setup_admin(async_client, suffix="_unauth")
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
json={"name": "x", "expires_in_days": 7},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListLongLivedTokens:
|
||||
async def test_list_returns_only_callers_tokens_by_default(self, async_client: AsyncClient):
|
||||
admin_token = await _setup_admin(async_client, suffix="_list_default")
|
||||
bob_id = await _create_user(async_client, admin_token, "bob_list")
|
||||
bob_token = await _login(async_client, "bob_list")
|
||||
|
||||
# Each user creates one token.
|
||||
await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"name": "admins", "expires_in_days": 7},
|
||||
)
|
||||
await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {bob_token}"},
|
||||
json={"name": "bobs", "expires_in_days": 7},
|
||||
)
|
||||
|
||||
# Bob's listing should see only his.
|
||||
bob_listing = await async_client.get(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {bob_token}"},
|
||||
)
|
||||
names = {t["name"] for t in bob_listing.json()}
|
||||
assert names == {"bobs"}
|
||||
assert bob_id == bob_listing.json()[0]["user_id"]
|
||||
|
||||
async def test_admin_can_filter_by_user_id(self, async_client: AsyncClient):
|
||||
admin_token = await _setup_admin(async_client, suffix="_admin_filter")
|
||||
bob_id = await _create_user(async_client, admin_token, "bob_filter")
|
||||
bob_token = await _login(async_client, "bob_filter")
|
||||
await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {bob_token}"},
|
||||
json={"name": "bobs", "expires_in_days": 7},
|
||||
)
|
||||
|
||||
admin_view = await async_client.get(
|
||||
f"/api/v1/auth/tokens?user_id={bob_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert admin_view.status_code == 200
|
||||
names = {t["name"] for t in admin_view.json()}
|
||||
assert names == {"bobs"}
|
||||
|
||||
async def test_non_admin_cannot_see_other_users_tokens(self, async_client: AsyncClient):
|
||||
admin_token = await _setup_admin(async_client, suffix="_non_admin")
|
||||
await _create_user(async_client, admin_token, "alice_see")
|
||||
bob_id = await _create_user(async_client, admin_token, "bob_see")
|
||||
alice_token = await _login(async_client, "alice_see")
|
||||
|
||||
forbidden = await async_client.get(
|
||||
f"/api/v1/auth/tokens?user_id={bob_id}",
|
||||
headers={"Authorization": f"Bearer {alice_token}"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Revoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRevokeLongLivedToken:
|
||||
async def test_owner_can_revoke_own_token(self, async_client: AsyncClient):
|
||||
token = await _setup_admin(async_client, suffix="_revoke_own")
|
||||
created = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"name": "x", "expires_in_days": 7},
|
||||
)
|
||||
token_id = created.json()["id"]
|
||||
|
||||
revoke = await async_client.delete(
|
||||
f"/api/v1/auth/tokens/{token_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert revoke.status_code == 204
|
||||
|
||||
# Now gone from the listing.
|
||||
listing = await async_client.get("/api/v1/auth/tokens", headers={"Authorization": f"Bearer {token}"})
|
||||
assert all(t["id"] != token_id for t in listing.json())
|
||||
|
||||
async def test_admin_can_revoke_any_users_token(self, async_client: AsyncClient):
|
||||
admin_token = await _setup_admin(async_client, suffix="_revoke_any")
|
||||
await _create_user(async_client, admin_token, "bob_revoke")
|
||||
bob_token = await _login(async_client, "bob_revoke")
|
||||
created = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {bob_token}"},
|
||||
json={"name": "bobs", "expires_in_days": 7},
|
||||
)
|
||||
token_id = created.json()["id"]
|
||||
|
||||
admin_revoke = await async_client.delete(
|
||||
f"/api/v1/auth/tokens/{token_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert admin_revoke.status_code == 204
|
||||
|
||||
async def test_non_owner_non_admin_cannot_revoke(self, async_client: AsyncClient):
|
||||
admin_token = await _setup_admin(async_client, suffix="_revoke_other")
|
||||
await _create_user(async_client, admin_token, "alice_attack")
|
||||
await _create_user(async_client, admin_token, "bob_target")
|
||||
bob_token = await _login(async_client, "bob_target")
|
||||
alice_token = await _login(async_client, "alice_attack")
|
||||
|
||||
created = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {bob_token}"},
|
||||
json={"name": "bobs", "expires_in_days": 7},
|
||||
)
|
||||
token_id = created.json()["id"]
|
||||
|
||||
forbidden = await async_client.delete(
|
||||
f"/api/v1/auth/tokens/{token_id}",
|
||||
headers={"Authorization": f"Bearer {alice_token}"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
|
||||
async def test_revoke_unknown_id_404(self, async_client: AsyncClient):
|
||||
token = await _setup_admin(async_client, suffix="_revoke_unknown")
|
||||
response = await async_client.delete(
|
||||
"/api/v1/auth/tokens/99999",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth fall-through: ``verify_camera_stream_token`` accepts both kinds
|
||||
# ---------------------------------------------------------------------------
|
||||
# The full /camera/stream HTTP integration would need a real ffmpeg / printer
|
||||
# socket to keep the StreamingResponse alive. Verifying the auth dependency
|
||||
# directly is a stronger check anyway: the route's only auth job is to call
|
||||
# ``verify_camera_stream_token``, which is what these tests exercise.
|
||||
|
||||
|
||||
class TestCameraStreamTokenVerification:
|
||||
async def test_long_lived_token_verifies_via_camera_stream_path(self, async_client: AsyncClient):
|
||||
"""A freshly minted long-lived token must pass the same dependency
|
||||
the camera-stream route uses, after the ephemeral path would have
|
||||
rejected it.
|
||||
"""
|
||||
from backend.app.core.auth import verify_camera_stream_token
|
||||
|
||||
token = await _setup_admin(async_client, suffix="_verify_long")
|
||||
created = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"name": "kiosk", "expires_in_days": 90},
|
||||
)
|
||||
long_lived = created.json()["token"]
|
||||
|
||||
assert await verify_camera_stream_token(long_lived) is True
|
||||
|
||||
async def test_revoked_long_lived_token_fails_camera_stream_check(self, async_client: AsyncClient):
|
||||
from backend.app.core.auth import verify_camera_stream_token
|
||||
|
||||
token = await _setup_admin(async_client, suffix="_verify_revoke")
|
||||
created = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"name": "kiosk", "expires_in_days": 30},
|
||||
)
|
||||
long_lived = created.json()["token"]
|
||||
token_id = created.json()["id"]
|
||||
|
||||
await async_client.delete(
|
||||
f"/api/v1/auth/tokens/{token_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert await verify_camera_stream_token(long_lived) is False
|
||||
|
||||
async def test_garbage_token_fails_camera_stream_check(self, async_client: AsyncClient):
|
||||
from backend.app.core.auth import verify_camera_stream_token
|
||||
|
||||
await _setup_admin(async_client, suffix="_verify_garbage")
|
||||
assert await verify_camera_stream_token("bblt_aaaaaaaa_garbage") is False
|
||||
assert await verify_camera_stream_token("not-a-real-token") is False
|
||||
243
backend/tests/unit/services/test_long_lived_tokens.py
Normal file
243
backend/tests/unit/services/test_long_lived_tokens.py
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
"""Unit tests for the long-lived camera-token service (#1108).
|
||||
|
||||
Drives the service directly against a real SQLAlchemy session so the
|
||||
hash/lookup/expiry/revoke logic is exercised end-to-end with no HTTP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.models.long_lived_token import LongLivedToken
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.long_lived_tokens import (
|
||||
ALLOWED_SCOPES,
|
||||
MAX_TOKEN_LIFETIME_DAYS,
|
||||
create_token,
|
||||
list_all_tokens,
|
||||
list_user_tokens,
|
||||
revoke_token,
|
||||
verify_token,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def alice(db_session) -> User:
|
||||
user = User(
|
||||
username="alice",
|
||||
email="alice@example.test",
|
||||
password_hash="x",
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def bob(db_session) -> User:
|
||||
user = User(
|
||||
username="bob",
|
||||
email="bob@example.test",
|
||||
password_hash="x",
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_create_returns_plaintext_once_and_stores_hash(db_session, alice: User):
|
||||
"""Create returns the plaintext token; the DB only stores its hash."""
|
||||
created = await create_token(
|
||||
db_session,
|
||||
user_id=alice.id,
|
||||
name="Home Assistant",
|
||||
expires_in_days=30,
|
||||
)
|
||||
|
||||
assert created.plaintext.startswith("bblt_")
|
||||
assert created.record.id is not None
|
||||
assert created.record.user_id == alice.id
|
||||
assert created.record.name == "Home Assistant"
|
||||
assert created.record.scope == "camera_stream"
|
||||
assert created.record.lookup_prefix in created.plaintext
|
||||
# Hash never matches plaintext.
|
||||
assert created.record.secret_hash != created.plaintext
|
||||
# Expiry roughly 30 days from now (allow a few seconds of clock drift).
|
||||
delta = created.record.expires_at - datetime.utcnow()
|
||||
assert timedelta(days=29, hours=23) < delta < timedelta(days=30, minutes=1)
|
||||
|
||||
|
||||
async def test_create_rejects_zero_or_negative_expiry(db_session, alice: User):
|
||||
"""Issue #1108 explicitly forbids ``expire_in: 0``."""
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
await create_token(db_session, user_id=alice.id, name="x", expires_in_days=0)
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
await create_token(db_session, user_id=alice.id, name="x", expires_in_days=-5)
|
||||
|
||||
|
||||
async def test_create_rejects_expiry_above_policy_cap(db_session, alice: User):
|
||||
"""Above the 365-day ceiling → reject. UI layer also clamps but the
|
||||
service is the canonical guard.
|
||||
"""
|
||||
with pytest.raises(ValueError, match="exceeds policy maximum"):
|
||||
await create_token(
|
||||
db_session,
|
||||
user_id=alice.id,
|
||||
name="x",
|
||||
expires_in_days=MAX_TOKEN_LIFETIME_DAYS + 1,
|
||||
)
|
||||
|
||||
|
||||
async def test_create_rejects_unsupported_scope(db_session, alice: User):
|
||||
"""V1 only allows ``camera_stream``."""
|
||||
assert {"camera_stream"} == set(ALLOWED_SCOPES)
|
||||
with pytest.raises(ValueError, match="unsupported scope"):
|
||||
await create_token(
|
||||
db_session,
|
||||
user_id=alice.id,
|
||||
name="x",
|
||||
expires_in_days=7,
|
||||
scope="anything_else",
|
||||
)
|
||||
|
||||
|
||||
async def test_create_rejects_blank_or_oversize_name(db_session, alice: User):
|
||||
with pytest.raises(ValueError, match="name is required"):
|
||||
await create_token(db_session, user_id=alice.id, name=" ", expires_in_days=7)
|
||||
with pytest.raises(ValueError, match="100"):
|
||||
await create_token(db_session, user_id=alice.id, name="x" * 101, expires_in_days=7)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_verify_happy_path_returns_record_and_updates_last_used(db_session, alice: User):
|
||||
created = await create_token(db_session, user_id=alice.id, name="Frigate", expires_in_days=7)
|
||||
assert created.record.last_used_at is None
|
||||
|
||||
record = await verify_token(db_session, created.plaintext)
|
||||
assert record is not None
|
||||
assert record.id == created.record.id
|
||||
assert record.last_used_at is not None
|
||||
|
||||
|
||||
async def test_verify_returns_none_for_garbage_token(db_session, alice: User):
|
||||
await create_token(db_session, user_id=alice.id, name="x", expires_in_days=7)
|
||||
assert await verify_token(db_session, "not-a-real-token") is None
|
||||
assert await verify_token(db_session, "bblt_short") is None
|
||||
# Wrong prefix entirely.
|
||||
assert await verify_token(db_session, "abc_12345678_zzz") is None
|
||||
|
||||
|
||||
async def test_verify_returns_none_for_expired_token(db_session, alice: User):
|
||||
created = await create_token(db_session, user_id=alice.id, name="x", expires_in_days=1)
|
||||
# Force expiry into the past.
|
||||
created.record.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
await db_session.commit()
|
||||
assert await verify_token(db_session, created.plaintext) is None
|
||||
|
||||
|
||||
async def test_verify_returns_none_for_revoked_token(db_session, alice: User):
|
||||
created = await create_token(db_session, user_id=alice.id, name="x", expires_in_days=7)
|
||||
revoked = await revoke_token(db_session, created.record.id)
|
||||
assert revoked is True
|
||||
assert await verify_token(db_session, created.plaintext) is None
|
||||
|
||||
|
||||
async def test_verify_returns_none_when_scope_mismatched(db_session, alice: User):
|
||||
"""A camera_stream-scoped token must NOT validate against any other scope.
|
||||
|
||||
No other scopes exist today, but if/when they do, this guard prevents a
|
||||
camera token from being accepted by, say, a control endpoint.
|
||||
"""
|
||||
created = await create_token(db_session, user_id=alice.id, name="x", expires_in_days=7)
|
||||
assert await verify_token(db_session, created.plaintext, scope="other") is None
|
||||
|
||||
|
||||
async def test_verify_does_not_collide_across_users_with_same_prefix(db_session, alice: User, bob: User, monkeypatch):
|
||||
"""If two tokens happened to land on the same lookup_prefix, only the
|
||||
one whose hash matches must verify. We force the collision by patching
|
||||
the token-part generator and asserting verify returns the right record.
|
||||
"""
|
||||
from backend.app.services import long_lived_tokens
|
||||
|
||||
real = long_lived_tokens._generate_token_parts
|
||||
|
||||
sequence = iter(["aliceaaa", "bobbbbbb"])
|
||||
|
||||
def _fixed_prefix():
|
||||
# First call (alice's token) gets the real generator output but with
|
||||
# the prefix forced to a known value.
|
||||
plaintext, _, hash_input = real()
|
||||
prefix = next(sequence)
|
||||
# Splice the forced prefix into the plaintext + hash_input.
|
||||
new_plaintext = "bblt_" + prefix + plaintext[len("bblt_") + 8 :]
|
||||
return new_plaintext, prefix, new_plaintext
|
||||
|
||||
monkeypatch.setattr(long_lived_tokens, "_generate_token_parts", _fixed_prefix)
|
||||
|
||||
a = await create_token(db_session, user_id=alice.id, name="a", expires_in_days=7)
|
||||
b = await create_token(db_session, user_id=bob.id, name="b", expires_in_days=7)
|
||||
assert a.record.lookup_prefix != b.record.lookup_prefix # sanity
|
||||
|
||||
# Cross-verify: alice's plaintext must only match alice's record.
|
||||
assert (await verify_token(db_session, a.plaintext)).id == a.record.id
|
||||
assert (await verify_token(db_session, b.plaintext)).id == b.record.id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List + revoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_list_user_tokens_returns_only_owners_active_tokens(db_session, alice: User, bob: User):
|
||||
a1 = await create_token(db_session, user_id=alice.id, name="a1", expires_in_days=7)
|
||||
await create_token(db_session, user_id=alice.id, name="a2", expires_in_days=7)
|
||||
await create_token(db_session, user_id=bob.id, name="b1", expires_in_days=7)
|
||||
await revoke_token(db_session, a1.record.id)
|
||||
|
||||
alice_tokens = await list_user_tokens(db_session, alice.id)
|
||||
names = {t.name for t in alice_tokens}
|
||||
assert names == {"a2"} # a1 revoked, b1 belongs to bob
|
||||
|
||||
|
||||
async def test_list_all_tokens_returns_every_active_token(db_session, alice: User, bob: User):
|
||||
await create_token(db_session, user_id=alice.id, name="a", expires_in_days=7)
|
||||
b = await create_token(db_session, user_id=bob.id, name="b", expires_in_days=7)
|
||||
await revoke_token(db_session, b.record.id)
|
||||
|
||||
all_tokens = await list_all_tokens(db_session)
|
||||
names = {t.name for t in all_tokens}
|
||||
assert "a" in names
|
||||
assert "b" not in names # revoked excluded
|
||||
|
||||
|
||||
async def test_revoke_is_idempotent(db_session, alice: User):
|
||||
created = await create_token(db_session, user_id=alice.id, name="x", expires_in_days=7)
|
||||
assert await revoke_token(db_session, created.record.id) is True
|
||||
# Second revoke is a no-op (returns False, never raises).
|
||||
assert await revoke_token(db_session, created.record.id) is False
|
||||
|
||||
|
||||
async def test_revoke_unknown_id_returns_false(db_session):
|
||||
assert await revoke_token(db_session, 99_999) is False
|
||||
|
|
@ -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
frontend/src/__tests__/pages/CameraTokensPage.test.tsx
Normal file
203
frontend/src/__tests__/pages/CameraTokensPage.test.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* Frontend tests for the Camera API Tokens page (#1108).
|
||||
*
|
||||
* Coverage:
|
||||
* - List populates the "My tokens" table.
|
||||
* - Create flow shows the plaintext exactly once in a copy modal.
|
||||
* - Days input is clamped to the 365-day cap.
|
||||
* - Revoke triggers the confirm prompt and refreshes the list.
|
||||
* - Listing endpoints never return the plaintext (`token` field is null in the
|
||||
* refreshed view; covered indirectly via the create-then-refresh flow).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render } from '../utils';
|
||||
import { server } from '../mocks/server';
|
||||
import CameraTokensPage from '../../pages/CameraTokensPage';
|
||||
|
||||
function token(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
user_id: 7,
|
||||
name: 'Home Assistant',
|
||||
scope: 'camera_stream',
|
||||
lookup_prefix: 'abcd1234',
|
||||
created_at: '2026-04-01T10:00:00Z',
|
||||
expires_at: '2026-07-01T10:00:00Z',
|
||||
last_used_at: null,
|
||||
token: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('CameraTokensPage', () => {
|
||||
it('renders the user\'s tokens', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/auth/tokens', ({ request }) => {
|
||||
// No `user_id` query → caller's own tokens.
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.has('user_id')) {
|
||||
return HttpResponse.json([]);
|
||||
}
|
||||
return HttpResponse.json([token({ id: 1, name: 'Home Assistant' })]);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<CameraTokensPage />);
|
||||
|
||||
expect(await screen.findByText('Home Assistant')).toBeInTheDocument();
|
||||
expect(screen.getByText('abcd1234…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state when the user has no tokens', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/auth/tokens', () => HttpResponse.json([])),
|
||||
http.get('*/api/v1/auth/tokens/all', () => HttpResponse.json([])),
|
||||
http.get('*/api/v1/users/', () => HttpResponse.json([])),
|
||||
);
|
||||
|
||||
render(<CameraTokensPage />);
|
||||
|
||||
// Auth-disabled test environment treats user as admin → "No tokens yet"
|
||||
// renders once per panel (My tokens + admin view).
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText(/no tokens yet/i).length).toBeGreaterThan(0),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a token and displays the plaintext exactly once', async () => {
|
||||
let getCount = 0;
|
||||
server.use(
|
||||
http.get('*/api/v1/auth/tokens', () => {
|
||||
getCount += 1;
|
||||
// First load = empty, post-create reload = the new row WITHOUT the
|
||||
// plaintext (the listing API never returns it).
|
||||
return HttpResponse.json(
|
||||
getCount === 1 ? [] : [token({ id: 42, name: 'My Frigate', token: null })],
|
||||
);
|
||||
}),
|
||||
http.post('*/api/v1/auth/tokens', async ({ request }) => {
|
||||
const body = await request.json();
|
||||
expect(body).toMatchObject({
|
||||
name: 'My Frigate',
|
||||
expires_in_days: 90,
|
||||
scope: 'camera_stream',
|
||||
});
|
||||
return HttpResponse.json(
|
||||
token({ id: 42, name: 'My Frigate', token: 'bblt_abcd1234_secretsecretsecretsecretsecret' }),
|
||||
{ status: 201 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CameraTokensPage />);
|
||||
|
||||
await screen.findByText(/no tokens yet/i);
|
||||
await user.type(screen.getByLabelText(/token name/i), 'My Frigate');
|
||||
await user.click(screen.getByRole('button', { name: /^create$/i }));
|
||||
|
||||
// Plaintext shown once in the modal.
|
||||
expect(
|
||||
await screen.findByText('bblt_abcd1234_secretsecretsecretsecretsecret'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/only time this token will be visible/i)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /i've saved it/i }));
|
||||
|
||||
// After dismissing, the listing reload shows the row but NOT the plaintext.
|
||||
expect(await screen.findByText('My Frigate')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('bblt_abcd1234_secretsecretsecretsecretsecret'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clamps the days input to the 365-day policy cap', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/auth/tokens', () => HttpResponse.json([])),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CameraTokensPage />);
|
||||
await screen.findByText(/no tokens yet/i);
|
||||
|
||||
const daysInput = screen.getByLabelText(/days until expiry/i) as HTMLInputElement;
|
||||
await user.clear(daysInput);
|
||||
await user.type(daysInput, '500');
|
||||
expect(Number(daysInput.value)).toBe(365);
|
||||
});
|
||||
|
||||
it('revokes a token after confirming in the styled modal', async () => {
|
||||
let revoked = false;
|
||||
server.use(
|
||||
http.get('*/api/v1/auth/tokens', () =>
|
||||
HttpResponse.json(revoked ? [] : [token({ id: 9, name: 'kiosk' })]),
|
||||
),
|
||||
// Auth-disabled test env treats the user as admin, so the page also
|
||||
// calls /tokens/all and /users/. Stub them out so the refresh path
|
||||
// doesn't try to hit unmocked endpoints.
|
||||
http.get('*/api/v1/auth/tokens/all', () => HttpResponse.json([])),
|
||||
http.get('*/api/v1/users/', () => HttpResponse.json([])),
|
||||
http.delete('*/api/v1/auth/tokens/9', () => {
|
||||
revoked = true;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CameraTokensPage />);
|
||||
|
||||
await screen.findByText('kiosk');
|
||||
// Open the confirm modal.
|
||||
await user.click(screen.getByRole('button', { name: /revoke/i }));
|
||||
// Modal shows the token name and a Cancel + Revoke pair.
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(dialog).toHaveTextContent(/kiosk/);
|
||||
// Confirm — scope to the dialog so we don't match the row's revoke
|
||||
// button still rendered in the table behind the modal.
|
||||
await user.click(within(dialog).getByRole('button', { name: /^revoke$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('kiosk')).not.toBeInTheDocument();
|
||||
// "No tokens yet" appears once for "My tokens" and (in admin mode) once
|
||||
// for the all-users panel — at least one match is sufficient.
|
||||
expect(screen.getAllByText(/no tokens yet/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not revoke when the user cancels in the modal', async () => {
|
||||
let revokeCalled = false;
|
||||
server.use(
|
||||
http.get('*/api/v1/auth/tokens', () =>
|
||||
HttpResponse.json([token({ id: 9, name: 'kiosk' })]),
|
||||
),
|
||||
http.get('*/api/v1/auth/tokens/all', () => HttpResponse.json([])),
|
||||
http.get('*/api/v1/users/', () => HttpResponse.json([])),
|
||||
http.delete('*/api/v1/auth/tokens/9', () => {
|
||||
revokeCalled = true;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CameraTokensPage />);
|
||||
|
||||
await screen.findByText('kiosk');
|
||||
await user.click(screen.getByRole('button', { name: /revoke/i }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
await user.click(within(dialog).getByRole('button', { name: /cancel/i }));
|
||||
|
||||
// Modal closed, listing untouched, DELETE never sent.
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('kiosk')).toBeInTheDocument();
|
||||
expect(revokeCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
frontend/src/pages/CameraTokensPage.tsx
Normal file
492
frontend/src/pages/CameraTokensPage.tsx
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
/**
|
||||
* Long-lived camera-stream tokens (#1108).
|
||||
*
|
||||
* Exports two surfaces:
|
||||
*
|
||||
* - ``CameraTokensSection`` — the actual list+create+revoke UI. Designed to
|
||||
* drop into Settings → API Keys (or any other host card) without page
|
||||
* chrome of its own.
|
||||
*
|
||||
* - ``CameraTokensPage`` (default export) — a thin wrapper that puts the
|
||||
* section inside a standalone page layout. Kept around so direct
|
||||
* navigation to ``/camera-tokens`` keeps working for anyone who has
|
||||
* bookmarked it, but the canonical entry point is the Settings tab.
|
||||
*
|
||||
* The plaintext token is shown EXACTLY ONCE at create time inside a copy-
|
||||
* to-clipboard modal. Listings only ever show metadata.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Copy, Plus, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import { api, type LongLivedCameraToken } from '../api/client';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const DEFAULT_LIFETIME_DAYS = 90;
|
||||
const MAX_LIFETIME_DAYS = 365;
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function isExpired(iso: string): boolean {
|
||||
return new Date(iso).getTime() < Date.now();
|
||||
}
|
||||
|
||||
interface CreateTokenFormProps {
|
||||
onCreated: (token: LongLivedCameraToken) => void;
|
||||
}
|
||||
|
||||
function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const [name, setName] = useState('');
|
||||
const [days, setDays] = useState<number>(DEFAULT_LIFETIME_DAYS);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const created = await api.createLongLivedCameraToken({
|
||||
name: name.trim(),
|
||||
expires_in_days: days,
|
||||
});
|
||||
onCreated(created);
|
||||
setName('');
|
||||
setDays(DEFAULT_LIFETIME_DAYS);
|
||||
showToast(t('cameraTokens.toast.created', 'Token created'));
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof Error ? err.message : t('cameraTokens.toast.createFailed', 'Failed to create token'),
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-bambu-dark-secondary rounded-lg p-4 mb-6 border border-bambu-dark-tertiary"
|
||||
>
|
||||
<h3 className="text-base font-semibold text-white mb-3">
|
||||
{t('cameraTokens.create.title', 'Create new token')}
|
||||
</h3>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_140px_auto]">
|
||||
<input
|
||||
type="text"
|
||||
maxLength={100}
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('cameraTokens.create.namePlaceholder', 'e.g. Home Assistant')}
|
||||
className="px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
|
||||
aria-label={t('cameraTokens.create.nameLabel', 'Token name')}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_LIFETIME_DAYS}
|
||||
required
|
||||
value={days}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value);
|
||||
// Clamp client-side too — backend will also enforce, but a clear
|
||||
// hard cap in the input matches the policy and avoids confusing
|
||||
// 400s on submit.
|
||||
setDays(Math.min(Math.max(next, 1), MAX_LIFETIME_DAYS));
|
||||
}}
|
||||
className="px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
|
||||
aria-label={t('cameraTokens.create.daysLabel', 'Days until expiry')}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !name.trim()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('cameraTokens.create.submit', 'Create')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray mt-2">
|
||||
{t(
|
||||
'cameraTokens.create.hint',
|
||||
'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
|
||||
)}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConfirmRevokeModalProps {
|
||||
token: LongLivedCameraToken;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function ConfirmRevokeModal({ token, onConfirm, onCancel }: ConfirmRevokeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="bg-bambu-dark-secondary rounded-lg p-6 max-w-md w-full border border-red-500/40">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertTriangle className="w-6 h-6 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{t('cameraTokens.confirmRevoke.title', 'Revoke this token?')}
|
||||
</h2>
|
||||
<p className="text-sm text-bambu-gray mt-1">
|
||||
{t(
|
||||
'cameraTokens.confirmRevoke.body',
|
||||
'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
|
||||
{ name: token.name },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
|
||||
>
|
||||
{t('cameraTokens.confirmRevoke.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600"
|
||||
>
|
||||
{t('cameraTokens.confirmRevoke.confirm', 'Revoke')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface JustCreatedModalProps {
|
||||
token: LongLivedCameraToken;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function JustCreatedModal({ token, onClose }: JustCreatedModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const plaintext = token.token ?? '';
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!plaintext) return;
|
||||
try {
|
||||
// Modern clipboard API requires a secure context (HTTPS or localhost).
|
||||
// Fall back to a hidden textarea + execCommand so users on plain HTTP
|
||||
// (LAN deployments) can still copy the token.
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(plaintext);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = plaintext;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
try {
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
showToast(t('cameraTokens.toast.copied', 'Copied to clipboard'));
|
||||
} catch {
|
||||
showToast(t('cameraTokens.toast.copyFailed', 'Copy failed — select and copy manually'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-bambu-dark-secondary rounded-lg p-6 max-w-2xl w-full border border-bambu-green/40">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertTriangle className="w-6 h-6 text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{t('cameraTokens.created.title', 'Token created — copy it now')}
|
||||
</h2>
|
||||
<p className="text-sm text-bambu-gray mt-1">
|
||||
{t(
|
||||
'cameraTokens.created.warning',
|
||||
'This is the only time this token will be visible. After you close this dialog you can never view it again.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
|
||||
{plaintext}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
{t('cameraTokens.created.copy', 'Copy')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
|
||||
>
|
||||
{t('cameraTokens.created.dismiss', "I've saved it")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TokenRowProps {
|
||||
token: LongLivedCameraToken;
|
||||
showOwner?: boolean;
|
||||
ownerLabel?: string;
|
||||
onRevoke: (id: number) => Promise<void>;
|
||||
}
|
||||
|
||||
function TokenRow({ token, showOwner, ownerLabel, onRevoke }: TokenRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const expired = isExpired(token.expires_at);
|
||||
return (
|
||||
<tr className="border-b border-bambu-dark-tertiary last:border-b-0">
|
||||
<td className="py-3 px-3 text-white">{token.name}</td>
|
||||
{showOwner && <td className="py-3 px-3 text-bambu-gray">{ownerLabel}</td>}
|
||||
<td className="py-3 px-3 text-bambu-gray font-mono text-xs">{token.lookup_prefix}…</td>
|
||||
<td className="py-3 px-3 text-bambu-gray">{formatDate(token.created_at)}</td>
|
||||
<td className={`py-3 px-3 ${expired ? 'text-red-400' : 'text-bambu-gray'}`}>
|
||||
{formatDate(token.expires_at)}
|
||||
{expired && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs bg-red-500/20 text-red-300 rounded">
|
||||
{t('cameraTokens.list.expired', 'Expired')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-bambu-gray">{formatDate(token.last_used_at)}</td>
|
||||
<td className="py-3 px-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRevoke(token.id)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-sm text-red-400 hover:text-red-300"
|
||||
title={t('cameraTokens.list.revoke', 'Revoke')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('cameraTokens.list.revoke', 'Revoke')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface TokenTableProps {
|
||||
tokens: LongLivedCameraToken[];
|
||||
showOwner?: boolean;
|
||||
userIdToName?: Map<number, string>;
|
||||
onRevoke: (id: number) => Promise<void>;
|
||||
emptyMessage: string;
|
||||
}
|
||||
|
||||
function TokenTable({ tokens, showOwner, userIdToName, onRevoke, emptyMessage }: TokenTableProps) {
|
||||
const { t } = useTranslation();
|
||||
if (tokens.length === 0) {
|
||||
return <p className="text-sm text-bambu-gray italic">{emptyMessage}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-bambu-gray text-left border-b border-bambu-dark-tertiary">
|
||||
<tr>
|
||||
<th className="py-2 px-3 font-medium">{t('cameraTokens.list.name', 'Name')}</th>
|
||||
{showOwner && <th className="py-2 px-3 font-medium">{t('cameraTokens.list.owner', 'Owner')}</th>}
|
||||
<th className="py-2 px-3 font-medium">{t('cameraTokens.list.prefix', 'Prefix')}</th>
|
||||
<th className="py-2 px-3 font-medium">{t('cameraTokens.list.created', 'Created')}</th>
|
||||
<th className="py-2 px-3 font-medium">{t('cameraTokens.list.expires', 'Expires')}</th>
|
||||
<th className="py-2 px-3 font-medium">{t('cameraTokens.list.lastUsed', 'Last used')}</th>
|
||||
<th className="py-2 px-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((tok) => (
|
||||
<TokenRow
|
||||
key={tok.id}
|
||||
token={tok}
|
||||
showOwner={showOwner}
|
||||
ownerLabel={userIdToName?.get(tok.user_id) ?? `#${tok.user_id}`}
|
||||
onRevoke={onRevoke}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual UI block: create form + my-tokens table + admin all-tokens table.
|
||||
* Renders without any outer page chrome so it can be embedded inside
|
||||
* Settings → API Keys (the canonical home) or any other host card.
|
||||
*/
|
||||
export function CameraTokensSection() {
|
||||
const { t } = useTranslation();
|
||||
const { user, isAdmin } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [myTokens, setMyTokens] = useState<LongLivedCameraToken[]>([]);
|
||||
const [allTokens, setAllTokens] = useState<LongLivedCameraToken[]>([]);
|
||||
const [userIdToName, setUserIdToName] = useState<Map<number, string>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [justCreated, setJustCreated] = useState<LongLivedCameraToken | null>(null);
|
||||
const [pendingRevoke, setPendingRevoke] = useState<LongLivedCameraToken | null>(null);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const mine = await api.listMyLongLivedCameraTokens();
|
||||
setMyTokens(mine);
|
||||
if (isAdmin) {
|
||||
const all = await api.listAllLongLivedCameraTokens();
|
||||
setAllTokens(all);
|
||||
// Username lookup: best-effort from the users API. If it errors
|
||||
// (e.g. permission missing for some reason), the table still renders
|
||||
// with the numeric user_id as fallback.
|
||||
try {
|
||||
const users = await api.getUsers();
|
||||
setUserIdToName(new Map(users.map((u: { id: number; username: string }) => [u.id, u.username])));
|
||||
} catch {
|
||||
setUserIdToName(new Map());
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof Error ? err.message : t('cameraTokens.toast.loadFailed', 'Failed to load tokens'),
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAdmin]);
|
||||
|
||||
// Open the confirmation modal. The actual delete fires from
|
||||
// ``confirmRevoke`` once the user clicks through.
|
||||
const requestRevoke = async (id: number) => {
|
||||
const target = [...myTokens, ...allTokens].find((tok) => tok.id === id);
|
||||
if (target) {
|
||||
setPendingRevoke(target);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmRevoke = async () => {
|
||||
if (!pendingRevoke) return;
|
||||
const id = pendingRevoke.id;
|
||||
setPendingRevoke(null);
|
||||
try {
|
||||
await api.revokeLongLivedCameraToken(id);
|
||||
showToast(t('cameraTokens.toast.revoked', 'Token revoked'));
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof Error ? err.message : t('cameraTokens.toast.revokeFailed', 'Failed to revoke token'),
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const otherUsersTokens = useMemo(
|
||||
() => allTokens.filter((t) => t.user_id !== user?.id),
|
||||
[allTokens, user?.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-bambu-gray mb-4">
|
||||
{t(
|
||||
'cameraTokens.description',
|
||||
'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
<CreateTokenForm
|
||||
onCreated={(token) => {
|
||||
setJustCreated(token);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-base font-semibold text-white mb-3">
|
||||
{t('cameraTokens.list.myTitle', 'My tokens')}
|
||||
</h3>
|
||||
{loading ? (
|
||||
<p className="text-sm text-bambu-gray">{t('cameraTokens.loading', 'Loading…')}</p>
|
||||
) : (
|
||||
<TokenTable
|
||||
tokens={myTokens}
|
||||
onRevoke={requestRevoke}
|
||||
emptyMessage={t('cameraTokens.list.empty', 'No tokens yet.')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white mb-3">
|
||||
{t('cameraTokens.list.allTitle', 'All users (admin view)')}
|
||||
</h3>
|
||||
<TokenTable
|
||||
tokens={otherUsersTokens}
|
||||
showOwner
|
||||
userIdToName={userIdToName}
|
||||
onRevoke={requestRevoke}
|
||||
emptyMessage={t('cameraTokens.list.empty', 'No tokens yet.')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{justCreated && (
|
||||
<JustCreatedModal token={justCreated} onClose={() => setJustCreated(null)} />
|
||||
)}
|
||||
|
||||
{pendingRevoke && (
|
||||
<ConfirmRevokeModal
|
||||
token={pendingRevoke}
|
||||
onConfirm={() => void confirmRevoke()}
|
||||
onCancel={() => setPendingRevoke(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CameraTokensPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">
|
||||
{t('cameraTokens.title', 'Camera API Tokens')}
|
||||
</h1>
|
||||
<CameraTokensSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { formatDateOnly } from '../utils/date';
|
|||
import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
|
||||
import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
|
||||
import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
|
||||
import { CameraTokensSection } from './CameraTokensPage';
|
||||
import { Collapsible } from '../components/Collapsible';
|
||||
import { Button } from '../components/Button';
|
||||
import { SmartPlugCard } from '../components/SmartPlugCard';
|
||||
|
|
@ -75,6 +76,7 @@ registerSettingsSearch({ labelKey: 'settings.prometheusMetrics', tab: 'network',
|
|||
registerSettingsSearch({ labelKey: 'settings.createNewApiKey', tab: 'apikeys', keywords: 'api key create permission scope', anchor: 'card-createapi' });
|
||||
registerSettingsSearch({ labelKey: 'settings.webhookEndpoints', tab: 'apikeys', keywords: 'webhook endpoint post http', anchor: 'card-webhooks' });
|
||||
registerSettingsSearch({ labelKey: 'settings.apiBrowser', tab: 'apikeys', keywords: 'api browser endpoint documentation test', anchor: 'card-apibrowser' });
|
||||
registerSettingsSearch({ labelKey: 'cameraTokens.title', tab: 'apikeys', keywords: 'camera token long-lived home assistant frigate kiosk stream', anchor: 'card-camera-tokens' });
|
||||
registerSettingsSearch({ labelKey: 'settings.tabs.virtualPrinter', tab: 'virtual-printer', keywords: 'virtual printer proxy archive slicer bambustudio orcaslicer ip bind', anchor: 'card-vp' });
|
||||
registerSettingsSearch({ labelKey: 'settings.tabs.spoolbuddy', tab: 'spoolbuddy', keywords: 'spoolbuddy device scale nfc rfid kiosk unregister', anchor: 'card-spoolbuddy' });
|
||||
registerSettingsSearch({ labelKey: 'settings.currentUser', tab: 'users', subTab: 'users', keywords: 'current user profile password change', anchor: 'card-currentuser' });
|
||||
|
|
@ -3503,9 +3505,15 @@ export function SettingsPage() {
|
|||
|
||||
{/* API Keys Tab */}
|
||||
{activeTab === 'apikeys' && (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{/* Left Column - API Keys Management */}
|
||||
<div className={hasPermission('api_keys:read')
|
||||
? 'grid grid-cols-1 xl:grid-cols-2 gap-4'
|
||||
: 'grid grid-cols-1 gap-4'}>
|
||||
{/* Left Column - API Keys Management. Admin-gated content
|
||||
(webhook keys, webhook docs) is hidden from users without
|
||||
api_keys:read; the Camera Tokens panel is always shown so
|
||||
users with camera:view can self-manage their own tokens. */}
|
||||
<div>
|
||||
{hasPermission('api_keys:read') && <>
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2" id="card-createapi">
|
||||
|
|
@ -3774,10 +3782,27 @@ export function SettingsPage() {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
|
||||
{/* Long-lived camera-stream tokens (#1108) */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2" id="card-camera-tokens">
|
||||
<Video className="w-4 h-4 text-bambu-green" />
|
||||
{t('cameraTokens.title', 'Camera API Tokens')}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CameraTokensSection />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - API Browser */}
|
||||
<div>
|
||||
{/* Right Column - API Browser. Hidden from users without
|
||||
api_keys:read since the API Browser is the testing surface
|
||||
for those keys; non-admins land in this tab only for the
|
||||
Camera Tokens panel and don't need the browser. */}
|
||||
{hasPermission('api_keys:read') && <div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2" id="card-apibrowser">
|
||||
<Globe className="w-5 h-5 text-bambu-green" />
|
||||
|
|
@ -3806,7 +3831,7 @@ export function SettingsPage() {
|
|||
</Card>
|
||||
|
||||
<APIBrowser apiKey={testApiKey} />
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue