Просмотр исходного кода

feat(camwall): serve the Cam Wall at /camwall, and on a token-authenticated kiosk

Cam Wall had no URL — the only way in was the toggle on the Printers page,
so it could not be bookmarked, linked, or shown on a wall-mounted screen.

Add a standalone /camwall route. Signed in, it is the wall as it was. For a
TV or Pi with no login, it authenticates with a long-lived token in the URL.

A kiosk needs the printer list and per-printer status, both of which sit
behind PRINTERS_READ. Rather than widen camera_stream to cover GET /printers
— whose response carries serial_number and ip_address, which have no business
on a screen in a shared room — add a read-only feed at
GET /api/v1/camwall/printers that serves only what a tile draws, and gate it
on a new camwall token scope. The print filename is not served at all: a token
wall renders the compact overlay, so the part on the bed is never named.

The scope is separate rather than a widening: camera_stream tokens are already
in the wild, minted to hand out video, and must not gain the ability to
enumerate a fleet by name. camera_stream is refused by the feed; camwall
passes the stream gate so its own tiles fill.

Kiosk walls drop the settings popover and click-through entirely (not merely
hidden — a passive screen must carry no focusable control it cannot act on),
cap the overlay at compact, and poll rather than open a WebSocket. maxLive,
interval and status can be set from the URL, clamped to the popover's ranges.
maziggy 1 месяц назад
Родитель
Сommit
d09db436c3

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 8 - 4
backend/app/api/routes/auth.py

