Explorar o código

fix(auth): a kiosk waits out a database problem instead of logging out (issue #3360)

- A SpoolBuddy kiosk that loaded while the database was unavailable
  gave up after three tries and showed the login page, which a
  touch-only screen can't use, until a reboot. A stored sign-in without
  an answer is now "unconfirmed": protected pages, permission pages and
  the Cam Wall show "Can't reach Bambuddy. Retrying..." while the
  re-check keeps asking (2 s doubling to 60 s), and show the page once
  it answers. A definite 401 or 403 still goes to the login page. A
  ?token= kiosk key is persisted when a later try confirms it.
- An API key's last-used time is written at most once a minute instead
  of on every request (LastUsedThrottle in services/last_used.py). The
  attempt is remembered in-process, so a write that keeps failing isn't
  retried per request, and a time in the future counts as stale.
- A failed last-used write no longer refuses a key that matched: it is
  logged, rolled back and the row re-read. A key disabled or set to
  expire meanwhile is refused; a failed re-read is still a 503.
- Long-lived camera tokens (Cam Wall, overlay, camera stream) get the
  same throttle and guarded write instead of a 500.
maziggy hai 13 horas
pai
achega
642cd85736

+ 1 - 0
CHANGELOG.md

@@ -175,6 +175,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A SpoolBuddy kiosk no longer gets stuck on the login page during a database problem (#3360, reported by @sargonas)** — A kiosk signs in with its API key from the launch URL. When it loaded while Bambuddy's database was unavailable (out of connections, or a read-only replica after a failover), it gave up after three quick tries and showed the login page, which a touch-only screen can't use, until the next reboot. Pages now wait on a "Can't reach Bambuddy. Retrying…" screen and keep trying in the background, then show the page as soon as Bambuddy answers. A key or session Bambuddy reports as invalid, expired or belonging to a deactivated account still goes to the login page. This also covers browser tabs with a saved sign-in, which used to show the login page, or the app as if sign-in were off, when they loaded during an outage. Writing an API key's last-used time no longer refuses a key that has already been checked: if the write fails, the key is accepted and the failure logged. That time is now written at most once a minute instead of on every request, which takes one write and commit off almost every request made with a key. A write that fails is not tried again for a minute either. Camera tokens for the Cam Wall, stream overlays and camera streams get the same treatment: a failed last-used write no longer refuses a valid token.
 - **Slicing for the Cool Plate SuperTack works (#3359, reported by @Akaviiii)** — Picking **Cool Plate SuperTack** in the Slice dialog sent a plate name neither Bambu Studio nor OrcaSlicer knows. The slicer then sliced for the Cool Plate and failed with "Filaments are not compatible with the plate type". **Smooth PEI Plate** had the same problem: the slicers call that plate High Temp Plate, so the dialog now offers it once, as **Smooth PEI Plate / High Temp Plate**. Slicer pipelines saved with either option slice on the right plate without being saved again. A plate name no slicer knows is now refused when slicing through the API or saving a pipeline, instead of being sliced as Cool Plate, and so is OrcaSlicer's Textured Cool Plate when Bambu Studio does the slicing. A pipeline holding a plate the dialog doesn't list shows that plate when it is applied, instead of showing Auto, and a run of a pipeline with a plate no slicer knows fails with a message saying so. Files sliced for OrcaSlicer's Textured Cool Plate now also record their bed temperature.
 - **The MQTT Debug log shows what the printer sent** — Each logged message was the same object Bambuddy then processed, and processing writes Bambuddy's own state into it. An incoming push therefore showed Bambuddy's merged AMS list instead of the printer's AMS block, without fields such as `ams_exist_bits` and still listing an AMS that had been unplugged, and later messages could gain an AMS list they never carried. The log now keeps every incoming message exactly as it arrived.
 - **Printers without an AMS print from the spool holder (#3356, reported by @dogod3814t)** — A queue print to a printer with no AMS attached, such as a P1S on its spool holder alone, failed with "Failed to get AMS mapping table" when nothing was set on the spool holder. With nothing to match, the job went out without a filament mapping and asking the printer to use its AMS, which it refuses. Bambuddy now tells a printer that reports no AMS to print from the spool holder, as the slicer does. Dual-nozzle printers are unchanged. An AMS unplugged while Bambuddy runs also disappears from the printer card and from slicers connected through a virtual printer now, instead of staying until a restart, so this works straight after removing one. As with a spool taken out of a slot, spools assigned to an AMS that stays unplugged for more than two minutes are unassigned; RFID spools are assigned again when the AMS is back, other spools need assigning again.

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

@@ -32,6 +32,7 @@ from backend.app.models.api_key import APIKey
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
+from backend.app.services.last_used import LastUsedThrottle
 from backend.app.utils.local_time import utcnow_naive
 
 logger = logging.getLogger(__name__)
@@ -1663,6 +1664,19 @@ _validated_api_key: ContextVar[tuple[str, APIKey] | None] = ContextVar("_validat
 _authenticated_user: ContextVar[tuple[str, User] | None] = ContextVar("_authenticated_user", default=None)
 
 
+# A key's last-used time is written at most once a minute (#3360).
+_api_key_last_used = LastUsedThrottle(timedelta(minutes=1))
+
+
+def _api_key_expired(api_key: APIKey) -> bool:
+    if not api_key.expires_at:
+        return False
+    expires = api_key.expires_at
+    if expires.tzinfo is None:
+        expires = expires.replace(tzinfo=timezone.utc)
+    return expires < datetime.now(timezone.utc)
+
+
 def _api_key_check_unavailable(error: Exception) -> HTTPException:
     """The 503 for a key that couldn't be checked (database, pool), logged.
 
@@ -1681,7 +1695,9 @@ async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | No
     """Validate an API key and return the APIKey object if valid, None otherwise.
 
     Raises a 503 HTTPException when the key can't be checked at all (database
-    or pool error): that is not an invalid key, and must not read as one.
+    or pool error): that is not an invalid key, and must not read as one. A
+    failed ``last_used`` write is not such a case: the key has been checked by
+    then, so it is logged and the key is accepted (#3360).
 
     L-1: Pre-filter by key_prefix (first 8 chars) before running pbkdf2 so only
     O(1) candidate rows are hashed instead of the full key table.  The prefix is
@@ -1717,20 +1733,27 @@ async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | No
             continue
         if not matches:
             continue
-        # Check expiration
-        if api_key.expires_at:
-            expires = api_key.expires_at
-            if expires.tzinfo is None:
-                expires = expires.replace(tzinfo=timezone.utc)
-            if expires < datetime.now(timezone.utc):
-                return None  # Expired
-        # Update last_used timestamp
-        api_key.last_used = datetime.now(timezone.utc)
-        try:
-            await db.commit()
-        except Exception as e:  # SEC-AUTH-EXC: last_used write failed -> rollback + raise 503, key not returned
-            await db.rollback()
-            raise _api_key_check_unavailable(e) from e
+        if _api_key_expired(api_key):
+            return None
+        now = datetime.now(timezone.utc)
+        if _api_key_last_used.due(api_key.key_hash, api_key.last_used, now):
+            key_id = api_key.id
+            api_key.last_used = now
+            try:
+                await db.commit()
+            except Exception as e:  # SEC-AUTH-EXC: last_used write failed, key already matched -> re-read below
+                # A read-only replica after a failover, a locked database: the
+                # key matched, only the bookkeeping failed.
+                await db.rollback()
+                logger.warning("Could not record API key %s as used: %s", key_id, e)
+                try:
+                    await db.refresh(api_key)
+                except Exception as e:  # SEC-AUTH-EXC: re-reading the key failed -> raise 503, key not returned
+                    raise _api_key_check_unavailable(e) from e
+                # Re-read, so it is checked again: it may have been turned off
+                # or set to expire in the meantime.
+                if not api_key.enabled or _api_key_expired(api_key):
+                    return None
         _validated_api_key.set((api_key_value, api_key))
         return api_key
     return None

+ 36 - 0
backend/app/services/last_used.py

@@ -0,0 +1,36 @@
+"""When a credential's last-used time is due to be written again (#3360).
+
+API keys and long-lived tokens record when they were last used. The time is
+shown to people, so writing it at most once an interval is enough, and writing
+it on every request (each poll of a kiosk) costs a write and a commit per
+request. The attempt is remembered in this process too: when writes fail (a
+read-only replica after a failover, a locked SQLite database) the stored time
+does not move, and without that every request would try again, and on SQLite
+wait out the busy timeout first.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+
+class LastUsedThrottle:
+    def __init__(self, interval: timedelta):
+        self._interval = interval
+        # Keyed by the credential's stored hash, which is unique to it and,
+        # unlike a row id, never handed to another credential.
+        self._attempts: dict[str, datetime] = {}
+
+    def _recent(self, then: datetime | None, now: datetime) -> bool:
+        if then is None:
+            return False
+        if then.tzinfo is None:
+            then = then.replace(tzinfo=timezone.utc)
+        # A time in the future (a clock that ran ahead, a restored backup) is
+        # not recent: it would otherwise stop updating until the clock got there.
+        return timedelta(0) <= now - then < self._interval
+
+    def due(self, key: str, stored: datetime | None, now: datetime) -> bool:
+        """Whether to write the time now, and if so, counts this as the attempt."""
+        if self._recent(stored, now) or self._recent(self._attempts.get(key), now):
+            return False
+        self._attempts[key] = now
+        return True

+ 22 - 9
backend/app/services/long_lived_tokens.py

@@ -21,16 +21,21 @@ tokens — a leaked permanent token would be irrevocable footgun-by-design).
 
 from __future__ import annotations
 
+import logging
 import secrets
 from collections.abc import Collection
 from dataclasses import dataclass
 from datetime import datetime, timedelta, timezone
 
 from sqlalchemy import select
+from sqlalchemy.exc import SQLAlchemyError
 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
+from backend.app.services.last_used import LastUsedThrottle
+
+logger = logging.getLogger(__name__)
 
 # Issue #1108 hard cap. Bump here if policy changes — UI default is shorter
 # (90 days) and the create route enforces this ceiling.
@@ -59,8 +64,9 @@ STREAM_SCOPES: tuple[str, ...] = ("camera_stream", "camwall", "overlay")
 
 # 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)
+# connection open), but snapshots may rapid-fire — this caps DB churn. The
+# throttle also holds when the write fails (#3360).
+_last_used = LastUsedThrottle(timedelta(minutes=1))
 
 # Token format constants — kept in one place so format changes are localized.
 _TOKEN_PREFIX = "bblt_"
@@ -198,18 +204,25 @@ async def verify_token(
         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:
+        if _last_used.due(record.secret_hash, record.last_used_at, now):
+            record_id = record.id
             record.last_used_at = now
-            await db.commit()
+            try:
+                await db.commit()
+            except SQLAlchemyError as e:
+                # The token matched; only the bookkeeping failed (a read-only
+                # replica after a failover, a locked database), which must not
+                # turn a Cam Wall kiosk away (#3360). Re-read it so a revoke
+                # that landed meanwhile still counts; a failed re-read raises.
+                await db.rollback()
+                logger.warning("Could not record token %s as used: %s", record_id, e)
+                await db.refresh(record)
+                if record.revoked_at is not None or _is_expired(record, now):
+                    return None
         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).

+ 125 - 7
backend/tests/integration/test_api_key_infra_errors.py

@@ -6,16 +6,23 @@ during a pool timeout, or when writing its last-used time hit "database is
 locked", and the web interface clears its sign-in on that message. Such an
 error now refuses the request with 503, as the auth checks in the middleware
 do, and is logged.
+
+Writing the key's last-used time is the exception: the key has been checked by
+then, so a failed write (a read-only replica after a failover) is logged and
+the key accepted. The time is written at most once a minute (#3360).
 """
 
 import logging
 from contextlib import contextmanager
 from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
 
 import pytest
 from sqlalchemy import event, update
 from sqlalchemy.exc import OperationalError
+from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core import auth
 from backend.app.models.api_key import APIKey
 
 
@@ -39,13 +46,15 @@ async def _key(async_client) -> str:
 
 
 @contextmanager
-def _failing(test_engine, statement_start: str):
-    """Make the next statement starting with *statement_start* fail as SQLite does when locked."""
-    state = {"failed": False}
+def _failing(test_engine, *statement_starts: str):
+    """Make the next statements starting with *statement_starts*, in turn, fail
+    as SQLite does when locked."""
+    state = {"failed": False, "left": list(statement_starts)}
 
     def hook(conn, cursor, statement, parameters, context, executemany):
-        if not state["failed"] and statement.lstrip().upper().startswith(statement_start):
-            state["failed"] = True
+        if state["left"] and statement.lstrip().upper().startswith(state["left"][0]):
+            state["left"].pop(0)
+            state["failed"] = not state["left"]
             raise OperationalError(statement, parameters, Exception("database is locked"))
 
     event.listen(test_engine.sync_engine, "before_cursor_execute", hook)
@@ -55,6 +64,21 @@ def _failing(test_engine, statement_start: str):
         event.remove(test_engine.sync_engine, "before_cursor_execute", hook)
 
 
+@contextmanager
+def _counting(test_engine, statement_start: str):
+    seen: list[str] = []
+
+    def hook(conn, cursor, statement, parameters, context, executemany):
+        if statement.lstrip().upper().startswith(statement_start):
+            seen.append(statement)
+
+    event.listen(test_engine.sync_engine, "before_cursor_execute", hook)
+    try:
+        yield seen
+    finally:
+        event.remove(test_engine.sync_engine, "before_cursor_execute", hook)
+
+
 PROTECTED = "/api/v1/printers/"
 
 
@@ -84,16 +108,110 @@ async def test_a_failed_key_lookup_is_a_logged_503(async_client, test_engine, ca
 
 @pytest.mark.asyncio
 @pytest.mark.integration
-async def test_a_failed_last_used_write_is_a_503_not_an_invalid_key(async_client, test_engine):
+async def test_a_failed_last_used_write_still_lets_the_key_in(async_client, test_engine, caplog):
+    """The reporter's read-only replica: the key matched, only the write failed."""
+    key = await _key(async_client)
+
+    with _failing(test_engine, "UPDATE API_KEYS") as state, caplog.at_level(logging.WARNING):
+        response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
+
+    assert state["failed"]
+    assert response.status_code == 200, response.text
+    assert "Could not record API key" in caplog.text
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_failed_write_and_a_failed_reread_is_a_503(async_client, test_engine):
     key = await _key(async_client)
 
-    with _failing(test_engine, "UPDATE API_KEYS") as state:
+    with _failing(test_engine, "UPDATE API_KEYS", "SELECT API_KEYS") as state:
         response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
 
     assert state["failed"]
     assert response.status_code == 503, response.text
 
 
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_key_turned_off_before_the_reread_is_refused(async_client, test_engine):
+    key = await _key(async_client)
+    original = AsyncSession.refresh
+
+    async def refresh_after_disabling(self, instance, *args, **kwargs):
+        await original(self, instance, *args, **kwargs)
+        if isinstance(instance, APIKey):
+            instance.enabled = False
+
+    with (
+        _failing(test_engine, "UPDATE API_KEYS"),
+        patch.object(AsyncSession, "refresh", refresh_after_disabling),
+    ):
+        response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
+
+    assert response.status_code == 401, response.text
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_key_set_to_expire_before_the_reread_is_refused(async_client, test_engine):
+    """Setting a past expiry revokes a key just as turning it off does."""
+    key = await _key(async_client)
+    original = AsyncSession.refresh
+
+    async def refresh_after_expiring(self, instance, *args, **kwargs):
+        await original(self, instance, *args, **kwargs)
+        if isinstance(instance, APIKey):
+            instance.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
+
+    with (
+        _failing(test_engine, "UPDATE API_KEYS"),
+        patch.object(AsyncSession, "refresh", refresh_after_expiring),
+    ):
+        response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
+
+    assert response.status_code == 401, response.text
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_failing_write_is_not_retried_on_every_request(async_client, test_engine):
+    """On SQLite each try waits out the busy timeout first."""
+    key = await _key(async_client)
+
+    with _counting(test_engine, "UPDATE API_KEYS") as writes, _failing(test_engine, "UPDATE API_KEYS"):
+        for _ in range(3):
+            response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
+            assert response.status_code == 200, response.text
+
+    assert len(writes) == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_last_used_is_written_at_most_once_a_minute(async_client, test_engine, db_session):
+    key = await _key(async_client)
+
+    with _counting(test_engine, "UPDATE API_KEYS") as writes:
+        for _ in range(3):
+            response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
+            assert response.status_code == 200, response.text
+    assert len(writes) == 1
+
+    await db_session.execute(
+        update(APIKey)
+        .values(last_used=datetime.now(timezone.utc) - timedelta(minutes=2))
+        .where(APIKey.name == "integration")
+    )
+    await db_session.commit()
+    # The attempt this process remembers is a minute old too.
+    auth._api_key_last_used._attempts.clear()
+    with _counting(test_engine, "UPDATE API_KEYS") as writes:
+        response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
+    assert response.status_code == 200, response.text
+    assert len(writes) == 1
+
+
 @pytest.mark.asyncio
 @pytest.mark.integration
 async def test_a_wrong_key_is_still_a_401(async_client):

+ 4 - 3
backend/tests/integration/test_webhook_api_key_check.py

@@ -5,7 +5,8 @@ fixes: a database error, a key too long to hash and an unreadable stored hash
 all came back as a 500, and a failed last-used write left the session dirty.
 ``get_api_key`` now reads the headers and leaves the check to
 ``_validate_api_key``, so these answer as they do everywhere: 503 when the key
-can't be checked, 401 when it is not a valid key.
+can't be checked, 401 when it is not a valid key. A failed last-used write
+after the key matched lets the key in (#3360).
 """
 
 import logging
@@ -73,12 +74,12 @@ async def test_a_failed_key_lookup_is_a_503(async_client: AsyncClient, key, test
 
 @pytest.mark.asyncio
 @pytest.mark.integration
-async def test_a_failed_last_used_write_is_a_503(async_client: AsyncClient, key, test_engine):
+async def test_a_failed_last_used_write_lets_the_key_in(async_client: AsyncClient, key, test_engine):
     with _failing(test_engine, "UPDATE API_KEYS") as state:
         response = await async_client.get(ROUTE, headers={"X-API-Key": key})
 
     assert state["failed"]
-    assert response.status_code == 503, response.text
+    assert response.status_code == 404, response.text
 
 
 @pytest.mark.asyncio

+ 48 - 0
backend/tests/unit/services/test_last_used_throttle.py

@@ -0,0 +1,48 @@
+"""When a credential's last-used time is written again (#3360)."""
+
+from datetime import datetime, timedelta, timezone
+
+from backend.app.services.last_used import LastUsedThrottle
+
+NOW = datetime(2026, 10, 24, 12, 0, tzinfo=timezone.utc)
+
+
+def _throttle():
+    return LastUsedThrottle(timedelta(minutes=1))
+
+
+def test_never_used_is_due():
+    assert _throttle().due("k", None, NOW)
+
+
+def test_used_within_the_minute_is_not_due():
+    assert not _throttle().due("k", NOW - timedelta(seconds=30), NOW)
+
+
+def test_used_over_a_minute_ago_is_due():
+    assert _throttle().due("k", NOW - timedelta(minutes=2), NOW)
+
+
+def test_a_naive_stored_time_is_read_as_utc():
+    assert not _throttle().due("k", (NOW - timedelta(seconds=30)).replace(tzinfo=None), NOW)
+
+
+def test_a_time_in_the_future_is_due():
+    """A clock that ran ahead would otherwise freeze the time until it got there."""
+    assert _throttle().due("k", NOW + timedelta(hours=3), NOW)
+
+
+def test_an_attempt_counts_when_the_stored_time_did_not_move():
+    """A failed write leaves the stored time where it was."""
+    throttle = _throttle()
+    stale = NOW - timedelta(hours=1)
+
+    assert throttle.due("k", stale, NOW)
+    assert not throttle.due("k", stale, NOW + timedelta(seconds=10))
+    assert throttle.due("k", stale, NOW + timedelta(minutes=2))
+
+
+def test_attempts_are_per_credential():
+    throttle = _throttle()
+    assert throttle.due("a", None, NOW)
+    assert throttle.due("b", None, NOW)

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

@@ -247,3 +247,88 @@ async def test_revoke_is_idempotent(db_session, alice: User):
 
 async def test_revoke_unknown_id_returns_false(db_session):
     assert await revoke_token(db_session, 99_999) is False
+
+
+# ---------------------------------------------------------------------------
+# A failed last-used write (#3360)
+# ---------------------------------------------------------------------------
+
+
+def _commit_fails(db_session, monkeypatch) -> list:
+    from sqlalchemy.exc import OperationalError
+
+    calls: list = []
+
+    async def failing_commit():
+        calls.append(1)
+        raise OperationalError(
+            "UPDATE long_lived_tokens", {}, Exception("cannot execute UPDATE in a read-only transaction")
+        )
+
+    monkeypatch.setattr(db_session, "commit", failing_commit)
+    return calls
+
+
+async def test_a_failed_last_used_write_still_lets_the_token_in(db_session, alice: User, monkeypatch, caplog):
+    """A Cam Wall kiosk on a read-only replica: the token matched, only the write failed."""
+    created = await create_token(db_session, user_id=alice.id, name="Wall", expires_in_days=7, scope="camwall")
+    _commit_fails(db_session, monkeypatch)
+
+    record = await verify_token(db_session, created.plaintext, scope="camwall")
+
+    assert record is not None
+    assert record.id == created.record.id
+    assert "Could not record token" in caplog.text
+
+
+async def test_a_token_revoked_before_the_reread_is_refused(db_session, alice: User, monkeypatch):
+    created = await create_token(db_session, user_id=alice.id, name="Wall", expires_in_days=7, scope="camwall")
+    _commit_fails(db_session, monkeypatch)
+    original = db_session.refresh
+
+    async def refresh_after_revoking(instance, *args, **kwargs):
+        await original(instance, *args, **kwargs)
+        instance.revoked_at = datetime.now(timezone.utc)
+
+    monkeypatch.setattr(db_session, "refresh", refresh_after_revoking)
+
+    assert await verify_token(db_session, created.plaintext, scope="camwall") is None
+
+
+async def test_a_failing_write_is_not_retried_on_every_request(db_session, alice: User, monkeypatch):
+    created = await create_token(db_session, user_id=alice.id, name="Wall", expires_in_days=7, scope="camwall")
+    commits = _commit_fails(db_session, monkeypatch)
+
+    for _ in range(3):
+        assert await verify_token(db_session, created.plaintext, scope="camwall") is not None
+
+    assert len(commits) == 1
+
+
+async def test_a_token_expired_before_the_reread_is_refused(db_session, alice: User, monkeypatch):
+    created = await create_token(db_session, user_id=alice.id, name="Wall", expires_in_days=7, scope="camwall")
+    _commit_fails(db_session, monkeypatch)
+    original = db_session.refresh
+
+    async def refresh_after_expiring(instance, *args, **kwargs):
+        await original(instance, *args, **kwargs)
+        instance.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
+
+    monkeypatch.setattr(db_session, "refresh", refresh_after_expiring)
+
+    assert await verify_token(db_session, created.plaintext, scope="camwall") is None
+
+
+async def test_a_failed_reread_refuses_the_token(db_session, alice: User, monkeypatch):
+    from sqlalchemy.exc import OperationalError
+
+    created = await create_token(db_session, user_id=alice.id, name="Wall", expires_in_days=7, scope="camwall")
+    _commit_fails(db_session, monkeypatch)
+
+    async def failing_refresh(instance, *args, **kwargs):
+        raise OperationalError("SELECT long_lived_tokens", {}, Exception("server closed the connection"))
+
+    monkeypatch.setattr(db_session, "refresh", failing_refresh)
+
+    with pytest.raises(OperationalError):
+        await verify_token(db_session, created.plaintext, scope="camwall")

+ 15 - 4
frontend/src/App.tsx

@@ -12,6 +12,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext';
 import { ColorCatalogProvider } from './contexts/ColorCatalogContext';
 import { SpoolBuddyLayout } from './components/spoolbuddy/SpoolBuddyLayout';
 import { PageLoading } from './components/PageLoading';
+import { AuthRetrying } from './components/AuthRetrying';
 import { lazyPage } from './utils/lazyPage';
 
 // Every page loads as its own file when first opened (#3175).
@@ -101,14 +102,20 @@ function WebSocketProvider({ children }: { children: React.ReactNode }) {
   return <>{children}</>;
 }
 
-function ProtectedRoute({ children }: { children: React.ReactNode }) {
-  const { authEnabled, loading, user } = useAuth();
+export function ProtectedRoute({ children }: { children: React.ReactNode }) {
+  const { authEnabled, loading, user, authUnconfirmed } = useAuth();
   const location = useLocation();
 
   if (loading) {
     return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
   }
 
+  // A stored sign-in the server couldn't check yet: wait here, so a kiosk
+  // isn't left on a login page it can't use (#3360).
+  if (authUnconfirmed) {
+    return <AuthRetrying />;
+  }
+
   if (authEnabled && !user) {
     return <Navigate to="/login" replace state={{ from: location }} />;
   }
@@ -116,19 +123,23 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
   return <>{children}</>;
 }
 
-function PermissionRoute({ permission, children }: { permission: string | string[]; children: React.ReactNode }) {
+export function PermissionRoute({ permission, children }: { permission: string | string[]; children: React.ReactNode }) {
   // Permission-gated route: any user with the given permission can enter, not
   // just admins. Individual components below this guard apply their own
   // per-action permission checks. Used for pages where delegation is supported
   // (e.g. settings:read grants read-only access to Settings; specific tabs
   // require their own permissions like users:read, groups:update, etc.).
-  const { authEnabled, loading, user, hasPermission } = useAuth();
+  const { authEnabled, loading, user, hasPermission, authUnconfirmed } = useAuth();
   const location = useLocation();
 
   if (loading) {
     return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
   }
 
+  if (authUnconfirmed) {
+    return <AuthRetrying />;
+  }
+
   // Auth disabled → open access (backward compatibility)
   if (!authEnabled) {
     return <>{children}</>;

+ 102 - 0
frontend/src/__tests__/components/ProtectedRouteRetrying.test.tsx

@@ -0,0 +1,102 @@
+/**
+ * A stored sign-in the server can't check yet waits on the page it asked for
+ * instead of landing on /login (#3360): a SpoolBuddy kiosk is touch-only and
+ * has no way to sign in there, so it stayed on the login page until a reboot.
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { AuthProvider } from '../../contexts/AuthContext';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { PermissionRoute, ProtectedRoute } from '../../App';
+import { CamWallPage } from '../../pages/CamWallPage';
+import { setAuthToken } from '../../api/client';
+
+function renderKiosk() {
+  return render(
+    <MemoryRouter initialEntries={['/spoolbuddy']}>
+      <AuthProvider>
+        <Routes>
+          <Route path="/login" element={<div>login page</div>} />
+          <Route path="/spoolbuddy" element={<ProtectedRoute><div>kiosk page</div></ProtectedRoute>} />
+        </Routes>
+      </AuthProvider>
+    </MemoryRouter>
+  );
+}
+
+describe('ProtectedRoute while the sign-in is unconfirmed (#3360)', () => {
+  let down: boolean;
+
+  beforeEach(() => {
+    down = true;
+    setAuthToken('bb_kiosk', 'persistent');
+    server.use(
+      http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+      http.get('/api/v1/auth/me', () =>
+        down
+          ? HttpResponse.json({ detail: 'Authentication service temporarily unavailable' }, { status: 503 })
+          : HttpResponse.json({ id: 1, username: 'kiosk', is_active: true, permissions: [], groups: [] })
+      )
+    );
+  });
+
+  afterEach(() => {
+    setAuthToken(null);
+  });
+
+  it('shows the retry screen, then the page once Bambuddy answers', async () => {
+    renderKiosk();
+
+    expect(await screen.findByText("Can't reach Bambuddy. Retrying…", {}, { timeout: 4000 })).toBeInTheDocument();
+    expect(screen.queryByText('login page')).not.toBeInTheDocument();
+
+    down = false;
+    expect(await screen.findByText('kiosk page', {}, { timeout: 6000 })).toBeInTheDocument();
+  }, 12000);
+
+  it('waits the same way on a permission-gated page', async () => {
+    render(
+      <MemoryRouter initialEntries={['/settings']}>
+        <AuthProvider>
+          <Routes>
+            <Route path="/login" element={<div>login page</div>} />
+            <Route path="/settings" element={<PermissionRoute permission="settings:read"><div>settings</div></PermissionRoute>} />
+          </Routes>
+        </AuthProvider>
+      </MemoryRouter>
+    );
+
+    expect(await screen.findByText("Can't reach Bambuddy. Retrying…", {}, { timeout: 4000 })).toBeInTheDocument();
+    expect(screen.queryByText('login page')).not.toBeInTheDocument();
+  });
+
+  it('waits the same way on the Cam Wall opened with a session', async () => {
+    render(
+      <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
+        <MemoryRouter initialEntries={['/camwall']}>
+          <AuthProvider>
+            <Routes>
+              <Route path="/login" element={<div>login page</div>} />
+              <Route path="/camwall" element={<CamWallPage />} />
+            </Routes>
+          </AuthProvider>
+        </MemoryRouter>
+      </QueryClientProvider>
+    );
+
+    expect(await screen.findByText("Can't reach Bambuddy. Retrying…", {}, { timeout: 4000 })).toBeInTheDocument();
+    expect(screen.queryByText('login page')).not.toBeInTheDocument();
+  });
+
+  it('still goes to the login page on a definite 401', async () => {
+    server.use(http.get('/api/v1/auth/me', () => HttpResponse.json({ detail: 'Invalid API key' }, { status: 401 })));
+
+    renderKiosk();
+
+    expect(await screen.findByText('login page', {}, { timeout: 4000 })).toBeInTheDocument();
+  });
+});

+ 92 - 0
frontend/src/__tests__/contexts/AuthContext.test.tsx

@@ -513,6 +513,98 @@ describe('AuthContext', () => {
       expect(window.localStorage.removeItem).toHaveBeenCalledWith('auth_token');
     });
 
+    it('keeps asking after the first load gets no answer, and signs in when it does (#3360)', async () => {
+      // The reporter's kiosk: the database was out of connections while it
+      // loaded. It used to stop after three tries and show the login page.
+      let down = true;
+      server.use(
+        http.get('/api/v1/auth/me', () =>
+          down
+            ? HttpResponse.json({ detail: 'Authentication service temporarily unavailable' }, { status: 503 })
+            : HttpResponse.json({ id: 1, username: 'kiosk', is_active: true, permissions: [], groups: [] })
+        )
+      );
+
+      const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
+
+      await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 4000 });
+      expect(result.current.authUnconfirmed).toBe(true);
+      expect(result.current.user).toBeNull();
+      expect(getAuthToken()).toBe('valid-token');
+
+      down = false;
+      await waitFor(() => expect(result.current.user?.username).toBe('kiosk'), { timeout: 6000 });
+      expect(result.current.authUnconfirmed).toBe(false);
+    }, 12000);
+
+    it('ends the wait on a definite 401', async () => {
+      let down = true;
+      server.use(
+        http.get('/api/v1/auth/me', () =>
+          down
+            ? new HttpResponse(null, { status: 503 })
+            : HttpResponse.json({ detail: 'Invalid API key' }, { status: 401 })
+        )
+      );
+
+      const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
+      await waitFor(() => expect(result.current.authUnconfirmed).toBe(true), { timeout: 4000 });
+
+      down = false;
+      await waitFor(() => expect(result.current.authUnconfirmed).toBe(false), { timeout: 6000 });
+      expect(result.current.user).toBeNull();
+      expect(getAuthToken()).toBeNull();
+    }, 12000);
+
+    it('waits instead of showing the open UI when even the auth status gets no answer', async () => {
+      let down = true;
+      server.use(
+        http.get('/api/v1/auth/status', () =>
+          down
+            ? new HttpResponse(null, { status: 503 })
+            : HttpResponse.json({ auth_enabled: true, requires_setup: false })
+        ),
+        http.get('/api/v1/auth/me', () =>
+          HttpResponse.json({ id: 1, username: 'kiosk', is_active: true, permissions: [], groups: [] })
+        )
+      );
+
+      const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
+      await waitFor(() => expect(result.current.loading).toBe(false));
+      expect(result.current.authUnconfirmed).toBe(true);
+
+      down = false;
+      await waitFor(() => expect(result.current.user?.username).toBe('kiosk'), { timeout: 6000 });
+      expect(result.current.authEnabled).toBe(true);
+      expect(result.current.authUnconfirmed).toBe(false);
+    }, 12000);
+
+    it("persists a kiosk's ?token= key once a later try confirms it", async () => {
+      setAuthToken(null);
+      localStorage.removeItem('auth_token');
+      window.history.replaceState({}, '', '/spoolbuddy?token=bb_kiosk');
+      let down = true;
+      server.use(
+        http.get('/api/v1/auth/me', () =>
+          down
+            ? new HttpResponse(null, { status: 503 })
+            : HttpResponse.json({ id: 1, username: 'kiosk', is_active: true, permissions: [], groups: [] })
+        )
+      );
+      vi.mocked(window.localStorage.setItem).mockClear();
+      try {
+        const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
+        await waitFor(() => expect(result.current.authUnconfirmed).toBe(true), { timeout: 4000 });
+        expect(window.localStorage.setItem).not.toHaveBeenCalledWith('auth_token', 'bb_kiosk');
+
+        down = false;
+        await waitFor(() => expect(result.current.user).not.toBeNull(), { timeout: 6000 });
+        expect(window.localStorage.setItem).toHaveBeenCalledWith('auth_token', 'bb_kiosk');
+      } finally {
+        window.history.replaceState({}, '', '/');
+      }
+    }, 12000);
+
     it('loads the user when the persisted token is valid', async () => {
       server.use(
         http.get('/api/v1/auth/me', () =>

+ 14 - 0
frontend/src/components/AuthRetrying.tsx

@@ -0,0 +1,14 @@
+import { Loader2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+/** Shown instead of the login page while a stored sign-in can't be checked
+ *  because Bambuddy doesn't answer; AuthContext keeps retrying (#3360). */
+export function AuthRetrying() {
+  const { t } = useTranslation();
+  return (
+    <div role="status" className="flex min-h-screen flex-col items-center justify-center gap-3 bg-bambu-dark text-bambu-gray">
+      <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+      <span>{t('login.retrying', "Can't reach Bambuddy. Retrying…")}</span>
+    </div>
+  );
+}

+ 41 - 5
frontend/src/contexts/AuthContext.tsx

@@ -6,6 +6,10 @@ interface AuthContextType {
   user: UserResponse | null;
   authEnabled: boolean;
   loading: boolean;
+  /** A token is stored but the server could not say whether it is valid
+   *  (backend or database down). Retried in the background; the route guards
+   *  wait instead of sending a kiosk to /login, where it can't sign in (#3360). */
+  authUnconfirmed: boolean;
   isAdmin: boolean;
   /** Login with username/password. Returns LoginResponse (may include requires_2fa). */
   login: (username: string, password: string, persistence?: TokenPersistence) => Promise<LoginResponse>;
@@ -25,10 +29,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
   const [authEnabled, setAuthEnabled] = useState(false);
   const [requiresSetup, setRequiresSetup] = useState(false);
   const [loading, setLoading] = useState(true);
+  const [authUnconfirmed, setAuthUnconfirmed] = useState(false);
+  // A ?token= kiosk key not yet confirmed by the server, kept for a re-check
+  // that confirms it after a failed first load, which persists it then.
+  const pendingUrlTokenRef = useRef<string | null>(null);
   const hasRedirectedRef = useRef(false);
   const mountedRef = useRef(true);
 
   const checkAuthStatus = async () => {
+    let hasStoredToken = false;
     try {
       // Bootstrap: if URL has ?token= param, store it session-only first and
       // strip it from the URL. Allows SpoolBuddy kiosk to pass an API key via
@@ -42,6 +51,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
       const urlToken = isStreamingPage ? null : urlParams.get('token');
       if (urlToken) {
         setAuthToken(urlToken, 'session'); // session-only until server confirms it's valid
+        pendingUrlTokenRef.current = urlToken;
         urlParams.delete('token');
         const cleanSearch = urlParams.toString();
         const cleanUrl = window.location.pathname
@@ -50,6 +60,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         window.history.replaceState({}, '', cleanUrl);
       }
 
+      hasStoredToken = Boolean(getAuthToken());
       const status = await api.getAuthStatus();
       if (!mountedRef.current) return;
       setAuthEnabled(status.auth_enabled);
@@ -91,31 +102,46 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
           if (!mountedRef.current) return;
           if (currentUser) {
             setUser(currentUser);
+            setAuthUnconfirmed(false);
             // Persist kiosk token only after the server confirms it is valid.
             if (urlToken && token === urlToken) {
               setAuthToken(urlToken, 'persistent');
+              pendingUrlTokenRef.current = null;
             }
           } else {
             // No user: either a definitive 401 (token already cleared by
             // request()) or transient failures exhausted their retries. In the
-            // transient case we deliberately keep the token so a reload retries
-            // rather than forcing a re-login.
+            // transient case we deliberately keep the token and keep asking
+            // in the background, so the page comes back by itself (#3360).
+            setUser(null);
             if (definitiveAuthFailure) {
               setAuthToken(null);
+              setAuthUnconfirmed(false);
+            } else {
+              setAuthUnconfirmed(true);
+              runRecheckRef.current();
             }
-            setUser(null);
           }
         } else {
           setUser(null);
+          setAuthUnconfirmed(false);
         }
       } else {
         // Auth not enabled, allow access
         setUser(null);
+        setAuthUnconfirmed(false);
       }
     } catch {
       if (!mountedRef.current) return;
-      setAuthEnabled(false);
       setUser(null);
+      if (hasStoredToken) {
+        // No answer at all, but a token is stored: wait for one rather than
+        // showing the open UI or the login page (#3360).
+        setAuthUnconfirmed(true);
+        runRecheckRef.current();
+      } else {
+        setAuthEnabled(false);
+      }
     } finally {
       if (mountedRef.current) {
         setLoading(false);
@@ -145,7 +171,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     }
     try {
       const currentUser = await api.getCurrentUser();
-      if (mountedRef.current) setUser(currentUser);
+      if (mountedRef.current) {
+        setUser(currentUser);
+        const pending = pendingUrlTokenRef.current;
+        if (pending && getAuthToken() === pending) {
+          setAuthToken(pending, 'persistent');
+          pendingUrlTokenRef.current = null;
+        }
+      }
       return true;
     } catch (err) {
       // 401: signed out. request() clears the token only for some messages,
@@ -178,6 +211,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     if (!mountedRef.current) return;
     if (answered) {
       recheckDelayRef.current = 0;
+      setAuthUnconfirmed(false);
       return;
     }
     const delay = recheckDelayRef.current ? Math.min(recheckDelayRef.current * 2, 60000) : 2000;
@@ -251,6 +285,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     setAuthToken(token, persistence);
     setUser(userObj);
     setAuthEnabled(true);
+    setAuthUnconfirmed(false);
   };
 
   const logout = () => {
@@ -321,6 +356,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         user,
         authEnabled,
         loading,
+        authUnconfirmed,
         isAdmin,
         login,
         loginWithToken,

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

@@ -3417,6 +3417,7 @@ export default {
 
   // Login page
   login: {
+    retrying: 'Bambuddy ist nicht erreichbar. Neuer Versuch läuft…',
     title: 'Bambuddy Anmeldung',
     subtitle: 'Melden Sie sich bei Ihrem Konto an',
     username: 'Benutzername',

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

@@ -3449,6 +3449,7 @@ export default {
 
   // Login page
   login: {
+    retrying: 'Can\'t reach Bambuddy. Retrying…',
     title: 'Bambuddy Login',
     subtitle: 'Sign in to your account',
     username: 'Username',

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

@@ -3419,6 +3419,7 @@ export default {
 
   // Login page
   login: {
+    retrying: 'No se puede conectar con Bambuddy. Reintentando…',
     title: 'Inicio de sesión en Bambuddy',
     subtitle: 'Inicie sesión en su cuenta',
     username: 'Nombre de usuario',

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

@@ -3405,6 +3405,7 @@ export default {
 
   // Login page
   login: {
+    retrying: 'Impossible de joindre Bambuddy. Nouvelle tentative…',
     title: 'Connexion Bambuddy',
     subtitle: 'Connectez-vous à votre compte',
     username: 'Utilisateur',

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

@@ -3404,6 +3404,7 @@ export default {
 
   // Login page
   login: {
+    retrying: 'Impossibile raggiungere Bambuddy. Nuovo tentativo…',
     title: 'Login Bambuddy',
     subtitle: 'Accedi al tuo account',
     username: 'Nome utente',

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

@@ -3417,6 +3417,7 @@ export default {
 
   // Login page
   login: {
+    retrying: 'Bambuddy に接続できません。再試行しています…',
     title: 'Bambuddy ログイン',
     subtitle: 'アカウントにサインイン',
     username: 'ユーザー名',

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

@@ -3257,6 +3257,7 @@ export default {
     clearQueue: '대기열을 지우시겠습니까?'
   },
   login: {
+    retrying: 'Bambuddy에 연결할 수 없습니다. 다시 시도하는 중…',
     title: 'Bambuddy 로그인',
     subtitle: '계정에 로그인하세요',
     username: '사용자명',

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -3447,6 +3447,7 @@ export default {
 
   // Login page
   login: {
+    retrying: 'Bambuddy is niet bereikbaar. Opnieuw proberen…',
     title: 'Bambuddy-login',
     subtitle: 'Log in op je account',
     username: 'Gebruikersnaam',

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

@@ -3404,6 +3404,7 @@ export default {
 
   // Login page
   login: {
+    retrying: 'Não foi possível conectar ao Bambuddy. Tentando novamente…',
     title: 'Login Bambuddy',
     subtitle: 'Faça login na sua conta',
     username: 'Nome de usuário',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -3249,6 +3249,7 @@ export default {
     clearQueue: "Очистить очередь печати?",
   },
   login: {
+    retrying: "Bambuddy недоступен. Повторная попытка…",
     title: "Вход в Bambuddy",
     subtitle: "Войдите в свою учётную запись",
     username: "Имя пользователя",

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -3446,6 +3446,7 @@ errors: {
 
   // Login page
   login: {
+    retrying: 'Kan inte nå Bambuddy. Försöker igen…',
     title: 'Bambuddy-inloggning',
     subtitle: 'Logga in på ditt konto',
     username: 'Användarnamn',

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

@@ -3420,6 +3420,7 @@ export default {
 
   // Giriş sayfası
   login: {
+    retrying: 'Bambuddy\'ye ulaşılamıyor. Yeniden deneniyor…',
     title: 'Bambuddy Giriş',
     subtitle: 'Hesabınıza giriş yapın',
     username: 'Kullanıcı Adı',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -3444,6 +3444,7 @@ export default {
 
   // Login page
   login: {
+    retrying: "Bambuddy недоступний. Повторна спроба…",
     title: "Вхід у Bambuddy",
     subtitle: "Увійти у свій обліковий запис",
     username: "Ім’я користувача",

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

@@ -3404,6 +3404,7 @@ export default {
 
   // Login page
   login: {
+    retrying: '无法连接 Bambuddy,正在重试…',
     title: 'Bambuddy 登录',
     subtitle: '登录您的账户',
     username: '用户名',

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

@@ -3404,6 +3404,7 @@ export default {
 
   // Login page
   login: {
+    retrying: '無法連線至 Bambuddy,正在重試…',
     title: 'Bambuddy 登入',
     subtitle: '登入您的帳戶',
     username: '使用者名稱',

+ 6 - 1
frontend/src/pages/CamWallPage.tsx

@@ -28,6 +28,7 @@ import { CAM_WALL_TILE_SIZES, DEFAULT_CAM_WALL_TILE_SIZE } from '../components/c
 import { type CameraTileStatusMode } from '../components/CameraTile';
 import { api, setStreamToken } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
+import { AuthRetrying } from '../components/AuthRetrying';
 
 // 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.
@@ -71,7 +72,7 @@ function tileSizeFrom(params: URLSearchParams): number {
 export function CamWallPage() {
   const { t } = useTranslation();
   const location = useLocation();
-  const { authEnabled, loading: authLoading, user } = useAuth();
+  const { authEnabled, loading: authLoading, user, authUnconfirmed } = useAuth();
   const [searchParams] = useSearchParams();
 
   const token = searchParams.get('token');
@@ -156,6 +157,10 @@ export function CamWallPage() {
     );
   }
 
+  if (!kiosk && authUnconfirmed) {
+    return <AuthRetrying />;
+  }
+
   // 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 }} />;