@@ -1617,10 +1617,14 @@ async def provision_ldap_user(
 # =============================================================================
 # 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).
+# 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).
+#
+# Two scopes, both minted here — see ALLOWED_SCOPES in services/long_lived_tokens
+# for what each one reaches: "camera_stream" (video only) and "camwall" (video
+# plus the Cam Wall's read-only tile metadata, #2531).
 
 
 def _long_lived_token_to_response(record, *, plaintext: str | None = None) -> dict:

+ 95 - 0
backend/app/api/routes/camwall.py

@@ -0,0 +1,95 @@
+"""Read-only Cam Wall feed for token-authenticated kiosk displays (#2531).
+
+The Cam Wall inside the SPA runs on the ordinary printers API, behind a JWT. A
+wall pinned to a TV has no login, so it authenticates with a long-lived
+``camwall``-scoped token carried in the URL — and a URL on a lobby screen is
+about as private as a sticky note.
+
+That is why this endpoint exists instead of letting a token through to
+``GET /printers``: the printer list carries ``serial_number`` and
+``ip_address`` (see ``schemas/printer.py``), and neither belongs on a screen in
+a shared room. What a wall tile actually draws is the whole payload here — a
+name, a connection flag, a state, a progress bar.
+
+Notably absent is the print filename. A token wall renders the compact status
+overlay, so the part being printed is never named to the room; the field simply
+isn't served rather than being served and then hidden client-side.
+"""
+
+import logging
+
+from fastapi import APIRouter, Depends
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequireCamWallTokenIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.models.printer import Printer
+from backend.app.services.printer_manager import printer_manager
+
+_logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/camwall", tags=["camwall"])
+
+
+@router.get("/printers")
+async def list_camwall_printers(
+    _: None = RequireCamWallTokenIfAuthEnabled,
+    db: AsyncSession = Depends(get_db),
+) -> list[dict]:
+    """Every printer plus the handful of status fields a Cam Wall tile draws.
+
+    One call for the whole wall rather than one per printer: a kiosk polls this
+    on a fixed interval with no WebSocket to invalidate it, and N+1 requests
+    every few seconds is a poor trade for a screen nobody is interacting with.
+
+    Ordered by name so tile positions stay put across polls — a wall that
+    reshuffles itself is unusable to watch.
+    """
+    result = await db.execute(select(Printer).order_by(Printer.name))
+    printers = list(result.scalars().all())
+
+    payload: list[dict] = []
+    for printer in printers:
+        state = printer_manager.get_status(printer.id)
+        entry: dict = {
+            "id": printer.id,
+            "name": printer.name,
+            "camera_rotation": printer.camera_rotation or 0,
+            # Mirrors get_printer_status(): no state object at all means the
+            # printer was never connected this run; a state object still has
+            # to be asked whether its link is currently up.
+            "connected": bool(state and state.connected),
+            "state": None,
+            "progress": None,
+            "remaining_time": None,
+            "layer_num": None,
+            "total_layers": None,
+            # Codes only — enough for the client to run the same
+            # filterKnownHMSErrors() it uses on the authenticated wall, so the
+            # error chip means the same thing in both modes.
+            "hms_errors": [],
+        }
+        if state is not None:
+            entry.update(
+                {
+                    "state": state.state,
+                    "progress": state.progress,
+                    "remaining_time": state.remaining_time,
+                    "layer_num": state.layer_num,
+                    "total_layers": state.total_layers,
+                    "hms_errors": [
+                        {
+                            "code": e.code,
+                            "attr": e.attr,
+                            "module": e.module,
+                            "severity": e.severity,
+                            "actions": e.actions or [],
+                        }
+                        for e in (state.hms_errors or [])
+                    ],
+                }
+            )
+        payload.append(entry)
+
+    return payload

+ 42 - 1
backend/app/core/auth.py

@@ -700,9 +700,27 @@ async def verify_camera_stream_token(token: str) -> bool:
 
         # 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 STREAM_SCOPES, verify_token as verify_long_lived
+
+        record = await verify_long_lived(db, token, scope=STREAM_SCOPES)
+        return record is not None
+
+
+async def verify_camwall_token(token: str) -> bool:
+    """Verify a Cam Wall token (#2531). Reusable — does not consume it.
+
+    Deliberately narrower than :func:`verify_camera_stream_token`: only the
+    long-lived ``camwall`` scope passes. The 60-minute ephemeral token belongs
+    to a logged-in browser, which already reaches the wall's metadata through
+    the ordinary printers API and has no need of this endpoint; and a
+    ``camera_stream`` token was handed out for video alone, so it must not
+    acquire the ability to enumerate printers by name just because a new
+    feature shipped.
+    """
+    async with async_session() as db:
         from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
 
-        record = await verify_long_lived(db, token, scope="camera_stream")
+        record = await verify_long_lived(db, token, scope="camwall")
         return record is not None
 
 
@@ -1673,6 +1691,29 @@ def require_camera_stream_token_if_auth_enabled():
 RequireCameraStreamTokenIfAuthEnabled = Depends(require_camera_stream_token_if_auth_enabled())
 
 
+def require_camwall_token_if_auth_enabled():
+    """Dependency that validates a Cam Wall token query param when auth is enabled.
+
+    Used by the read-only Cam Wall feed (#2531), which a kiosk browser loads
+    with the token in the URL because it has no login session to carry a JWT.
+    """
+
+    async def checker(token: str | None = None) -> None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return  # Auth disabled, allow access
+        if not token or not await verify_camwall_token(token):
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Valid Cam Wall token required. Create one under Settings > API Keys with the 'Cam Wall' scope.",
+            )
+
+    return checker
+
+
+RequireCamWallTokenIfAuthEnabled = Depends(require_camwall_token_if_auth_enabled())
+
+
 def require_ownership_permission(
     all_permission: str | Permission,
     own_permission: str | Permission,

+ 10 - 0
backend/app/main.py

@@ -25,6 +25,7 @@ from backend.app.api.routes import (
     auth,
     bug_report,
     camera,
+    camwall,
     cloud,
     discovery,
     external_links,
@@ -6460,6 +6461,14 @@ PUBLIC_API_ROUTES = {
     # before the route handler runs, regardless of the route's own
     # "no auth required" intent.
     "/api/v1/system/appliance",
+    # Cam Wall kiosk feed (#2531): a TV or Pi in kiosk mode has no login, so it
+    # authenticates with a long-lived ``camwall``-scoped token in the query
+    # string — exactly like the camera streams two lists below, and for the same
+    # reason (no header to put a JWT in). "Public" here only means the middleware
+    # steps aside; the route still runs RequireCamWallTokenIfAuthEnabled, which
+    # rejects an absent, expired, revoked, or wrong-scoped token. In particular a
+    # plain ``camera_stream`` token does NOT open this door.
+    "/api/v1/camwall/printers",
 }
 
 # Route prefixes that are public (for routes with dynamic segments)
@@ -6851,6 +6860,7 @@ app.include_router(updates.router, prefix=app_settings.api_prefix)
 app.include_router(sponsor_prompt.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.router, prefix=app_settings.api_prefix)
 app.include_router(camera.router, prefix=app_settings.api_prefix)
+app.include_router(camwall.router, prefix=app_settings.api_prefix)
 app.include_router(external_links.router, prefix=app_settings.api_prefix)
 app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(library.router, prefix=app_settings.api_prefix)

+ 31 - 8
backend/app/services/long_lived_tokens.py

@@ -22,6 +22,7 @@ tokens — a leaked permanent token would be irrevocable footgun-by-design).
 from __future__ import annotations
 
 import secrets
+from collections.abc import Collection
 from dataclasses import dataclass
 from datetime import datetime, timedelta, timezone
 
@@ -35,9 +36,20 @@ from backend.app.models.long_lived_token import LongLivedToken
 # (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"})
+# Every scope is a separate grant, never implied by another. A token minted for
+# one purpose must not silently widen when a later scope is added.
+#
+#   camera_stream — the MJPEG stream / snapshot endpoints and nothing else
+#                   (#1108). What a Home Assistant or Frigate card needs.
+#   camwall       — those same streams *plus* the read-only tile metadata the
+#                   Cam Wall draws: printer names and print state (#2531).
+#                   Strictly wider than camera_stream, so it gets its own scope
+#                   rather than quietly extending tokens already handed out.
+ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream", "camwall"})
+
+# Scopes the camera stream / snapshot endpoints honour. A Cam Wall token has to
+# be able to pull the video its own tiles are showing.
+STREAM_SCOPES: tuple[str, ...] = ("camera_stream", "camwall")
 
 # 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
@@ -142,23 +154,34 @@ async def create_token(
     return CreatedToken(record=record, plaintext=plaintext)
 
 
-async def verify_token(db: AsyncSession, token: str, *, scope: str = "camera_stream") -> LongLivedToken | None:
+async def verify_token(
+    db: AsyncSession,
+    token: str,
+    *,
+    scope: str | Collection[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.
+    ``scope`` accepts a single scope or a collection of acceptable ones — the
+    stream endpoints pass ``STREAM_SCOPES`` because more than one scope may
+    legitimately reach them. The record must carry one of them; a token is
+    never accepted on the strength of a scope it does not hold.
+
+    The pbkdf2 verify is the slow step (intentional), 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
+    scopes = (scope,) if isinstance(scope, str) else tuple(scope)
 
     now = datetime.now(timezone.utc)
     result = await db.execute(
         select(LongLivedToken).where(
             LongLivedToken.lookup_prefix == lookup_prefix,
-            LongLivedToken.scope == scope,
+            LongLivedToken.scope.in_(scopes),
             LongLivedToken.revoked_at.is_(None),
         )
     )

+ 203 - 0
backend/tests/integration/test_camwall_api.py

@@ -0,0 +1,203 @@
+"""Integration tests for the token-authenticated Cam Wall feed (#2531).
+
+The feature's whole reason for existing as a separate endpoint (rather than
+letting a token through to ``GET /printers``) is that a kiosk URL is not a
+secret. So the tests that matter here are the negative ones: what a Cam Wall
+token *cannot* reach, and what the payload *does not* contain.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _setup_admin(async_client: AsyncClient, *, suffix: str) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": f"camwalladmin{suffix}",
+            "admin_password": "AdminPass1!",
+        },
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": f"camwalladmin{suffix}", "password": "AdminPass1!"},
+    )
+    return login.json()["access_token"]
+
+
+async def _mint(async_client: AsyncClient, jwt: str, *, scope: str, name: str = "kiosk") -> str:
+    response = await async_client.post(
+        "/api/v1/auth/tokens",
+        headers={"Authorization": f"Bearer {jwt}"},
+        json={"name": name, "expires_in_days": 30, "scope": scope},
+    )
+    assert response.status_code == 201, response.text
+    assert response.json()["scope"] == scope
+    return response.json()["token"]
+
+
+@pytest.fixture
+async def printer_row(db_session):
+    """Insert the printer straight into the DB.
+
+    POST /printers probes the real device before it will store a row, and there
+    is no printer on the other end of a test run.
+    """
+    from backend.app.models.printer import Printer
+
+    printer = Printer(
+        name="Wall P1S",
+        ip_address="192.168.1.77",
+        access_code="12345678",
+        serial_number="01P00A000000001",
+        model="P1S",
+    )
+    db_session.add(printer)
+    await db_session.commit()
+    return printer
+
+
+class TestCamWallFeedAuth:
+    async def test_no_token_is_rejected(self, async_client: AsyncClient):
+        await _setup_admin(async_client, suffix="_notoken")
+        response = await async_client.get("/api/v1/camwall/printers")
+        assert response.status_code == 401
+
+    async def test_garbage_token_is_rejected(self, async_client: AsyncClient):
+        await _setup_admin(async_client, suffix="_garbage")
+        response = await async_client.get("/api/v1/camwall/printers?token=bblt_aaaaaaaa_nope")
+        assert response.status_code == 401
+
+    async def test_camera_stream_token_cannot_reach_the_feed(self, async_client: AsyncClient):
+        """The point of the separate scope.
+
+        ``camera_stream`` tokens are already in the wild, minted by users who
+        agreed to hand out *video*. Shipping the Cam Wall must not retroactively
+        grant them the ability to enumerate printers by name.
+        """
+        jwt = await _setup_admin(async_client, suffix="_wrongscope")
+        stream_token = await _mint(async_client, jwt, scope="camera_stream")
+
+        response = await async_client.get(f"/api/v1/camwall/printers?token={stream_token}")
+        assert response.status_code == 401
+
+    async def test_camwall_token_reaches_the_feed(self, async_client: AsyncClient, printer_row):
+        jwt = await _setup_admin(async_client, suffix="_rightscope")
+        camwall_token = await _mint(async_client, jwt, scope="camwall")
+
+        response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
+        assert response.status_code == 200, response.text
+        body = response.json()
+        assert len(body) == 1
+        assert body[0]["name"] == "Wall P1S"
+
+    async def test_revoked_camwall_token_is_rejected(self, async_client: AsyncClient):
+        jwt = await _setup_admin(async_client, suffix="_revoked")
+        created = await async_client.post(
+            "/api/v1/auth/tokens",
+            headers={"Authorization": f"Bearer {jwt}"},
+            json={"name": "kiosk", "expires_in_days": 30, "scope": "camwall"},
+        )
+        camwall_token = created.json()["token"]
+        await async_client.delete(
+            f"/api/v1/auth/tokens/{created.json()['id']}",
+            headers={"Authorization": f"Bearer {jwt}"},
+        )
+
+        response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
+        assert response.status_code == 401
+
+
+class TestCamWallFeedPayload:
+    async def test_payload_withholds_secrets_and_filenames(self, async_client: AsyncClient, printer_row):
+        """A URL taped to a TV must not disclose more than the picture does.
+
+        Serial number and IP ride along on the ordinary printer list even for
+        non-secret callers, and the filename names the customer's part. None of
+        the three may appear here.
+        """
+        jwt = await _setup_admin(async_client, suffix="_payload")
+        camwall_token = await _mint(async_client, jwt, scope="camwall")
+
+        response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
+        assert response.status_code == 200
+        entry = response.json()[0]
+
+        for leaked in ("serial_number", "ip_address", "access_code", "subtask_name", "gcode_file"):
+            assert leaked not in entry, f"{leaked} must not be served to a kiosk token"
+
+        assert set(entry) == {
+            "id",
+            "name",
+            "camera_rotation",
+            "connected",
+            "state",
+            "progress",
+            "remaining_time",
+            "layer_num",
+            "total_layers",
+            "hms_errors",
+        }
+
+    async def test_disconnected_printer_reports_connected_false(self, async_client: AsyncClient, printer_row):
+        """No MQTT client is running in tests, so the printer has no state at
+        all — the tile must render as offline rather than blank.
+        """
+        jwt = await _setup_admin(async_client, suffix="_offline")
+        camwall_token = await _mint(async_client, jwt, scope="camwall")
+
+        response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
+        entry = response.json()[0]
+        assert entry["connected"] is False
+        assert entry["state"] is None
+        assert entry["hms_errors"] == []
+
+
+class TestCamWallTokenReachesTheVideo:
+    """A wall that can list the tiles but not fill them is useless — the same
+    token has to satisfy the camera-stream gate.
+    """
+
+    async def test_camwall_token_passes_the_camera_stream_gate(self, async_client: AsyncClient):
+        from backend.app.core.auth import verify_camera_stream_token
+
+        jwt = await _setup_admin(async_client, suffix="_video")
+        camwall_token = await _mint(async_client, jwt, scope="camwall")
+
+        assert await verify_camera_stream_token(camwall_token) is True
+
+    async def test_camera_stream_token_still_passes_its_own_gate(self, async_client: AsyncClient):
+        """Regression guard on #1108: widening the accepted scopes must not have
+        broken the tokens that were already working.
+        """
+        from backend.app.core.auth import verify_camera_stream_token
+
+        jwt = await _setup_admin(async_client, suffix="_video_legacy")
+        stream_token = await _mint(async_client, jwt, scope="camera_stream")
+
+        assert await verify_camera_stream_token(stream_token) is True
+
+    async def test_camwall_gate_rejects_a_camera_stream_token(self, async_client: AsyncClient):
+        from backend.app.core.auth import verify_camwall_token
+
+        jwt = await _setup_admin(async_client, suffix="_gate_narrow")
+        stream_token = await _mint(async_client, jwt, scope="camera_stream")
+
+        assert await verify_camwall_token(stream_token) is False
+
+
+class TestScopeValidation:
+    async def test_unknown_scope_is_rejected_at_mint(self, async_client: AsyncClient):
+        jwt = await _setup_admin(async_client, suffix="_badscope")
+        response = await async_client.post(
+            "/api/v1/auth/tokens",
+            headers={"Authorization": f"Bearer {jwt}"},
+            json={"name": "x", "expires_in_days": 30, "scope": "printers_write"},
+        )
+        assert response.status_code == 400
+        assert "unsupported scope" in response.json()["detail"].lower()

+ 7 - 2
backend/tests/unit/services/test_long_lived_tokens.py

@@ -107,8 +107,13 @@ async def test_create_rejects_expiry_above_policy_cap(db_session, alice: User):
 
 
 async def test_create_rejects_unsupported_scope(db_session, alice: User):
-    """V1 only allows ``camera_stream``."""
-    assert {"camera_stream"} == set(ALLOWED_SCOPES)
+    """The scope set is closed: ``camera_stream`` (#1108) and ``camwall`` (#2531).
+
+    Pinned deliberately. Adding a scope should be a decision someone makes on
+    purpose — a new value here means a new class of thing a URL-borne token can
+    reach, so it should not be possible to add one without this line failing.
+    """
+    assert {"camera_stream", "camwall"} == set(ALLOWED_SCOPES)
     with pytest.raises(ValueError, match="unsupported scope"):
         await create_token(
             db_session,

+ 6 - 0
frontend/src/App.tsx

@@ -14,6 +14,7 @@ import { ProjectDetailPage } from './pages/ProjectDetailPage';
 import { FileManagerPage } from './pages/FileManagerPage';
 import { LibraryTrashPage } from './pages/LibraryTrashPage';
 import { CameraPage } from './pages/CameraPage';
+import { CamWallPage } from './pages/CamWallPage';
 import { StreamOverlayPage } from './pages/StreamOverlayPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { GroupEditPage } from './pages/GroupEditPage';
@@ -182,6 +183,11 @@ function App() {
                 {/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */}
                 <Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
 
+                {/* Cam Wall on its own URL (#2531). Outside ProtectedRoute because a
+                    ?token= kiosk has no session to protect; the page itself sends a
+                    tokenless visitor to /login, and the backend gates the feed. */}
+                <Route path="/camwall" element={<CamWallPage />} />
+
                 {/* SpoolBuddy kiosk UI */}
                 <Route element={<ProtectedRoute><WebSocketProvider><SpoolBuddyLayout /></WebSocketProvider></ProtectedRoute>}>
                   <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />

+ 145 - 0
frontend/src/__tests__/pages/CamWallPage.test.tsx

@@ -0,0 +1,145 @@
+/**
+ * Standalone Cam Wall page (#2531).
+ *
+ * The assertions that carry weight are the kiosk ones: a wall on a TV must not
+ * offer controls it cannot honour, must not name the file on the bed, and must
+ * carry its token into the <img> URLs — an MJPEG tag has no Authorization
+ * header, so a missing token means a wall of broken images.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { render as rtlRender } from '@testing-library/react';
+import { ThemeProvider } from '../../contexts/ThemeContext';
+import { ToastProvider } from '../../contexts/ToastContext';
+import { AuthProvider } from '../../contexts/AuthContext';
+import { CamWallPage } from '../../pages/CamWallPage';
+import { api, getStreamToken } from '../../api/client';
+
+const KIOSK_TOKEN = 'bblt_abcdefgh_secretsecretsecret';
+
+const FEED = [
+  {
+    id: 7,
+    name: 'X1C-Lab',
+    camera_rotation: 0,
+    connected: true,
+    state: 'RUNNING',
+    progress: 42,
+    remaining_time: 33,
+    layer_num: 120,
+    total_layers: 300,
+    hms_errors: [],
+  },
+];
+
+// The page renders outside the app layout, so it supplies its own route context.
+// The shared render() util hard-codes BrowserRouter with no way to seed a query
+// string, and the query string is the whole point here.
+function renderAt(search: string) {
+  const queryClient = new QueryClient({
+    defaultOptions: { queries: { retry: false, gcTime: 0 } },
+  });
+  return rtlRender(
+    <QueryClientProvider client={queryClient}>
+      <MemoryRouter initialEntries={[`/camwall${search}`]}>
+        <AuthProvider>
+          <ThemeProvider>
+            <ToastProvider>
+              <CamWallPage />
+            </ToastProvider>
+          </ThemeProvider>
+        </AuthProvider>
+      </MemoryRouter>
+    </QueryClientProvider>,
+  );
+}
+
+describe('CamWallPage — kiosk mode', () => {
+  beforeEach(() => {
+    vi.spyOn(api, 'getCamWallPrinters').mockResolvedValue(FEED);
+    // AuthProvider probes /auth/me on mount; a kiosk browser has no session.
+    vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 401 }));
+    localStorage.clear();
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('fetches the wall with the URL token and renders a tile per printer', async () => {
+    renderAt(`?token=${KIOSK_TOKEN}`);
+
+    await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
+    expect(api.getCamWallPrinters).toHaveBeenCalledWith(KIOSK_TOKEN);
+  });
+
+  it('carries the token into the stream URL', async () => {
+    renderAt(`?token=${KIOSK_TOKEN}`);
+
+    await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
+    // Tiles start paused (jsdom's IntersectionObserver never reports a tile as
+    // on-screen), and a paused tile renders no <img> at all. So assert on the
+    // module-level token those URLs are built from, which is what CameraTile
+    // reads through withStreamToken().
+    expect(getStreamToken()).toBe(KIOSK_TOKEN);
+  });
+
+  it('offers no settings popover — a TV has nobody standing at it', async () => {
+    renderAt(`?token=${KIOSK_TOKEN}`);
+
+    await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
+    expect(screen.queryByTitle('Cam wall settings')).not.toBeInTheDocument();
+  });
+
+  it('renders tiles inert — click-through would need a session the token has not got', async () => {
+    renderAt(`?token=${KIOSK_TOKEN}`);
+
+    await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
+    expect(screen.queryByRole('button', { name: /X1C-Lab/ })).not.toBeInTheDocument();
+  });
+
+  it('refuses to honour ?status=full — a kiosk wall never names the part on the bed', async () => {
+    renderAt(`?token=${KIOSK_TOKEN}&status=full`);
+
+    await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
+    // 'full' is what adds the progress/layer strip. Capped to 'compact', so the
+    // strip must be absent even though the feed says the printer is at 42%.
+    expect(screen.queryByText('42%')).not.toBeInTheDocument();
+    expect(screen.queryByText(/Layer 120/)).not.toBeInTheDocument();
+  });
+
+  it('says so plainly when the token has expired or been revoked', async () => {
+    vi.spyOn(api, 'getCamWallPrinters').mockRejectedValue(new Error('401'));
+    renderAt(`?token=${KIOSK_TOKEN}`);
+
+    await waitFor(() =>
+      expect(screen.getByText(/no longer valid/i)).toBeInTheDocument(),
+    );
+  });
+});
+
+describe('CamWallPage — signed out, no token', () => {
+  beforeEach(() => {
+    vi.spyOn(api, 'getCamWallPrinters').mockResolvedValue(FEED);
+    localStorage.clear();
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('does not reach for the kiosk feed without a token', async () => {
+    vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 401 }));
+    renderAt('');
+    await act(async () => {
+      await Promise.resolve();
+    });
+
+    // No token means this is an ordinary app page; it must fall back to the
+    // session-authenticated printers API (or bounce to /login), never to the
+    // kiosk endpoint.
+    expect(api.getCamWallPrinters).not.toHaveBeenCalled();
+  });
+});

+ 39 - 7
frontend/src/api/client.ts

@@ -287,14 +287,20 @@ export interface SystemHealthResult {
   };
 }
 
-// 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.
+// Long-lived camera 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.
+//
+// 'camera_stream' reaches the video endpoints only. 'camwall' additionally
+// reaches the read-only Cam Wall feed, which names the printers (#2531), so it
+// is a separate scope rather than a widening of tokens already in the wild.
+export type LongLivedTokenScope = 'camera_stream' | 'camwall';
+
 export interface LongLivedCameraToken {
   id: number;
   user_id: number;
   name: string;
-  scope: 'camera_stream';
+  scope: LongLivedTokenScope;
   lookup_prefix: string;
   created_at: string;
   expires_at: string;
@@ -302,6 +308,22 @@ export interface LongLivedCameraToken {
   token: string | null;
 }
 
+// One row of the token-authenticated Cam Wall feed (#2531). Deliberately
+// smaller than PrinterStatus: no serial, no IP, no print filename — a kiosk URL
+// is not a secret, so the payload behind it must not be either.
+export interface CamWallPrinter {
+  id: number;
+  name: string;
+  camera_rotation: number;
+  connected: boolean;
+  state: string | null;
+  progress: number | null;
+  remaining_time: number | null;
+  layer_num: number | null;
+  total_layers: number | null;
+  hms_errors: HMSError[];
+}
+
 // Printer types
 export interface Printer {
   id: number;
@@ -5645,11 +5667,15 @@ export const api = {
   getWebSocketToken: () =>
     request<{ token: string }>('/auth/ws-token', { method: 'POST' }),
 
-  // Long-lived camera-stream tokens (#1108)
-  createLongLivedCameraToken: (payload: { name: string; expires_in_days: number }) =>
+  // Long-lived camera tokens (#1108, #2531)
+  createLongLivedCameraToken: (payload: {
+    name: string;
+    expires_in_days: number;
+    scope?: LongLivedTokenScope;
+  }) =>
     request<LongLivedCameraToken>('/auth/tokens', {
       method: 'POST',
-      body: JSON.stringify({ ...payload, scope: 'camera_stream' }),
+      body: JSON.stringify({ scope: 'camera_stream', ...payload }),
     }),
   listMyLongLivedCameraTokens: () =>
     request<LongLivedCameraToken[]>('/auth/tokens'),
@@ -5659,6 +5685,12 @@ export const api = {
     request<LongLivedCameraToken[]>(`/auth/tokens?user_id=${userId}`),
   revokeLongLivedCameraToken: (tokenId: number) =>
     request<void>(`/auth/tokens/${tokenId}`, { method: 'DELETE' }),
+  // Token-authenticated Cam Wall feed (#2531). `token` is omitted only when
+  // auth is disabled, where the backend gate is a no-op anyway.
+  getCamWallPrinters: (token?: string) =>
+    request<CamWallPrinter[]>(
+      token ? `/camwall/printers?token=${encodeURIComponent(token)}` : '/camwall/printers',
+    ),
   getCameraStreamUrl: (printerId: number, fps = 10) =>
     withStreamToken(`${API_BASE}/printers/${printerId}/camera/stream?fps=${fps}`),
   getCameraSnapshotUrl: (printerId: number) =>

+ 25 - 10
frontend/src/components/CameraTile.tsx

@@ -128,9 +128,11 @@ export function CameraTile({
     `/api/v1/printers/${printerId}/camera/snapshot?t=${bust}`,
   );
 
-  const handleClick = () => {
-    if (onClick) onClick();
-  };
+  // A kiosk wall passes no onClick — there is no pointer at a TV, and the page
+  // is authenticated by a token that cannot open the single-camera view. Render
+  // the tile as plain, non-focusable content rather than a button that looks
+  // clickable and then does nothing.
+  const interactive = onClick != null;
 
   const transform = cameraRotation ? `rotate(${cameraRotation}deg)` : undefined;
 
@@ -145,13 +147,12 @@ export function CameraTile({
   const hasLayers = layerNum != null && totalLayers != null && totalLayers > 0;
   const hasRemaining = remainingMin != null && remainingMin > 0;
 
-  return (
-    <button
-      type="button"
-      onClick={handleClick}
-      className="group relative aspect-video w-full overflow-hidden rounded-lg border border-bambu-dark-tertiary bg-black text-left focus:outline-none focus:ring-2 focus:ring-bambu-green"
-      title={printerName}
-    >
+  const rootClass = `group relative aspect-video w-full overflow-hidden rounded-lg border border-bambu-dark-tertiary bg-black text-left ${
+    interactive ? 'focus:outline-none focus:ring-2 focus:ring-bambu-green' : 'cursor-default'
+  }`;
+
+  const content = (
+    <>
       {!connected || mode === 'paused' ? (
         <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/60">
           {connected ? (
@@ -243,6 +244,20 @@ export function CameraTile({
         )}
         <span className="block truncate text-xs font-medium">{printerName}</span>
       </div>
+    </>
+  );
+
+  if (!interactive) {
+    return (
+      <div className={rootClass} title={printerName}>
+        {content}
+      </div>
+    );
+  }
+
+  return (
+    <button type="button" onClick={onClick} className={rootClass} title={printerName}>
+      {content}
     </button>
   );
 }

+ 55 - 10
frontend/src/components/CameraWall.tsx

@@ -4,17 +4,51 @@ import { useQueries } from '@tanstack/react-query';
 import { Settings as SettingsIcon } from 'lucide-react';
 import { CameraTile, type CameraTileMode, type CameraTileStatusMode } from './CameraTile';
 import { filterKnownHMSErrors } from './HMSErrorModal';
-import { api, type Printer, type PrinterStatus } from '../api/client';
+import { api, type PrinterStatus } from '../api/client';
+
+// The wall only ever reads these three fields off a printer, so it asks for no
+// more than that. Printer[] satisfies this structurally, and so does the
+// smaller payload the token-authenticated kiosk feed returns (#2531) — which
+// deliberately carries neither serial number nor IP.
+export interface CameraWallPrinter {
+  id: number;
+  name: string;
+  camera_rotation?: number;
+}
+
+// What a tile draws from a printer's status. PrinterStatus satisfies it; so
+// does CamWallPrinter, which is how the kiosk page feeds the same component
+// without the JWT-gated per-printer status endpoint.
+export interface CameraWallStatus {
+  connected?: boolean;
+  state?: string | null;
+  progress?: number | null;
+  remaining_time?: number | null;
+  layer_num?: number | null;
+  total_layers?: number | null;
+  subtask_name?: string | null;
+  gcode_file?: string | null;
+  hms_errors?: PrinterStatus['hms_errors'];
+}
 
 interface CameraWallProps {
-  printers: Printer[];
+  printers: CameraWallPrinter[];
   maxLive: number;
   snapshotIntervalSec: number;
   statusMode: CameraTileStatusMode;
-  onTileClick: (printerId: number, printerName: string) => void;
   onChangeMaxLive: (next: number) => void;
   onChangeSnapshotIntervalSec: (next: number) => void;
   onChangeStatusMode: (next: CameraTileStatusMode) => void;
+  // Omitted on a kiosk wall: a TV has no pointer, and click-through would open
+  // a page the wall's token cannot authenticate. Tiles render inert instead.
+  onTileClick?: (printerId: number, printerName: string) => void;
+  // Supplied by the kiosk page, which polls one feed for the whole wall. When
+  // absent the component fetches per-printer status itself, reusing the
+  // ['printerStatus', id] cache the printer cards already populate.
+  statuses?: Map<number, CameraWallStatus | undefined>;
+  // Kiosk walls hide the settings popover — the knobs come from the URL, and
+  // there is nobody standing at the screen to turn them.
+  showSettings?: boolean;
 }
 
 const MIN_MAX_LIVE = 1;
@@ -32,26 +66,33 @@ export function CameraWall({
   onChangeMaxLive,
   onChangeSnapshotIntervalSec,
   onChangeStatusMode,
+  statuses,
+  showSettings: settingsEnabled = true,
 }: CameraWallProps) {
   const { t } = useTranslation();
   const tileRefs = useRef<Map<number, HTMLDivElement | null>>(new Map());
 
   // Reuses the same ['printerStatus', id] cache that each PrinterCard
-  // populates, so flipping between Cards and Cam Wall is instant.
+  // populates, so flipping between Cards and Cam Wall is instant. Skipped
+  // entirely when the caller already has the statuses — the kiosk page polls
+  // one feed for the whole wall, and its token cannot reach this endpoint.
+  const ownQueries = statuses ? [] : printers;
   const statusQueries = useQueries({
-    queries: printers.map((p) => ({
+    queries: ownQueries.map((p) => ({
       queryKey: ['printerStatus', p.id],
       queryFn: () => api.getPrinterStatus(p.id),
       staleTime: 5000,
     })),
   });
-  const statusByPrinter = useMemo(() => {
-    const map = new Map<number, PrinterStatus | undefined>();
-    printers.forEach((p, i) => {
+  const fetchedStatuses = useMemo(() => {
+    const map = new Map<number, CameraWallStatus | undefined>();
+    ownQueries.forEach((p, i) => {
       map.set(p.id, statusQueries[i]?.data);
     });
     return map;
-  }, [printers, statusQueries]);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [printers, statusQueries, statuses]);
+  const statusByPrinter = statuses ?? fetchedStatuses;
   const [visibleIds, setVisibleIds] = useState<Set<number>>(() => new Set());
   const [showSettings, setShowSettings] = useState(false);
   const settingsRef = useRef<HTMLDivElement | null>(null);
@@ -135,6 +176,9 @@ export function CameraWall({
             total: printers.length,
           })}
         </span>
+        {/* Not merely hidden — a kiosk wall must not carry a focusable control
+            it cannot act on. CSS-hiding would leave it tabbable. */}
+        {settingsEnabled && (
         <div className="relative" ref={settingsRef}>
           <button
             type="button"
@@ -224,6 +268,7 @@ export function CameraWall({
             </div>
           )}
         </div>
+        )}
       </div>
 
       <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
@@ -258,7 +303,7 @@ export function CameraWall({
                 hmsErrorCount={
                   filterKnownHMSErrors(statusByPrinter.get(p.id)?.hms_errors ?? []).length
                 }
-                onClick={() => onTileClick(p.id, p.name)}
+                onClick={onTileClick ? () => onTileClick(p.id, p.name) : undefined}
               />
             </div>
           );

+ 20 - 1
frontend/src/i18n/locales/de.ts

@@ -196,10 +196,16 @@ export default {
       extraLarge: 'Extra große Karten',
     },
     pageView: {
+      openCamWallPage: 'Kamera-Wand als Seite öffnen',
       cards: 'Karten',
       camWall: 'Kamera-Wand',
     },
     camWall: {
+      page: {
+        tokenRejected:
+          'Dieser Kamera-Wand-Link ist nicht mehr gültig. Das Token ist möglicherweise abgelaufen oder wurde widerrufen.',
+        loadFailed: 'Die Drucker konnten nicht geladen werden.',
+      },
       noPrinters: 'Keine Drucker anzuzeigen',
       noSignal: 'Kein Signal',
       live: 'Live',
@@ -6637,10 +6643,14 @@ export default {
     saveFailed: 'Einstellungen konnten nicht gespeichert werden.',
   },
   cameraTokens: {
+    scope: {
+      camera_stream: 'Kamera-Stream',
+      camwall: 'Kamera-Wand',
+    },
     title: 'Kamera-API-Tokens',
     navTitle: 'Kamera-API-Tokens',
     description:
-      'Langlebige Tokens zum Einbetten des Kamerastreams in Home Assistant, Frigate, Kioske oder andere Tools, die eine stabile URL benötigen. Jeder Token ist nur für den Kamerastream und kann jederzeit widerrufen werden.',
+      'Langlebige Tokens, um den Kamera-Stream in Home Assistant, Frigate, Kiosk-Bildschirme oder jedes andere Werkzeug einzubinden, das eine stabile Adresse braucht. Der Geltungsbereich wird beim Erstellen gewählt; ein Token lässt sich jederzeit widerrufen.',
     loading: 'Laden…',
     confirmRevoke: {
       title: 'Dieses Token widerrufen?',
@@ -6649,6 +6659,11 @@ export default {
       confirm: 'Widerrufen',
     },
     create: {
+      scopeLabel: 'Geltungsbereich',
+      hintCameraStream:
+        'Ein Kamera-Stream-Token kann ausschließlich Kamera-Streams und Schnappschüsse abrufen. Geeignet für Home Assistant, Frigate oder alles, was eine einzelne Kamera einbettet.',
+      hintCamWall:
+        'Ein Kamera-Wand-Token öffnet /camwall auf einem Bildschirm ohne Anmeldung. Es sieht Name und Status jedes Druckers sowie deren Kamera-Streams. Dateinamen, Adressen und Zugangscodes sieht es nicht.',
       title: 'Neues Token erstellen',
       nameLabel: 'Token-Name',
       namePlaceholder: 'z. B. Home Assistant',
@@ -6658,6 +6673,9 @@ export default {
         'Maximale Lebensdauer 365 Tage. Der Token-Wert wird nur einmal bei der Erstellung angezeigt – jetzt kopieren.',
     },
     created: {
+      camWallUrlTitle: 'Kamera-Wand-Adresse für diesen Bildschirm',
+      camWallUrlHint:
+        'Diese Adresse auf dem Bildschirm öffnen. Wer die Adresse lesen kann, kann die Kamera-Wand sehen — behandeln Sie sie wie einen Schlüssel. Widerrufen Sie das Token, um den Bildschirm abzuschalten.',
       title: 'Token erstellt – jetzt kopieren',
       warning:
         'Dies ist das einzige Mal, dass dieser Token sichtbar ist. Nach dem Schließen dieses Dialogs können Sie ihn nie wieder anzeigen.',
@@ -6665,6 +6683,7 @@ export default {
       dismiss: 'Ich habe es gespeichert',
     },
     list: {
+      scope: 'Geltungsbereich',
       myTitle: 'Meine Tokens',
       allTitle: 'Alle Benutzer (Admin-Ansicht)',
       empty: 'Noch keine Tokens.',

+ 20 - 1
frontend/src/i18n/locales/en.ts

@@ -196,10 +196,16 @@ export default {
       extraLarge: 'Extra large cards',
     },
     pageView: {
+      openCamWallPage: 'Open cam wall as page',
       cards: 'Cards',
       camWall: 'Cam wall',
     },
     camWall: {
+      page: {
+        tokenRejected:
+          'This Cam Wall link is no longer valid. The token may have expired or been revoked.',
+        loadFailed: 'Could not load the printers.',
+      },
       noPrinters: 'No printers to show',
       noSignal: 'No signal',
       live: 'Live',
@@ -6681,10 +6687,14 @@ export default {
     saveFailed: 'Could not save auto-purge settings.',
   },
   cameraTokens: {
+    scope: {
+      camera_stream: 'Camera stream',
+      camwall: 'Cam Wall',
+    },
     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.',
+      'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Pick the scope when you create one; a token can be revoked at any time.',
     loading: 'Loading…',
     confirmRevoke: {
       title: 'Revoke this token?',
@@ -6693,6 +6703,11 @@ export default {
       confirm: 'Revoke',
     },
     create: {
+      scopeLabel: 'Scope',
+      hintCameraStream:
+        'A camera-stream token can only fetch camera streams and snapshots. Use it for Home Assistant, Frigate, or anything embedding a single camera.',
+      hintCamWall:
+        "A Cam Wall token opens /camwall on a screen with no login. It can see every printer's name and state, and their camera streams. It cannot see filenames, addresses or access codes.",
       title: 'Create new token',
       nameLabel: 'Token name',
       namePlaceholder: 'e.g. Home Assistant',
@@ -6702,6 +6717,9 @@ export default {
         'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
     },
     created: {
+      camWallUrlTitle: 'Cam Wall URL for this display',
+      camWallUrlHint:
+        'Open this on the screen. Anyone who can read the URL can watch the wall, so treat it like a key — revoke the token to cut the display off.',
       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.',
@@ -6709,6 +6727,7 @@ export default {
       dismiss: "I've saved it",
     },
     list: {
+      scope: 'Scope',
       myTitle: 'My tokens',
       allTitle: 'All users (admin view)',
       empty: 'No tokens yet.',

+ 20 - 1
frontend/src/i18n/locales/es.ts

@@ -196,10 +196,16 @@ export default {
       extraLarge: 'Tarjetas extragrandes',
     },
     pageView: {
+      openCamWallPage: 'Abrir el muro de cámaras como página',
       cards: 'Tarjetas',
       camWall: 'Muro de cámaras',
     },
     camWall: {
+      page: {
+        tokenRejected:
+          'Este enlace del muro de cámaras ya no es válido. Es posible que el token haya caducado o se haya revocado.',
+        loadFailed: 'No se han podido cargar las impresoras.',
+      },
       noPrinters: 'No hay impresoras que mostrar',
       noSignal: 'Sin señal',
       live: 'En vivo',
@@ -6646,10 +6652,14 @@ export default {
     saveFailed: 'No se pudieron guardar los ajustes de purga automática.',
   },
   cameraTokens: {
+    scope: {
+      camera_stream: 'Transmisión de cámara',
+      camwall: 'Muro de cámaras',
+    },
     title: 'Tokens de API de la cámara',
     navTitle: 'Tokens de API de la cámara',
     description:
-      'Tokens de larga duración para incrustar la transmisión de la cámara en Home Assistant, Frigate, quioscos o cualquier otra herramienta que necesite una URL estable. Cada token es exclusivo de la transmisión de la cámara y se puede revocar en cualquier momento.',
+      'Tokens de larga duración para incrustar la transmisión de la cámara en Home Assistant, Frigate, pantallas en modo quiosco o cualquier otra herramienta que necesite una dirección estable. El alcance se elige al crearlos; un token se puede revocar en cualquier momento.',
     loading: 'Cargando…',
     confirmRevoke: {
       title: '¿Revocar este token?',
@@ -6658,6 +6668,11 @@ export default {
       confirm: 'Revocar',
     },
     create: {
+      scopeLabel: 'Alcance',
+      hintCameraStream:
+        'Un token de transmisión de cámara solo puede obtener transmisiones e instantáneas. Úsalo para Home Assistant, Frigate o cualquier cosa que incruste una sola cámara.',
+      hintCamWall:
+        'Un token de muro de cámaras abre /camwall en una pantalla sin iniciar sesión. Puede ver el nombre y el estado de cada impresora, y sus transmisiones de cámara. No puede ver nombres de archivo, direcciones ni códigos de acceso.',
       title: 'Crear nuevo token',
       nameLabel: 'Nombre del token',
       namePlaceholder: 'p. ej. Home Assistant',
@@ -6667,6 +6682,9 @@ export default {
         'La vida útil máxima es de 365 días. El valor del token se muestra solo una vez al crearlo — cópielo ahora.',
     },
     created: {
+      camWallUrlTitle: 'Dirección del muro de cámaras para esta pantalla',
+      camWallUrlHint:
+        'Abre esta dirección en la pantalla. Cualquiera que pueda leerla puede ver el muro, así que trátala como una llave: revoca el token para dejar la pantalla sin acceso.',
       title: 'Token creado — cópielo ahora',
       warning:
         'Esta es la única vez que este token estará visible. Después de cerrar este diálogo no podrá volver a verlo nunca.',
@@ -6674,6 +6692,7 @@ export default {
       dismiss: 'Lo he guardado',
     },
     list: {
+      scope: 'Alcance',
       myTitle: 'Mis tokens',
       allTitle: 'Todos los usuarios (vista de administración)',
       empty: 'Aún no hay tokens.',

+ 20 - 1
frontend/src/i18n/locales/fr.ts

@@ -196,10 +196,16 @@ export default {
       extraLarge: 'Très grandes cartes',
     },
     pageView: {
+      openCamWallPage: 'Ouvrir le mur de caméras en pleine page',
       cards: 'Cartes',
       camWall: 'Mur de caméras',
     },
     camWall: {
+      page: {
+        tokenRejected:
+          "Ce lien du mur de caméras n'est plus valide. Le jeton a peut-être expiré ou été révoqué.",
+        loadFailed: 'Impossible de charger les imprimantes.',
+      },
       noPrinters: 'Aucune imprimante à afficher',
       noSignal: 'Aucun signal',
       live: 'En direct',
@@ -6625,10 +6631,14 @@ export default {
     saveFailed: 'Impossible d\'enregistrer les paramètres de purge automatique.',
   },
   cameraTokens: {
+    scope: {
+      camera_stream: 'Flux de caméra',
+      camwall: 'Mur de caméras',
+    },
     title: 'Jetons API caméra',
     navTitle: 'Jetons API caméra',
     description:
-      'Jetons longue durée pour intégrer le flux caméra dans Home Assistant, Frigate, kiosques ou tout autre outil nécessitant une URL stable. Chaque jeton est limité au flux caméra et peut être révoqué à tout moment.',
+      'Jetons de longue durée pour intégrer le flux de caméra dans Home Assistant, Frigate, des écrans en mode kiosque ou tout autre outil nécessitant une adresse stable. La portée se choisit à la création ; un jeton peut être révoqué à tout moment.',
     loading: 'Chargement…',
     confirmRevoke: {
       title: 'Révoquer ce jeton ?',
@@ -6637,6 +6647,11 @@ export default {
       confirm: 'Révoquer',
     },
     create: {
+      scopeLabel: 'Portée',
+      hintCameraStream:
+        'Un jeton de flux de caméra ne peut récupérer que des flux et des instantanés. À utiliser pour Home Assistant, Frigate ou tout ce qui intègre une seule caméra.',
+      hintCamWall:
+        "Un jeton Mur de caméras ouvre /camwall sur un écran sans connexion. Il voit le nom et l'état de chaque imprimante, ainsi que leurs flux de caméra. Il ne voit ni les noms de fichiers, ni les adresses, ni les codes d'accès.",
       title: 'Créer un nouveau jeton',
       nameLabel: 'Nom du jeton',
       namePlaceholder: 'par ex. Home Assistant',
@@ -6646,6 +6661,9 @@ export default {
         'Durée de vie maximale 365 jours. La valeur du jeton n\'est affichée qu\'une seule fois — copiez-la maintenant.',
     },
     created: {
+      camWallUrlTitle: 'Adresse du mur de caméras pour cet écran',
+      camWallUrlHint:
+        "Ouvrez cette adresse sur l'écran. Quiconque peut lire l'adresse peut regarder le mur : traitez-la comme une clé. Révoquez le jeton pour couper l'écran.",
       title: 'Jeton créé – copiez-le maintenant',
       warning:
         'C\'est la seule fois où ce jeton sera visible. Après la fermeture de ce dialogue, vous ne pourrez plus jamais le voir.',
@@ -6653,6 +6671,7 @@ export default {
       dismiss: 'Je l\'ai enregistré',
     },
     list: {
+      scope: 'Portée',
       myTitle: 'Mes jetons',
       allTitle: 'Tous les utilisateurs (vue admin)',
       empty: 'Aucun jeton pour le moment.',

+ 20 - 1
frontend/src/i18n/locales/it.ts

@@ -196,10 +196,16 @@ export default {
       extraLarge: 'Schede extra grandi',
     },
     pageView: {
+      openCamWallPage: 'Apri il muro telecamere come pagina',
       cards: 'Schede',
       camWall: 'Muro telecamere',
     },
     camWall: {
+      page: {
+        tokenRejected:
+          'Questo link al muro telecamere non è più valido. Il token potrebbe essere scaduto o essere stato revocato.',
+        loadFailed: 'Impossibile caricare le stampanti.',
+      },
       noPrinters: 'Nessuna stampante da mostrare',
       noSignal: 'Nessun segnale',
       live: 'Live',
@@ -6624,10 +6630,14 @@ export default {
     saveFailed: 'Impossibile salvare le impostazioni di pulizia automatica.',
   },
   cameraTokens: {
+    scope: {
+      camera_stream: 'Flusso della telecamera',
+      camwall: 'Muro telecamere',
+    },
     title: 'Token API telecamera',
     navTitle: 'Token API telecamera',
     description:
-      'Token a lunga durata per incorporare lo stream della telecamera in Home Assistant, Frigate, chioschi o qualsiasi altro strumento che richieda un URL stabile. Ogni token è limitato allo stream della telecamera e può essere revocato in qualsiasi momento.',
+      "Token di lunga durata per incorporare il flusso della telecamera in Home Assistant, Frigate, schermi in modalità chiosco o qualsiasi altro strumento che richieda un indirizzo stabile. L'ambito si sceglie alla creazione; un token può essere revocato in qualsiasi momento.",
     loading: 'Caricamento…',
     confirmRevoke: {
       title: 'Revocare questo token?',
@@ -6636,6 +6646,11 @@ export default {
       confirm: 'Revoca',
     },
     create: {
+      scopeLabel: 'Ambito',
+      hintCameraStream:
+        'Un token del flusso della telecamera può recuperare soltanto flussi e istantanee. Usalo per Home Assistant, Frigate o qualsiasi cosa incorpori una singola telecamera.',
+      hintCamWall:
+        'Un token Muro telecamere apre /camwall su uno schermo senza login. Vede nome e stato di ogni stampante e i relativi flussi della telecamera. Non vede nomi di file, indirizzi o codici di accesso.',
       title: 'Crea nuovo token',
       nameLabel: 'Nome token',
       namePlaceholder: 'es. Home Assistant',
@@ -6645,6 +6660,9 @@ export default {
         'Durata massima 365 giorni. Il valore del token viene mostrato solo alla creazione — copialo ora.',
     },
     created: {
+      camWallUrlTitle: 'Indirizzo del muro telecamere per questo schermo',
+      camWallUrlHint:
+        'Apri questo indirizzo sullo schermo. Chiunque possa leggerlo può guardare il muro, quindi trattalo come una chiave: revoca il token per escludere lo schermo.',
       title: 'Token creato – copialo ora',
       warning:
         'Questa è l\'unica volta in cui questo token sarà visibile. Dopo la chiusura di questa finestra non potrai più visualizzarlo.',
@@ -6652,6 +6670,7 @@ export default {
       dismiss: 'L\'ho salvato',
     },
     list: {
+      scope: 'Ambito',
       myTitle: 'I miei token',
       allTitle: 'Tutti gli utenti (vista admin)',
       empty: 'Nessun token ancora.',

+ 20 - 1
frontend/src/i18n/locales/ja.ts

@@ -195,10 +195,16 @@ export default {
       extraLarge: '特大',
     },
     pageView: {
+      openCamWallPage: 'カメラウォールをページとして開く',
       cards: 'カード',
       camWall: 'カメラウォール',
     },
     camWall: {
+      page: {
+        tokenRejected:
+          'このカメラウォールのリンクは無効です。トークンの有効期限が切れたか、取り消された可能性があります。',
+        loadFailed: 'プリンターを読み込めませんでした。',
+      },
       noPrinters: '表示するプリンターがありません',
       noSignal: '信号なし',
       live: 'ライブ',
@@ -6636,10 +6642,14 @@ export default {
     saveFailed: '自動削除設定を保存できませんでした。',
   },
   cameraTokens: {
+    scope: {
+      camera_stream: 'カメラストリーム',
+      camwall: 'カメラウォール',
+    },
     title: 'カメラAPIトークン',
     navTitle: 'カメラAPIトークン',
     description:
-      'Home Assistant、Frigate、キオスク、その他安定したURLが必要なツールにカメラストリームを埋め込むための長期トークン。各トークンはカメラストリーム専用で、いつでも取り消し可能。',
+      'Home Assistant、Frigate、キオスク画面など、安定した URL を必要とするツールにカメラ映像を埋め込むための長期トークンです。スコープは作成時に選択し、トークンはいつでも取り消せます。',
     loading: '読み込み中…',
     confirmRevoke: {
       title: 'このトークンを取り消しますか?',
@@ -6648,6 +6658,11 @@ export default {
       confirm: '取り消し',
     },
     create: {
+      scopeLabel: 'スコープ',
+      hintCameraStream:
+        'カメラストリームトークンで取得できるのは、カメラの映像とスナップショットだけです。Home Assistant や Frigate など、単一のカメラを埋め込む用途に使用してください。',
+      hintCamWall:
+        'カメラウォールトークンは、ログインなしの画面で /camwall を開きます。各プリンターの名前と状態、そしてカメラ映像を見ることができます。ファイル名、アドレス、アクセスコードは見えません。',
       title: '新しいトークンを作成',
       nameLabel: 'トークン名',
       namePlaceholder: '例:Home Assistant',
@@ -6657,6 +6672,9 @@ export default {
         '最大有効期間は365日。トークン値は作成時に一度だけ表示されます — 今すぐコピーしてください。',
     },
     created: {
+      camWallUrlTitle: 'この画面用のカメラウォール URL',
+      camWallUrlHint:
+        'この URL を画面で開いてください。URL を読める人は誰でもウォールを見られるため、鍵と同じように扱ってください。トークンを取り消すと、その画面は遮断されます。',
       title: 'トークンを作成しました – 今すぐコピー',
       warning:
         'このトークンが表示されるのは今回限りです。このダイアログを閉じると二度と表示できません。',
@@ -6664,6 +6682,7 @@ export default {
       dismiss: '保存しました',
     },
     list: {
+      scope: 'スコープ',
       myTitle: 'マイトークン',
       allTitle: '全ユーザー(管理者ビュー)',
       empty: 'トークンはまだありません。',

+ 21 - 1
frontend/src/i18n/locales/ko.ts

@@ -183,10 +183,16 @@ export default {
       extraLarge: '아주 큰 카드'
     },
     pageView: {
+      openCamWallPage: '카메라 월을 페이지로 열기',
       cards: '카드',
       camWall: '카메라 월'
     },
     camWall: {
+      page: {
+        tokenRejected:
+          '이 카메라 월 링크는 더 이상 유효하지 않습니다. 토큰이 만료되었거나 취소되었을 수 있습니다.',
+        loadFailed: '프린터를 불러오지 못했습니다.',
+      },
       noPrinters: '표시할 프린터가 없습니다',
       noSignal: '신호 없음',
       live: '라이브',
@@ -6106,9 +6112,14 @@ export default {
     purgeStatsDescription: '활성화되면 일일 정리 작업도 각 삭제된 아카이브를 빠른 통계(필라멘트, 시간, 비용, 에너지)에서 제거합니다. 기본값 비활성화 — 빠른 통계는 기여를 유지하고 파일만 디스크에서 제거됩니다.'
   },
   cameraTokens: {
+    scope: {
+      camera_stream: '카메라 스트림',
+      camwall: '카메라 월',
+    },
     title: '카메라 API 토큰',
     navTitle: '카메라 API 토큰',
-    description: 'Home Assistant, Frigate, 키오스크 또는 안정적인 URL이 필요한 다른 도구에 카메라 스트림을 내장하기 위한 장기 토큰. 각 토큰은 카메라 스트림 전용이며 언제든지 취소할 수 있습니다.',
+    description:
+      'Home Assistant, Frigate, 키오스크 화면 등 안정적인 주소가 필요한 도구에 카메라 스트림을 삽입하기 위한 장기 토큰입니다. 범위는 생성할 때 선택하며, 토큰은 언제든지 취소할 수 있습니다.',
     loading: '불러오는 중…',
     confirmRevoke: {
       title: '이 토큰을 취소하시겠습니까?',
@@ -6117,6 +6128,11 @@ export default {
       confirm: '취소'
     },
     create: {
+      scopeLabel: '범위',
+      hintCameraStream:
+        '카메라 스트림 토큰은 카메라 스트림과 스냅숏만 가져올 수 있습니다. Home Assistant, Frigate 등 카메라 하나를 삽입하는 용도로 사용하세요.',
+      hintCamWall:
+        '카메라 월 토큰은 로그인 없이 화면에서 /camwall을 엽니다. 모든 프린터의 이름과 상태, 카메라 스트림을 볼 수 있습니다. 파일 이름, 주소, 액세스 코드는 볼 수 없습니다.',
       title: '새 토큰 만들기',
       nameLabel: '토큰 이름',
       namePlaceholder: '예: Home Assistant',
@@ -6125,12 +6141,16 @@ export default {
       hint: '최대 수명은 365일입니다. 토큰 값은 생성 시 한 번만 표시됩니다 — 지금 복사하세요.'
     },
     created: {
+      camWallUrlTitle: '이 화면용 카메라 월 주소',
+      camWallUrlHint:
+        '이 주소를 화면에서 여세요. 주소를 읽을 수 있는 사람은 누구나 월을 볼 수 있으므로 열쇠처럼 다루세요. 토큰을 취소하면 해당 화면의 접근이 차단됩니다.',
       title: '토큰 생성됨 — 지금 복사하세요',
       warning: '이 토큰은 이 번만 볼 수 있습니다. 이 대화상자를 닫으면 다시는 볼 수 없습니다.',
       copy: '복사',
       dismiss: '저장했습니다'
     },
     list: {
+      scope: '범위',
       myTitle: '내 토큰',
       allTitle: '모든 사용자 (관리자 보기)',
       empty: '아직 토큰 없음.',

+ 20 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -196,10 +196,16 @@ export default {
       extraLarge: 'Cartões extra grandes',
     },
     pageView: {
+      openCamWallPage: 'Abrir o mural de câmeras como página',
       cards: 'Cartões',
       camWall: 'Mural de câmeras',
     },
     camWall: {
+      page: {
+        tokenRejected:
+          'Este link do mural de câmeras não é mais válido. O token pode ter expirado ou sido revogado.',
+        loadFailed: 'Não foi possível carregar as impressoras.',
+      },
       noPrinters: 'Nenhuma impressora para exibir',
       noSignal: 'Sem sinal',
       live: 'Ao vivo',
@@ -6624,10 +6630,14 @@ export default {
     saveFailed: 'Não foi possível salvar as configurações de limpeza automática.',
   },
   cameraTokens: {
+    scope: {
+      camera_stream: 'Transmissão da câmera',
+      camwall: 'Mural de câmeras',
+    },
     title: 'Tokens da API de câmera',
     navTitle: 'Tokens da API de câmera',
     description:
-      'Tokens de longa duração para incorporar o stream da câmera no Home Assistant, Frigate, quiosques ou qualquer outra ferramenta que precise de URL estável. Cada token é apenas para stream de câmera e pode ser revogado a qualquer momento.',
+      'Tokens de longa duração para incorporar a transmissão da câmera no Home Assistant, no Frigate, em telas em modo quiosque ou em qualquer outra ferramenta que precise de um endereço estável. O escopo é escolhido na criação; um token pode ser revogado a qualquer momento.',
     loading: 'Carregando…',
     confirmRevoke: {
       title: 'Revogar este token?',
@@ -6636,6 +6646,11 @@ export default {
       confirm: 'Revogar',
     },
     create: {
+      scopeLabel: 'Escopo',
+      hintCameraStream:
+        'Um token de transmissão da câmera só consegue buscar transmissões e instantâneos. Use-o no Home Assistant, no Frigate ou em qualquer coisa que incorpore uma única câmera.',
+      hintCamWall:
+        'Um token do mural de câmeras abre /camwall em uma tela sem login. Ele vê o nome e o estado de cada impressora e as transmissões das câmeras. Não vê nomes de arquivo, endereços nem códigos de acesso.',
       title: 'Criar novo token',
       nameLabel: 'Nome do token',
       namePlaceholder: 'ex. Home Assistant',
@@ -6645,6 +6660,9 @@ export default {
         'Tempo de vida máximo 365 dias. O valor do token é exibido apenas na criação — copie agora.',
     },
     created: {
+      camWallUrlTitle: 'Endereço do mural de câmeras para esta tela',
+      camWallUrlHint:
+        'Abra este endereço na tela. Qualquer pessoa que consiga lê-lo pode assistir ao painel, então trate-o como uma chave: revogue o token para cortar o acesso da tela.',
       title: 'Token criado – copie agora',
       warning:
         'Esta é a única vez que este token será visível. Após fechar este diálogo, você nunca poderá vê-lo novamente.',
@@ -6652,6 +6670,7 @@ export default {
       dismiss: 'Eu salvei',
     },
     list: {
+      scope: 'Escopo',
       myTitle: 'Meus tokens',
       allTitle: 'Todos os usuários (visão admin)',
       empty: 'Nenhum token ainda.',

+ 20 - 1
frontend/src/i18n/locales/tr.ts

@@ -196,10 +196,16 @@ export default {
       extraLarge: 'Çok büyük kartlar',
     },
     pageView: {
+      openCamWallPage: 'Kamera duvarını sayfa olarak aç',
       cards: 'Kartlar',
       camWall: 'Kamera duvarı',
     },
     camWall: {
+      page: {
+        tokenRejected:
+          'Bu kamera duvarı bağlantısı artık geçerli değil. Belirtecin süresi dolmuş veya belirteç iptal edilmiş olabilir.',
+        loadFailed: 'Yazıcılar yüklenemedi.',
+      },
       noPrinters: 'Gösterilecek yazıcı yok',
       noSignal: 'Sinyal yok',
       live: 'Canlı',
@@ -6577,10 +6583,14 @@ export default {
     saveFailed: 'Otomatik temizleme ayarları kaydedilemedi.',
   },
   cameraTokens: {
+    scope: {
+      camera_stream: 'Kamera akışı',
+      camwall: 'Kamera duvarı',
+    },
     title: 'Kamera API Belirteçleri',
     navTitle: 'Kamera API belirteçleri',
     description:
-      'Kamera akışını Home Assistant, Frigate, kiosklar veya kararlı bir URL\'ye ihtiyaç duyan başka herhangi bir araca gömmek için uzun ömürlü belirteçler. Her belirteç yalnızca kamera akışı içindir ve herhangi bir zamanda iptal edilebilir.',
+      'Kamera akışını Home Assistant, Frigate, kiosk ekranları veya sabit bir adres gerektiren başka araçlara gömmek için uzun ömürlü belirteçler. Kapsam oluşturma sırasında seçilir; belirteç istediğiniz zaman iptal edilebilir.',
     loading: 'Yükleniyor…',
     confirmRevoke: {
       title: 'Bu belirteç iptal edilsin mi?',
@@ -6589,6 +6599,11 @@ export default {
       confirm: 'İptal Et',
     },
     create: {
+      scopeLabel: 'Kapsam',
+      hintCameraStream:
+        'Kamera akışı belirteci yalnızca kamera akışlarını ve anlık görüntüleri alabilir. Home Assistant, Frigate veya tek bir kamerayı gömen her şey için kullanın.',
+      hintCamWall:
+        'Kamera duvarı belirteci, oturum açmadan bir ekranda /camwall adresini açar. Her yazıcının adını ve durumunu, ayrıca kamera akışlarını görebilir. Dosya adlarını, adresleri veya erişim kodlarını göremez.',
       title: 'Yeni belirteç oluştur',
       nameLabel: 'Belirteç adı',
       namePlaceholder: 'örn. Home Assistant',
@@ -6598,6 +6613,9 @@ export default {
         'Maksimum ömür 365 gün. Belirteç değeri oluşturmada yalnızca bir kez gösterilir — şimdi kopyalayın.',
     },
     created: {
+      camWallUrlTitle: 'Bu ekran için kamera duvarı adresi',
+      camWallUrlHint:
+        'Bu adresi ekranda açın. Adresi okuyabilen herkes duvarı izleyebilir, bu yüzden onu bir anahtar gibi görün; ekranın erişimini kesmek için belirteci iptal edin.',
       title: 'Belirteç oluşturuldu — şimdi kopyalayın',
       warning:
         'Bu, bu belirtecin görünür olacağı tek seferdir. Bu iletişim kutusunu kapattıktan sonra onu bir daha asla görüntüleyemezsiniz.',
@@ -6605,6 +6623,7 @@ export default {
       dismiss: 'Kaydettim',
     },
     list: {
+      scope: 'Kapsam',
       myTitle: 'Belirteçlerim',
       allTitle: 'Tüm kullanıcılar (yönetici görünümü)',
       empty: 'Henüz belirteç yok.',

+ 20 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -196,10 +196,16 @@ export default {
       extraLarge: '超大卡片',
     },
     pageView: {
+      openCamWallPage: '在独立页面中打开摄像头墙',
       cards: '卡片',
       camWall: '摄像头墙',
     },
     camWall: {
+      page: {
+        tokenRejected:
+          '此摄像头墙链接已失效。令牌可能已过期或被撤销。',
+        loadFailed: '无法加载打印机。',
+      },
       noPrinters: '没有可显示的打印机',
       noSignal: '无信号',
       live: '直播',
@@ -6623,10 +6629,14 @@ export default {
     saveFailed: '无法保存自动清除设置。',
   },
   cameraTokens: {
+    scope: {
+      camera_stream: '摄像头视频流',
+      camwall: '摄像头墙',
+    },
     title: '摄像头 API 令牌',
     navTitle: '摄像头 API 令牌',
     description:
-      '长期令牌,用于将摄像头流嵌入 Home Assistant、Frigate、信息亭或其他需要稳定 URL 的工具。每个令牌仅限摄像头流,可随时撤销。',
+      '长期令牌,用于将摄像头视频流嵌入 Home Assistant、Frigate、自助展示屏或其他需要稳定网址的工具。权限范围在创建时选择,令牌可随时撤销。',
     loading: '加载中…',
     confirmRevoke: {
       title: '撤销此令牌?',
@@ -6635,6 +6645,11 @@ export default {
       confirm: '撤销',
     },
     create: {
+      scopeLabel: '权限范围',
+      hintCameraStream:
+        '摄像头视频流令牌只能获取摄像头视频流和快照。适用于 Home Assistant、Frigate 或任何嵌入单个摄像头的场景。',
+      hintCamWall:
+        '摄像头墙令牌可在无需登录的屏幕上打开 /camwall,能看到每台打印机的名称和状态以及摄像头视频流,但看不到文件名、地址或访问码。',
       title: '创建新令牌',
       nameLabel: '令牌名称',
       namePlaceholder: '例如 Home Assistant',
@@ -6644,6 +6659,9 @@ export default {
         '最大有效期 365 天。令牌值仅在创建时显示一次 — 请立即复制。',
     },
     created: {
+      camWallUrlTitle: '此屏幕的摄像头墙网址',
+      camWallUrlHint:
+        '在屏幕上打开此网址。任何能看到该网址的人都能观看摄像头墙,请像对待钥匙一样对待它——撤销令牌即可切断该屏幕的访问。',
       title: '令牌已创建 — 立即复制',
       warning:
         '这是此令牌唯一一次可见。关闭此对话框后您将无法再次查看。',
@@ -6651,6 +6669,7 @@ export default {
       dismiss: '我已保存',
     },
     list: {
+      scope: '权限范围',
       myTitle: '我的令牌',
       allTitle: '所有用户(管理员视图)',
       empty: '暂无令牌。',

+ 20 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -196,10 +196,16 @@ export default {
       extraLarge: '超大卡片',
     },
     pageView: {
+      openCamWallPage: '在獨立頁面中開啟攝影機牆',
       cards: '卡片',
       camWall: '攝影機牆',
     },
     camWall: {
+      page: {
+        tokenRejected:
+          '此攝影機牆連結已失效。權杖可能已過期或遭撤銷。',
+        loadFailed: '無法載入印表機。',
+      },
       noPrinters: '沒有可顯示的印表機',
       noSignal: '無訊號',
       live: '直播',
@@ -6623,10 +6629,14 @@ export default {
     saveFailed: '無法儲存自動清除設定。',
   },
   cameraTokens: {
+    scope: {
+      camera_stream: '攝影機串流',
+      camwall: '攝影機牆',
+    },
     title: '攝影機 API 權杖',
     navTitle: '攝影機 API 權杖',
     description:
-      '長期權杖,用於將攝影機串流嵌入 Home Assistant、Frigate、資訊站或其他需要穩定 URL 的工具。每個權杖僅限攝影機串流,可隨時撤銷。',
+      '長期權杖,用於將攝影機串流嵌入 Home Assistant、Frigate、自助展示螢幕或其他需要穩定網址的工具。權限範圍在建立時選擇,權杖可隨時撤銷。',
     loading: '載入中…',
     confirmRevoke: {
       title: '撤銷此權杖?',
@@ -6635,6 +6645,11 @@ export default {
       confirm: '撤銷',
     },
     create: {
+      scopeLabel: '權限範圍',
+      hintCameraStream:
+        '攝影機串流權杖只能取得攝影機串流與快照。適用於 Home Assistant、Frigate 或任何嵌入單一攝影機的情境。',
+      hintCamWall:
+        '攝影機牆權杖可在無須登入的螢幕上開啟 /camwall,能看到每台印表機的名稱與狀態以及攝影機串流,但看不到檔案名稱、位址或存取碼。',
       title: '建立新權杖',
       nameLabel: '權杖名稱',
       namePlaceholder: '例如 Home Assistant',
@@ -6644,6 +6659,9 @@ export default {
         '最大有效期 365 天。權杖值僅在建立時顯示一次 — 請立即複製。',
     },
     created: {
+      camWallUrlTitle: '此螢幕的攝影機牆網址',
+      camWallUrlHint:
+        '在螢幕上開啟此網址。任何能看到該網址的人都能觀看攝影機牆,請像對待鑰匙一樣對待它——撤銷權杖即可切斷該螢幕的存取。',
       title: '權杖已建立 — 立即複製',
       warning:
         '這是此權杖唯一一次可見。關閉此對話框後您將無法再次查看。',
@@ -6651,6 +6669,7 @@ export default {
       dismiss: '我已儲存',
     },
     list: {
+      scope: '權限範圍',
       myTitle: '我的權杖',
       allTitle: '所有使用者(管理員視圖)',
       empty: '尚無權杖。',

+ 196 - 0
frontend/src/pages/CamWallPage.tsx

@@ -0,0 +1,196 @@
+/**
+ * Standalone Cam Wall at ``/camwall`` (#2531).
+ *
+ * Two ways in, and they authenticate differently:
+ *
+ * - **Signed in.** Same wall the Printers page shows, on a URL you can
+ *   bookmark. Data comes from the ordinary printers API behind the session
+ *   JWT, and tiles stay clickable.
+ *
+ * - **``?token=<camwall token>``.** For a screen with no login — a shop TV, a
+ *   Raspberry Pi in kiosk mode. The token authenticates both the tile feed and
+ *   the video, and the wall drops to what a passive display needs: no settings
+ *   popover, no click-through, and the compact status overlay only. A URL taped
+ *   to a wall display is not a secret, so the page behind it shows a printer's
+ *   name and state and nothing else — never the filename of the part on the bed.
+ *
+ * There is no WebSocket in either mode: this page renders outside the app
+ * layout and its provider, so statuses are polled. A wall is watched, not
+ * operated — a few seconds of latency costs nothing, and a kiosk token cannot
+ * mint the WS ticket anyway.
+ */
+import { useEffect, useMemo, useState } from 'react';
+import { Navigate, useLocation, useSearchParams } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { CameraWall, type CameraWallStatus } from '../components/CameraWall';
+import { type CameraTileStatusMode } from '../components/CameraTile';
+import { api, setStreamToken } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
+
+// Kiosk polling cadence. Matches the staleTime the in-page wall runs at, so a
+// tile's chip is never more stale than it would be on the Printers page.
+const KIOSK_POLL_MS = 5000;
+
+const DEFAULT_MAX_LIVE = 4;
+const DEFAULT_SNAPSHOT_SEC = 8;
+const MIN_MAX_LIVE = 1;
+const MAX_MAX_LIVE = 16;
+const MIN_SNAPSHOT_SEC = 2;
+const MAX_SNAPSHOT_SEC = 60;
+
+function clampInt(raw: string | null, fallback: number, min: number, max: number): number {
+  const n = parseInt(raw ?? '', 10);
+  if (!Number.isFinite(n)) return fallback;
+  return Math.min(max, Math.max(min, n));
+}
+
+/** URL wins, then the knob the user last set on the Printers page, then the default. */
+function fromUrlOrStorage(
+  params: URLSearchParams,
+  urlKey: string,
+  storageKey: string,
+  fallback: number,
+  min: number,
+  max: number,
+): number {
+  if (params.has(urlKey)) return clampInt(params.get(urlKey), fallback, min, max);
+  return clampInt(localStorage.getItem(storageKey), fallback, min, max);
+}
+
+export function CamWallPage() {
+  const { t } = useTranslation();
+  const location = useLocation();
+  const { authEnabled, loading: authLoading, user } = useAuth();
+  const [searchParams] = useSearchParams();
+
+  const token = searchParams.get('token');
+  const kiosk = token != null && token !== '';
+
+  // Seeded from the URL, then from whatever the user last chose on the Printers
+  // page, then the defaults. Held as state so the settings popover on a
+  // signed-in wall actually moves them; a kiosk wall never shows the popover.
+  const [maxLive, setMaxLive] = useState(() =>
+    fromUrlOrStorage(searchParams, 'maxLive', 'camWallMaxLive', DEFAULT_MAX_LIVE, MIN_MAX_LIVE, MAX_MAX_LIVE),
+  );
+  const [snapshotIntervalSec, setSnapshotIntervalSec] = useState(() =>
+    fromUrlOrStorage(
+      searchParams,
+      'interval',
+      'camWallSnapshotSec',
+      DEFAULT_SNAPSHOT_SEC,
+      MIN_SNAPSHOT_SEC,
+      MAX_SNAPSHOT_SEC,
+    ),
+  );
+  const [statusMode, setStatusMode] = useState<CameraTileStatusMode>(() => {
+    const requested = searchParams.get('status') ?? localStorage.getItem('camWallStatusMode');
+    // 'full' names the file on the bed. Fine on a signed-in screen, wrong on one
+    // anybody can walk past — so a kiosk wall caps at 'compact', and the feed
+    // behind it declines to serve the filename in the first place.
+    const allowed: CameraTileStatusMode[] = kiosk ? ['off', 'compact'] : ['off', 'compact', 'full'];
+    return allowed.includes(requested as CameraTileStatusMode)
+      ? (requested as CameraTileStatusMode)
+      : 'compact';
+  });
+
+  // The MJPEG <img> tags cannot send an Authorization header, so they carry the
+  // token in the query string. Handing it to the client module means CameraTile
+  // picks it up through the same withStreamToken() path a signed-in wall uses.
+  // Safe against the app-wide stream-token sync: that query is disabled while
+  // no user is signed in, which is precisely the kiosk case.
+  useEffect(() => {
+    if (!kiosk) return;
+    setStreamToken(token);
+    return () => setStreamToken(null);
+  }, [kiosk, token]);
+
+  const kioskQuery = useQuery({
+    queryKey: ['camwall-printers', token],
+    queryFn: () => api.getCamWallPrinters(token ?? undefined),
+    enabled: kiosk,
+    refetchInterval: KIOSK_POLL_MS,
+  });
+
+  // Signed-in mode: same source as the Printers page, so the wall and the cards
+  // agree. CameraWall fetches the per-printer statuses itself from here.
+  const printersQuery = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+    enabled: !kiosk && !authLoading && (!authEnabled || user !== null),
+    refetchInterval: KIOSK_POLL_MS,
+  });
+
+  const kioskStatuses = useMemo(() => {
+    const map = new Map<number, CameraWallStatus | undefined>();
+    for (const p of kioskQuery.data ?? []) {
+      map.set(p.id, {
+        connected: p.connected,
+        state: p.state,
+        progress: p.progress,
+        remaining_time: p.remaining_time,
+        layer_num: p.layer_num,
+        total_layers: p.total_layers,
+        hms_errors: p.hms_errors,
+      });
+    }
+    return map;
+  }, [kioskQuery.data]);
+
+  if (!kiosk && authLoading) {
+    return (
+      <div className="flex min-h-screen items-center justify-center bg-bambu-dark text-bambu-gray">
+        {t('common.loading')}
+      </div>
+    );
+  }
+
+  // No token and no session: this is just a normal page of the app.
+  if (!kiosk && authEnabled && !user) {
+    return <Navigate to="/login" replace state={{ from: location }} />;
+  }
+
+  const query = kiosk ? kioskQuery : printersQuery;
+  const printers = kiosk ? (kioskQuery.data ?? []) : (printersQuery.data ?? []);
+
+  if (query.isError) {
+    return (
+      <div className="flex min-h-screen items-center justify-center bg-bambu-dark p-6 text-center">
+        <p className="max-w-md text-sm text-red-400">
+          {kiosk ? t('printers.camWall.page.tokenRejected') : t('printers.camWall.page.loadFailed')}
+        </p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="min-h-screen bg-bambu-dark p-4">
+      <CameraWall
+        printers={printers}
+        maxLive={maxLive}
+        snapshotIntervalSec={snapshotIntervalSec}
+        statusMode={statusMode}
+        statuses={kiosk ? kioskStatuses : undefined}
+        showSettings={!kiosk}
+        onTileClick={kiosk ? undefined : (id) => window.open(`/camera/${id}`, `camera-${id}`)}
+        // Writes to the same localStorage keys the Printers page reads, so a
+        // change made here follows the user back there. A kiosk wall hides the
+        // popover, so these never fire.
+        onChangeMaxLive={(next) => {
+          setMaxLive(next);
+          localStorage.setItem('camWallMaxLive', String(next));
+        }}
+        onChangeSnapshotIntervalSec={(next) => {
+          setSnapshotIntervalSec(next);
+          localStorage.setItem('camWallSnapshotSec', String(next));
+        }}
+        onChangeStatusMode={(next) => {
+          setStatusMode(next);
+          localStorage.setItem('camWallStatusMode', next);
+        }}
+      />
+    </div>
+  );
+}
+
+export default CamWallPage;

+ 70 - 7
frontend/src/pages/CameraTokensPage.tsx

@@ -18,7 +18,7 @@
 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 { api, type LongLivedCameraToken, type LongLivedTokenScope } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { parseUTCDate } from '../utils/date';
@@ -46,6 +46,7 @@ function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
   const { showToast } = useToast();
   const [name, setName] = useState('');
   const [days, setDays] = useState<number>(DEFAULT_LIFETIME_DAYS);
+  const [scope, setScope] = useState<LongLivedTokenScope>('camera_stream');
   const [submitting, setSubmitting] = useState(false);
 
   const handleSubmit = async (e: React.FormEvent) => {
@@ -56,10 +57,12 @@ function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
       const created = await api.createLongLivedCameraToken({
         name: name.trim(),
         expires_in_days: days,
+        scope,
       });
       onCreated(created);
       setName('');
       setDays(DEFAULT_LIFETIME_DAYS);
+      setScope('camera_stream');
       showToast(t('cameraTokens.toast.created', 'Token created'));
     } catch (err) {
       showToast(
@@ -79,7 +82,7 @@ function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
       <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]">
+      <div className="grid gap-3 md:grid-cols-[1fr_180px_140px_auto]">
         <input
           type="text"
           maxLength={100}
@@ -90,6 +93,15 @@ function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
           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')}
         />
+        <select
+          value={scope}
+          onChange={(e) => setScope(e.target.value as LongLivedTokenScope)}
+          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.scopeLabel', 'Scope')}
+        >
+          <option value="camera_stream">{t('cameraTokens.scope.camera_stream', 'Camera stream')}</option>
+          <option value="camwall">{t('cameraTokens.scope.camwall', 'Cam Wall')}</option>
+        </select>
         <input
           type="number"
           min={1}
@@ -116,6 +128,17 @@ function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
         </button>
       </div>
       <p className="text-xs text-bambu-gray mt-2">
+        {scope === 'camwall'
+          ? t(
+              'cameraTokens.create.hintCamWall',
+              'A Cam Wall token opens /camwall on a screen with no login — it can see every printer\'s name and state, and their camera streams. It cannot see filenames, addresses or access codes.',
+            )
+          : t(
+              'cameraTokens.create.hintCameraStream',
+              'A camera-stream token can only fetch camera streams and snapshots. Use it for Home Assistant, Frigate, or anything embedding a single camera.',
+            )}
+      </p>
+      <p className="text-xs text-bambu-gray mt-1">
         {t(
           'cameraTokens.create.hint',
           'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
@@ -186,17 +209,25 @@ function JustCreatedModal({ token, onClose }: JustCreatedModalProps) {
   const { showToast } = useToast();
   const plaintext = token.token ?? '';
 
-  const handleCopy = async () => {
-    if (!plaintext) return;
+  // For a Cam Wall token the useful artefact isn't the token, it's the URL you
+  // paste into the kiosk browser. Build it here so nobody has to assemble it by
+  // hand from the docs.
+  const camWallUrl =
+    token.scope === 'camwall' && plaintext
+      ? `${window.location.origin}/camwall?token=${encodeURIComponent(plaintext)}`
+      : null;
+
+  const copyText = async (value: string) => {
+    if (!value) 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);
+        await navigator.clipboard.writeText(value);
       } else {
         const ta = document.createElement('textarea');
-        ta.value = plaintext;
+        ta.value = value;
         ta.style.position = 'fixed';
         ta.style.opacity = '0';
         document.body.appendChild(ta);
@@ -236,13 +267,39 @@ function JustCreatedModal({ token, onClose }: JustCreatedModalProps) {
           </code>
           <button
             type="button"
-            onClick={handleCopy}
+            onClick={() => copyText(plaintext)}
             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>
+        {camWallUrl && (
+          <div className="mb-4">
+            <p className="text-sm font-medium text-white mb-1">
+              {t('cameraTokens.created.camWallUrlTitle', 'Cam Wall URL for this display')}
+            </p>
+            <p className="text-xs text-bambu-gray mb-2">
+              {t(
+                'cameraTokens.created.camWallUrlHint',
+                'Open this on the screen. Anyone who can read the URL can watch the wall, so treat it like a key — revoke the token to cut the display off.',
+              )}
+            </p>
+            <div className="flex items-center gap-2">
+              <code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
+                {camWallUrl}
+              </code>
+              <button
+                type="button"
+                onClick={() => copyText(camWallUrl)}
+                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>
+        )}
         <div className="flex justify-end">
           <button
             type="button"
@@ -271,6 +328,11 @@ function TokenRow({ token, showOwner, ownerLabel, onRevoke }: TokenRowProps) {
     <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">
+        <span className="rounded bg-bambu-dark-tertiary px-2 py-0.5 text-xs text-bambu-gray">
+          {t(`cameraTokens.scope.${token.scope}`, token.scope)}
+        </span>
+      </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-700 dark:text-red-400' : 'text-bambu-gray'}`}>
@@ -317,6 +379,7 @@ function TokenTable({ tokens, showOwner, userIdToName, onRevoke, emptyMessage }:
           <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.scope', 'Scope')}</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>

+ 17 - 1
frontend/src/pages/PrintersPage.tsx

@@ -84,9 +84,11 @@ import {
   LineChart as LineChartIcon,
   LayoutGrid,
   MonitorPlay,
+  ExternalLink,
 } from 'lucide-react';
 
-import { useNavigate } from 'react-router-dom';
+// Aliased: lucide-react already exports a `Link` icon into this module.
+import { Link as RouterLink, useNavigate } from 'react-router-dom';
 import { api, discoveryApi, firmwareApi, withStreamToken, ApiError } from '../api/client';
 import { formatDateOnly, formatETA, formatDuration, parseUTCDate } from '../utils/date';
 import type { Printer, PrinterCreate, PrinterStatus, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError, InventorySpool, SmartPlug, PrinterDiagnosticResult } from '../api/client';
@@ -8462,6 +8464,20 @@ export function PrintersPage() {
         </button>
       </div>
 
+      {/* Cam Wall on its own URL (#2531) — the linkable/bookmarkable form of the
+          view, and the page a kiosk token points at. Only offered once the wall
+          is the active view, so it doesn't compete with the toggle above. */}
+      {pageView === 'camwall' && hasPermission('camera:view') && (
+        <RouterLink
+          to="/camwall"
+          className={`flex h-8 items-center gap-1 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark px-2 text-xs font-medium text-white transition-colors hover:bg-bambu-dark-tertiary ${inMenu ? 'w-full justify-center' : ''}`}
+          title={t('printers.pageView.openCamWallPage')}
+        >
+          <ExternalLink className="w-3.5 h-3.5" />
+          {inMenu && <span>{t('printers.pageView.openCamWallPage')}</span>}
+        </RouterLink>
+      )}
+
       {/* Card size selector */}
       <div className={`flex h-8 items-center bg-bambu-dark rounded-lg border border-bambu-dark-tertiary ${pageView === 'camwall' ? 'opacity-40 pointer-events-none' : ''} ${inMenu ? 'w-full' : ''}`}>
         {cardSizeLabels.map((label, index) => {

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-C5xd9oTZ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DaanvRDY.css


+ 2 - 2
static/index.html

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

Некоторые файлы не были показаны из-за большого количества измененных файлов