浏览代码

fix(frontend): a tab whose live connection was closed recovers instead of freezing

The server closes a dashboard's WebSocket with 4401 when its session
ended, authentication was turned on, or a scope refresh failed. The tab
treated that as final and froze until reloaded, and a tab opened with
auth off never reached the login. Now a 4401, or a 401/403 when minting
the socket token, makes AuthContext re-check (only a definite answer
changes the state; an unanswered re-check is repeated), so an ended
session goes to the login, and the socket reconnects with a growing
wait (up to 5 min after a 4401, 30 s for a handshake that fails). A 403
for an API key whose owner was deactivated signs the tab out but keeps
the key.
maziggy 1 周之前
父节点
当前提交
b5fb3d80a2
共有 62 个文件被更改,包括 384 次插入 和 38 次删除
  1. 1 0
      CHANGELOG.md
  2. 1 2
      backend/app/api/routes/auth.py
  3. 2 3
      backend/app/api/routes/settings.py
  4. 1 1
      backend/app/core/websocket.py
  5. 3 2
      backend/tests/integration/test_session_end_closes_sockets.py
  6. 96 0
      frontend/src/__tests__/contexts/AuthContext.test.tsx
  7. 133 10
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  8. 77 0
      frontend/src/contexts/AuthContext.tsx
  9. 67 15
      frontend/src/hooks/useWebSocket.ts
  10. 0 0
      static/assets/AdditionalSection-wStRgkZC.js
  11. 0 0
      static/assets/AmsUnitCard-BzEZMAyv.js
  12. 0 0
      static/assets/ArchivesPage-Bcc4cbty.js
  13. 0 0
      static/assets/AssignToAmsModal-Cvnt_f5P.js
  14. 0 0
      static/assets/CamWallPage-B_hCYSL7.js
  15. 0 0
      static/assets/CameraDiagnoseModal-BgtyjxV8.js
  16. 0 0
      static/assets/CameraPage-CbzMwsyA.js
  17. 0 0
      static/assets/CameraWall-AqjsdxyX.js
  18. 0 0
      static/assets/ColumnConfigModal-DGoZMN8Y.js
  19. 0 0
      static/assets/ConfigureAmsSlotModal-C5Zr-GgU.js
  20. 0 0
      static/assets/ConnectAuthorizePage-BJQkcRHR.js
  21. 0 0
      static/assets/ContextMenu-Bl2E00jp.js
  22. 0 0
      static/assets/ExternalLinkPage-C-fYYYwR.js
  23. 0 1
      static/assets/FileManagerPage-TzFTip16.js
  24. 0 0
      static/assets/FileUploadModal-B43oeP1Y.js
  25. 0 0
      static/assets/FinancePage-BJPwOxXy.js
  26. 0 0
      static/assets/GCodeViewerPage-D_amqLVT.js
  27. 0 0
      static/assets/GroupEditPage-Dg4HOfX8.js
  28. 0 0
      static/assets/ImagePreviewModal-BN6z1ZJT.js
  29. 0 0
      static/assets/InventoryPage-BbNSVi97.js
  30. 0 0
      static/assets/LibraryTrashPage-DiiDBa7K.js
  31. 0 0
      static/assets/LoginPage-BCmkXljA.js
  32. 0 0
      static/assets/MaintenancePage-BVnCUGbh.js
  33. 0 0
      static/assets/ModelSourcesPage--RZWJYtU.js
  34. 0 0
      static/assets/NotificationsPage-CF3dva_k.js
  35. 0 1
      static/assets/PdfPreviewModal-CqiOdftE.js
  36. 0 0
      static/assets/PhotoGalleryModal-DKysWUqz.js
  37. 0 0
      static/assets/PrinterLocationsPage-DTCyBr44.js
  38. 0 0
      static/assets/PrintersPage-Bcz3SqgH.js
  39. 0 0
      static/assets/ProfilesPage-CbpW3G4i.js
  40. 0 0
      static/assets/ProjectDetailPage-eBQJVssP.js
  41. 0 0
      static/assets/ProjectsPage-DznWRV-3.js
  42. 0 0
      static/assets/QueuePage-BMJXLnSt.js
  43. 0 0
      static/assets/RichTextEditor-Cte7F-uu.js
  44. 0 0
      static/assets/SettingsPage-C7vSGqsB.js
  45. 0 0
      static/assets/SetupPage-BqAMqdh5.js
  46. 0 0
      static/assets/SliceModal-cPQkfO0Z.js
  47. 0 0
      static/assets/SpoolBuddyAmsPage-DSHITEQP.js
  48. 0 0
      static/assets/SpoolBuddyCalibrationPage-BUHCA4jM.js
  49. 0 0
      static/assets/SpoolBuddyDashboard-ToS71AEj.js
  50. 0 0
      static/assets/SpoolBuddyInventoryPage-imb3fLby.js
  51. 1 1
      static/assets/SpoolBuddySettingsPage-kcI30jqx.js
  52. 0 0
      static/assets/SpoolBuddyWriteTagPage-CLxhOdFc.js
  53. 0 0
      static/assets/SpreadsheetPreviewModal-BBtAa5XY.js
  54. 0 0
      static/assets/StatsPage-BzrMdb9m.js
  55. 0 0
      static/assets/StreamOverlayPage-CVb7u673.js
  56. 0 0
      static/assets/SystemInfoPage-DsvX1Pgw.js
  57. 0 0
      static/assets/index-DUkGVBl-.js
  58. 1 1
      static/assets/inventorySearch-Cv7-TyxG.js
  59. 0 0
      static/assets/locationSensorDefaults-BXl229pn.js
  60. 0 0
      static/assets/pdf-BkFA3nOq.js
  61. 0 0
      static/assets/utils-Cy5KAJaE.js
  62. 1 1
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,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 tab whose live connection was closed by the server now recovers instead of freezing** — When the server closes the live-update connection because of sign-in (your session ended, authentication was just turned on, or a temporary check failed), the page used to stop updating and stay on screen until reloaded. It now re-checks your sign-in: a tab whose session ended goes to the login page, and one that is still signed in reconnects. While its connection keeps being refused or can't be made it waits longer between attempts, up to five minutes when the server closes an accepted connection again and up to 30 seconds otherwise, instead of retrying every three seconds.
 - **Signing a user out everywhere now also closes their open dashboards** — A dashboard's live connection is checked only when it connects. Changing your password, a password reset link, an admin removing someone's two-factor authentication, deactivating a user and restoring a backup all make earlier sign-ins invalid, but a dashboard already open on the account, possibly someone else's, kept receiving live updates until it disconnected, and the link it connected with stayed valid for an hour to connect again. Those live dashboard connections are now closed and their links removed; your own tab reconnects after signing in again. Turning authentication on likewise closes the dashboards opened while it was off, which kept every printer, and disabling an API key, or one expiring, closes the dashboards it opened.
 - **A deleted API key's open connections and tokens no longer pass to the next key** — SQLite gives a deleted key's id to the next key created. Live dashboards opened with the key stayed connected, and the camera and WebSocket links it created stayed valid for up to an hour; both were tied to the key by id and took the new key's printers. Deleting a key, or a user with keys, now closes those connections and removes the links, and links of keys deleted before are removed at start.
 - **A printer added after a delete no longer takes over the deleted printer's print, notifications, camera and plate references** — SQLite gives a deleted printer's id to the next printer created, and Bambuddy kept much of this under that id until the next restart.

+ 1 - 2
backend/app/api/routes/auth.py

@@ -388,8 +388,7 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
         await db.commit()
         if request.auth_enabled:
             # Dashboards opened while it was off have every printer and would
-            # keep them. Closed, such a tab stops updating until it is reloaded
-            # and signs in (the SPA treats the close as final).
+            # keep them. Closed, such a tab re-checks auth and goes to the login.
             from backend.app.core.websocket import ws_manager
 
             await ws_manager.close_anonymous()

+ 2 - 3
backend/app/api/routes/settings.py

@@ -1620,9 +1620,8 @@ async def restore_backup(
             async with _db_mod.engine.begin() as conn:
                 await _db_mod.invalidate_all_sessions(conn)
             # Open dashboards of signed-in users too: they are never signed in
-            # again. API-key and anonymous ones are not closed outright (the
-            # dashboard treats the close as final until a sign-in), but the
-            # restored data decides what they may still see: the anonymous ones
+            # again. API-key and anonymous ones are not closed outright (nothing
+            # ended theirs), but the restored data decides what they may still see: the anonymous ones
             # go if the backup turns authentication on, and the refresh closes
             # those of a key the backup does not have (or has under another key).
             from backend.app.core.auth import invalidate_auth_enabled_cache

+ 1 - 1
backend/app/core/websocket.py

@@ -117,7 +117,7 @@ class ConnectionManager:
     async def close_signed_in(self):
         """Close every signed-in user's socket, after everyone is signed out (a
         backup restore). API-key and anonymous sockets stay: nothing ended
-        theirs, and the dashboard treats the close as final until a sign-in."""
+        theirs."""
         await self._close(lambda c: getattr(c.state, "bambuddy_principal_user_id", None) is not None)
 
     async def close_anonymous(self):

+ 3 - 2
backend/tests/integration/test_session_end_closes_sockets.py

@@ -103,8 +103,9 @@ def _socket(user_id=None, key_id=None):
 @pytest.mark.parametrize("auth_on", [True, False])
 async def test_a_backup_restore_closes_the_signed_in_sockets(async_client: AsyncClient, monkeypatch, tmp_path, auth_on):
     """With auth on in the restored data, sessions end, and what the restored
-    data no longer allows goes. With auth off nothing is closed: the dashboard
-    treats the close as final and would wait for a sign-in that never comes."""
+    data no longer allows goes. With auth off nothing is closed: no sign-in is
+    needed any more (the restore still invalidates every session), and everyone
+    may see every printer."""
     from backend.app.api.routes.settings import create_backup_zip
     from backend.app.core.config import settings as app_settings
     from backend.app.core.websocket import ws_manager

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

@@ -365,6 +365,102 @@ describe('AuthContext', () => {
       });
     });
 
+    it('re-reads the auth status on an auth:recheck event', async () => {
+      // A WebSocket closed 4401 asks for it: auth may have been turned on, or
+      // the session ended, while this tab still holds the old state.
+      const status = vi.fn(() => HttpResponse.json({ auth_enabled: true, requires_setup: false }));
+      server.use(http.get('/api/v1/auth/status', status));
+      const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
+      await waitFor(() => {
+        expect(result.current.user).not.toBeNull();
+      });
+      const before = status.mock.calls.length;
+
+      act(() => {
+        window.dispatchEvent(new CustomEvent('auth:recheck'));
+      });
+
+      await waitFor(() => {
+        expect(status.mock.calls.length).toBe(before + 1);
+      });
+    });
+
+    it.each([
+      ['the auth status', '/api/v1/auth/status'],
+      ['the signed-in user', '/api/v1/auth/me'],
+    ])('keeps the session when a re-check cannot read %s', async (_what, path) => {
+      // A failed scope refresh (a database error) closes every socket with
+      // 4401; the re-check that follows may hit the same failure. Only a
+      // definite answer may sign the tab out or switch it to "auth off".
+      const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
+      await waitFor(() => {
+        expect(result.current.user).not.toBeNull();
+      });
+      const before = result.current.user;
+      server.use(http.get(path, () => HttpResponse.json({ detail: 'database is locked' }, { status: 500 })));
+
+      act(() => {
+        window.dispatchEvent(new CustomEvent('auth:recheck'));
+      });
+      await new Promise((resolve) => setTimeout(resolve, 2500));
+
+      expect(result.current.authEnabled).toBe(true);
+      expect(result.current.user).toEqual(before);
+    });
+
+    it('signs a key whose owner was deactivated out on a re-check, keeping the key', async () => {
+      // /auth/me answers 403 for an API key (a SpoolBuddy kiosk) whose owner is
+      // deactivated or gone: a definite answer, unlike a database error. The
+      // key is kept: reactivating the owner makes it work again, and the kiosk
+      // would otherwise need setting up again.
+      const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
+      await waitFor(() => {
+        expect(result.current.user).not.toBeNull();
+      });
+      server.use(
+        http.get('/api/v1/auth/me', () =>
+          HttpResponse.json({ detail: 'API key owner is deactivated or no longer exists' }, { status: 403 })
+        )
+      );
+
+      act(() => {
+        window.dispatchEvent(new CustomEvent('auth:recheck'));
+      });
+
+      await waitFor(() => {
+        expect(result.current.user).toBeNull();
+      });
+      expect(getAuthToken()).toBe('valid-token');
+    });
+
+    it('repeats a re-check that got no answer until it gets one', async () => {
+      // Nothing else would ask again: after a refused token mint there is no
+      // socket, so no further close can trigger it, and the tab froze.
+      const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
+      await waitFor(() => {
+        expect(result.current.user).not.toBeNull();
+      });
+      let failing = true;
+      server.use(
+        http.get('/api/v1/auth/me', () =>
+          failing
+            ? HttpResponse.json({ detail: 'database is locked' }, { status: 500 })
+            : HttpResponse.json({ detail: 'Token no longer valid' }, { status: 401 })
+        )
+      );
+
+      act(() => {
+        window.dispatchEvent(new CustomEvent('auth:recheck'));
+      });
+      await new Promise((resolve) => setTimeout(resolve, 1500));
+      expect(result.current.user).not.toBeNull(); // kept: no answer yet
+      failing = false;
+
+      await waitFor(() => expect(result.current.user).toBeNull(), { timeout: 5000 });
+      // A definite 401 clears the token too, as the first load does.
+      expect(getAuthToken()).toBeNull();
+    }, 10000);
+
     it('does not crash when the event fires after unmount', async () => {
       const { result, unmount } = renderHook(() => useAuth(), {
         wrapper: createWrapper(),

+ 133 - 10
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -1072,11 +1072,14 @@ describe('useWebSocket hook', () => {
       vi.useRealTimers();
     });
 
-    it('does NOT reconnect after an auth-rejection close (4401)', async () => {
-      // Regression: a 4401 (ws-token invalid/expired or caller lacks
-      // WEBSOCKET_CONNECT) used to reschedule connect() every 3s, spamming
-      // /auth/ws-token forever. It must be terminal now.
+    it('after a 4401 asks for an auth re-check and reconnects with a fresh token', async () => {
+      // The server closes 4401 when the session ended (password change, reset,
+      // deactivation), auth was turned on, or a scope refresh failed. A session
+      // that is still valid reconnects; one that ended fails the token mint,
+      // and the re-check sends the tab to the login page.
       vi.useFakeTimers();
+      const recheck = vi.fn();
+      window.addEventListener('auth:recheck', recheck);
 
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
@@ -1086,27 +1089,142 @@ describe('useWebSocket hook', () => {
       act(() => {
         firstWs.open();
       });
-
       const instanceCountBefore = wsInstances.length;
 
-      // Server rejects auth.
       act(() => {
         firstWs.simulateClose(4401);
       });
+      expect(recheck).toHaveBeenCalledTimes(1);
 
-      // No reconnect even after the 3s window elapses.
       await vi.advanceTimersByTimeAsync(3000);
-      expect(wsInstances.length).toBe(instanceCountBefore);
+      expect(wsInstances.length).toBe(instanceCountBefore + 1);
+
+      window.removeEventListener('auth:recheck', recheck);
+      vi.useRealTimers();
+    });
+
+    it('backs off when the reconnect is refused with 4401 again, instead of looping', async () => {
+      // Regression: a 4401 used to reschedule connect() every 3s, spamming
+      // /auth/ws-token forever. A socket closed with 4401 again before its
+      // first message (the server's check after accepting it) doubles the wait
+      // (capped), so a failure that clears (a database hiccup) still recovers.
+      vi.useFakeTimers();
+
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+
+      await vi.advanceTimersByTimeAsync(0);
+      const firstWs = wsInstances[wsInstances.length - 1]!;
+      act(() => {
+        firstWs.open();
+        firstWs.simulateClose(4401);
+      });
+      await vi.advanceTimersByTimeAsync(3000);
+      const retry = wsInstances[wsInstances.length - 1]!;
+      expect(retry).not.toBe(firstWs);
+      const instanceCountBefore = wsInstances.length;
+
+      act(() => {
+        retry.open();
+        retry.simulateClose(4401); // refused again before any message
+      });
+      await vi.advanceTimersByTimeAsync(3000);
+      expect(wsInstances.length).toBe(instanceCountBefore); // not after 3 s
+      await vi.advanceTimersByTimeAsync(3000);
+      expect(wsInstances.length).toBe(instanceCountBefore + 1); // after 6 s
+
+      vi.useRealTimers();
+    });
+
+    it('backs off when the handshake keeps failing (a refusal shows as 1006)', async () => {
+      // The server refuses a socket before accepting it, which the browser
+      // reports as 1006 with no open, like a server that is down. Retrying
+      // every 3 s would mint a ws-token per tab every 3 s while it lasts.
+      vi.useFakeTimers();
+
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+
+      await vi.advanceTimersByTimeAsync(0);
+      act(() => {
+        wsInstances[wsInstances.length - 1]!.simulateClose(1006); // never opened
+      });
+      await vi.advanceTimersByTimeAsync(3000);
+      const second = wsInstances[wsInstances.length - 1]!;
+      const count = wsInstances.length;
+      act(() => {
+        second.simulateClose(1006); // refused again
+      });
+      await vi.advanceTimersByTimeAsync(3000);
+      expect(wsInstances.length).toBe(count); // not after 3 s
+      await vi.advanceTimersByTimeAsync(3000);
+      expect(wsInstances.length).toBe(count + 1); // after 6 s
+
+      vi.useRealTimers();
+    });
+
+    it('allows a retry again once a reconnected socket was admitted', async () => {
+      vi.useFakeTimers();
+
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+
+      await vi.advanceTimersByTimeAsync(0);
+      const firstWs = wsInstances[wsInstances.length - 1]!;
+      act(() => {
+        firstWs.open();
+        firstWs.simulateClose(4401);
+      });
+      await vi.advanceTimersByTimeAsync(3000);
+      const second = wsInstances[wsInstances.length - 1]!;
+      act(() => {
+        second.open();
+        second.simulateMessage({ type: 'pong' }); // admitted
+        second.simulateClose(4401); // a later, separate rejection
+      });
+      await vi.advanceTimersByTimeAsync(3000);
+      expect(wsInstances[wsInstances.length - 1]).not.toBe(second);
+
+      vi.useRealTimers();
+    });
+
+    it('asks for an auth re-check when the ws-token mint returns 401', async () => {
+      // A tab opened while auth was off, whose socket was down when auth was
+      // turned on: the mint answers 401 "Authentication required", which does
+      // not count as a sign-out in request(). Without a re-check the tab froze.
+      vi.useFakeTimers();
+      const recheck = vi.fn();
+      window.addEventListener('auth:recheck', recheck);
+      vi.stubGlobal(
+        'fetch',
+        vi.fn(async () => ({
+          ok: false,
+          status: 401,
+          statusText: 'Unauthorized',
+          headers: { get: () => null },
+          json: async () => ({ detail: 'Authentication required' }),
+        })),
+      );
+
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+      await vi.advanceTimersByTimeAsync(0);
+
+      expect(recheck).toHaveBeenCalledTimes(1);
+      expect(wsInstances.length).toBe(0);
 
+      window.removeEventListener('auth:recheck', recheck);
       vi.useRealTimers();
     });
 
-    it('does NOT open a socket or reconnect when ws-token mint returns 403', async () => {
+    it('does NOT open a socket or reconnect when ws-token mint returns 403, but re-checks auth', async () => {
       // Mike/Forge's case: an authenticated user whose group lacks
       // WEBSOCKET_CONNECT. POST /auth/ws-token returns 403; the hook must NOT
       // fall through to a tokenless socket (server closes it 4401) and must NOT
       // enter the reconnect loop — it degrades to REST polling instead.
       vi.useFakeTimers();
+      const recheck = vi.fn();
+      window.addEventListener('auth:recheck', recheck);
 
       vi.stubGlobal(
         'fetch',
@@ -1123,10 +1241,15 @@ describe('useWebSocket hook', () => {
       renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
 
       // Flush the token-mint rejection, then let the (would-be) reconnect
-      // window pass. No socket should ever be constructed.
+      // window pass. No socket should ever be constructed. Auth is re-checked:
+      // a 403 also means an API key (a kiosk) whose owner was deactivated while
+      // its socket was down, which then goes to the login; a user who only
+      // lacks the permission gets their user back and nothing changes.
       await vi.advanceTimersByTimeAsync(0);
       await vi.advanceTimersByTimeAsync(3000);
       expect(wsInstances.length).toBe(0);
+      expect(recheck).toHaveBeenCalledTimes(1);
+      window.removeEventListener('auth:recheck', recheck);
 
       vi.useRealTimers();
     });

+ 77 - 0
frontend/src/contexts/AuthContext.tsx

@@ -126,6 +126,74 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     }
   };
 
+  // Like checkAuthStatus, mid-session: only a definite answer changes the
+  // state. The 4401 that asks for it can come from a database failure, which
+  // the re-check may meet too; at first load an error shows the login or the
+  // open UI, but here it would sign a valid tab out or switch it to "auth
+  // off" (every admin control shown) until a reload.
+  // Returns whether it got a definite answer.
+  const recheckAuthStatus = async (): Promise<boolean> => {
+    let status;
+    try {
+      status = await api.getAuthStatus();
+    } catch {
+      return false;
+    }
+    if (!mountedRef.current) return true;
+    setAuthEnabled(status.auth_enabled);
+    setRequiresSetup(status.requires_setup);
+    if (!status.auth_enabled || !getAuthToken()) {
+      setUser(null);
+      return true;
+    }
+    try {
+      const currentUser = await api.getCurrentUser();
+      if (mountedRef.current) setUser(currentUser);
+      return true;
+    } catch (err) {
+      // 401: signed out. request() clears the token only for some messages,
+      // not "Token no longer valid" (a password change or reset) or "Token has
+      // been revoked", so it is cleared here, as the first load does. 403: an
+      // API key (a SpoolBuddy kiosk) whose owner is deactivated or gone; a
+      // user's JWT gets a 401 there. Signed out too, but the key is kept, as
+      // at first load: reactivating the owner makes it work again, and the
+      // kiosk would otherwise need setting up again. Anything else: no
+      // answer, keep.
+      if (!(err instanceof ApiError) || (err.status !== 401 && err.status !== 403)) return false;
+      if (mountedRef.current) {
+        if (err.status === 401) setAuthToken(null);
+        setUser(null);
+      }
+      return true;
+    }
+  };
+
+  // A re-check without an answer is repeated (2 s, doubling to 60 s) until it
+  // gets one: after a refused token mint there is no socket left to ask again.
+  const recheckTimerRef = useRef<number | null>(null);
+  const recheckDelayRef = useRef(0);
+  const runRecheck = async () => {
+    if (recheckTimerRef.current !== null) {
+      clearTimeout(recheckTimerRef.current);
+      recheckTimerRef.current = null;
+    }
+    const answered = await recheckAuthStatus();
+    if (!mountedRef.current) return;
+    if (answered) {
+      recheckDelayRef.current = 0;
+      return;
+    }
+    const delay = recheckDelayRef.current ? Math.min(recheckDelayRef.current * 2, 60000) : 2000;
+    recheckDelayRef.current = delay;
+    recheckTimerRef.current = window.setTimeout(() => {
+      recheckTimerRef.current = null;
+      runRecheck();
+    }, delay);
+  };
+  // The listener below is added once; it calls the current function.
+  const runRecheckRef = useRef(runRecheck);
+  runRecheckRef.current = runRecheck;
+
   useEffect(() => {
     mountedRef.current = true;
     // Check auth status on mount
@@ -140,10 +208,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
       setUser(null);
     };
     window.addEventListener('auth:expired', handleAuthExpired);
+    // A WebSocket closed with 4401: authentication may have been turned on, or
+    // this session ended, while the tab still holds the old state.
+    const handleAuthRecheck = () => {
+      if (!mountedRef.current) return;
+      runRecheckRef.current();
+    };
+    window.addEventListener('auth:recheck', handleAuthRecheck);
 
     return () => {
       mountedRef.current = false;
       window.removeEventListener('auth:expired', handleAuthExpired);
+      window.removeEventListener('auth:recheck', handleAuthRecheck);
+      if (recheckTimerRef.current !== null) clearTimeout(recheckTimerRef.current);
     };
   }, []);
 

+ 67 - 15
frontend/src/hooks/useWebSocket.ts

@@ -5,12 +5,25 @@ import { useTranslation } from 'react-i18next';
 import { api, ApiError } from '../api/client';
 import { inventoryLocationsQueryKey, inventorySuppliersQueryKey } from '../utils/inventoryQueries';
 
-// The only auth-failure close code /api/v1/ws emits (websocket.py
-// _WS_CLOSE_UNAUTHORIZED). A 4401 means the ws-token was missing / invalid /
-// expired, or the caller lacks WEBSOCKET_CONNECT — none of which a reconnect can
-// fix without a fresh login (which remounts this provider anyway). Treat it as
-// terminal so we don't respawn the /auth/ws-token loop.
+// The auth-failure close code of /api/v1/ws (websocket.py
+// _WS_CLOSE_UNAUTHORIZED). It reaches the browser only for a socket the server
+// had accepted: one closed when its session ended (password change or reset,
+// 2FA removal, deactivation, rename), when authentication was turned on, when a
+// scope refresh failed, or by the server's check right after accepting it. The
+// hook asks AuthContext to re-check (a tab whose session ended goes to the
+// login page, which unmounts this provider) and reconnects with a fresh token.
+// Each 4401 before a socket's first message doubles the wait, up to five
+// minutes: no /auth/ws-token loop, and a failure that clears still recovers.
+//
+// A socket refused before it was accepted (no or invalid token, user or key
+// gone, a failed lookup) gets an HTTP 403 on the handshake, which the browser
+// reports as 1006 with no open, as for a server that is down. Those can't be
+// told apart, so they get no re-check, and a backoff capped at 30 s, so tabs
+// still come back quickly after a restart.
 const WS_CLOSE_UNAUTHORIZED = 4401;
+const UNAUTHORIZED_RETRY_FIRST_MS = 3000;
+const UNAUTHORIZED_RETRY_MAX_MS = 300000;
+const HANDSHAKE_RETRY_MAX_MS = 30000;
 
 interface WebSocketMessage {
   type: string;
@@ -38,6 +51,12 @@ export function useWebSocket() {
   // reconnectTimeoutRef, then .close() ran ws.onclose which set a *fresh*
   // timeout — a leaked reconnect that kept minting ws-tokens post-logout).
   const disposedRef = useRef(false);
+  // The wait before the next reconnect after a 4401; 0 once a socket is
+  // admitted (its first message).
+  const unauthorizedDelayRef = useRef(0);
+  // The wait before the next reconnect after a handshake that failed (the
+  // socket never opened); 0 once one opens.
+  const handshakeDelayRef = useRef(0);
   const queryClient = useQueryClient();
   const [isConnected, setIsConnected] = useState(false);
   const lastMissingSpoolWarningRef = useRef<Map<number, string>>(new Map());
@@ -106,12 +125,18 @@ export function useWebSocket() {
     } catch (err) {
       // A 401/403 from the token mint is an AUTH decision, not a transient
       // blip, so retrying is pointless and hammers /auth/ws-token every 3s:
-      //   401 — the JWT expired. ``request()`` already cleared it and
-      //         dispatched ``auth:expired``, so the route guard is redirecting
-      //         to /login and this provider is about to unmount.
+      //   401 — not signed in. An expired JWT: ``request()`` already cleared
+      //         it and dispatched ``auth:expired``, so the route guard is
+      //         redirecting to /login. "Authentication required" (no token,
+      //         e.g. a tab opened while auth was off, which was turned on while
+      //         its socket was down) is not treated as a sign-out there, so
+      //         ask AuthContext to re-check, which then shows the login.
       //   403 — the user is validly logged in but their group lacks
-      //         WEBSOCKET_CONNECT. They stay logged in; live updates simply
-      //         degrade to the REST polling the query cache already does.
+      //         WEBSOCKET_CONNECT (they stay logged in; live updates simply
+      //         degrade to the REST polling the query cache already does), or
+      //         an API key (a kiosk) whose owner was deactivated while its
+      //         socket was down. Re-checked as well: the first gets their user
+      //         back and nothing changes, the second goes to the login.
       // Either way: do NOT open a tokenless socket (the server just closes it
       // 4401) and do NOT reconnect. The old catch-all fell through to a
       // tokenless socket whose 4401 close rescheduled connect() forever. A
@@ -120,6 +145,7 @@ export function useWebSocket() {
       // no token and connect fine).
       const status = err instanceof ApiError ? err.status : 0;
       if (status === 401 || status === 403) {
+        window.dispatchEvent(new CustomEvent('auth:recheck'));
         return;
       }
     }
@@ -135,8 +161,11 @@ export function useWebSocket() {
     const ws = new WebSocket(wsUrl);
 
     let pingInterval: number | null = null;
+    let opened = false;
 
     ws.onopen = () => {
+      opened = true;
+      handshakeDelayRef.current = 0;
       if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Connected');
       setIsConnected(true);
       // Start ping interval
@@ -148,6 +177,7 @@ export function useWebSocket() {
     };
 
     ws.onmessage = (event) => {
+      unauthorizedDelayRef.current = 0;
       try {
         const message: WebSocketMessage = JSON.parse(event.data);
         // Handle printer_status directly (already throttled) to avoid queue delays
@@ -173,11 +203,33 @@ export function useWebSocket() {
       setIsConnected(false);
       wsRef.current = null;
 
-      // Don't reconnect after an auth rejection (4401) or once the provider has
-      // unmounted — both would just respawn the /auth/ws-token loop. A 4401 is
-      // terminal (needs a fresh login, which remounts us); every other close
-      // code is treated as a network drop and gets the 3s reconnect.
-      if (disposedRef.current || event.code === WS_CLOSE_UNAUTHORIZED) {
+      // Don't reconnect once the provider has unmounted. After an auth
+      // rejection (4401): re-check auth, and reconnect with a growing wait (see
+      // WS_CLOSE_UNAUTHORIZED). A socket that never opened (a refused handshake
+      // or a server that is down) also waits longer each time, up to 30 s. One
+      // that opened and then dropped is a network drop: the 3s reconnect.
+      if (disposedRef.current) {
+        return;
+      }
+      if (event.code === WS_CLOSE_UNAUTHORIZED) {
+        window.dispatchEvent(new CustomEvent('auth:recheck'));
+        const delay = unauthorizedDelayRef.current
+          ? Math.min(unauthorizedDelayRef.current * 2, UNAUTHORIZED_RETRY_MAX_MS)
+          : UNAUTHORIZED_RETRY_FIRST_MS;
+        unauthorizedDelayRef.current = delay;
+        reconnectTimeoutRef.current = window.setTimeout(() => {
+          connect();
+        }, delay);
+        return;
+      }
+      if (!opened) {
+        const delay = handshakeDelayRef.current
+          ? Math.min(handshakeDelayRef.current * 2, HANDSHAKE_RETRY_MAX_MS)
+          : UNAUTHORIZED_RETRY_FIRST_MS;
+        handshakeDelayRef.current = delay;
+        reconnectTimeoutRef.current = window.setTimeout(() => {
+          connect();
+        }, delay);
         return;
       }
 

文件差异内容过多而无法显示
+ 0 - 0
static/assets/AdditionalSection-wStRgkZC.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/AmsUnitCard-BzEZMAyv.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/ArchivesPage-Bcc4cbty.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/AssignToAmsModal-Cvnt_f5P.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/CamWallPage-B_hCYSL7.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/CameraDiagnoseModal-BgtyjxV8.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/CameraPage-CbzMwsyA.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/CameraWall-AqjsdxyX.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/ColumnConfigModal-DGoZMN8Y.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/ConfigureAmsSlotModal-C5Zr-GgU.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/ConnectAuthorizePage-BJQkcRHR.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/ContextMenu-Bl2E00jp.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/ExternalLinkPage-C-fYYYwR.js


文件差异内容过多而无法显示
+ 0 - 1
static/assets/FileManagerPage-TzFTip16.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/FileUploadModal-B43oeP1Y.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/FinancePage-BJPwOxXy.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/GCodeViewerPage-D_amqLVT.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/GroupEditPage-Dg4HOfX8.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/ImagePreviewModal-BN6z1ZJT.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/InventoryPage-BbNSVi97.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/LibraryTrashPage-DiiDBa7K.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/LoginPage-BCmkXljA.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/MaintenancePage-BVnCUGbh.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/ModelSourcesPage--RZWJYtU.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/NotificationsPage-CF3dva_k.js


文件差异内容过多而无法显示
+ 0 - 1
static/assets/PdfPreviewModal-CqiOdftE.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/PhotoGalleryModal-DKysWUqz.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/PrinterLocationsPage-DTCyBr44.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/PrintersPage-Bcz3SqgH.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/ProfilesPage-CbpW3G4i.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/ProjectDetailPage-eBQJVssP.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/ProjectsPage-DznWRV-3.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/QueuePage-BMJXLnSt.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/RichTextEditor-Cte7F-uu.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/SettingsPage-C7vSGqsB.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/SetupPage-BqAMqdh5.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/SliceModal-cPQkfO0Z.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/SpoolBuddyAmsPage-DSHITEQP.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-BUHCA4jM.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/SpoolBuddyDashboard-ToS71AEj.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-imb3fLby.js


文件差异内容过多而无法显示
+ 1 - 1
static/assets/SpoolBuddySettingsPage-kcI30jqx.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-CLxhOdFc.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/SpreadsheetPreviewModal-BBtAa5XY.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/StatsPage-BzrMdb9m.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/StreamOverlayPage-CVb7u673.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/SystemInfoPage-DsvX1Pgw.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/index-DUkGVBl-.js


+ 1 - 1
static/assets/inventorySearch-nSMyBii7.js → static/assets/inventorySearch-Cv7-TyxG.js

@@ -1 +1 @@
-import{ft as e}from"./index-3KXgokKF.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{ft as e}from"./index-DUkGVBl-.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

文件差异内容过多而无法显示
+ 0 - 0
static/assets/locationSensorDefaults-BXl229pn.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/pdf-BkFA3nOq.js


文件差异内容过多而无法显示
+ 0 - 0
static/assets/utils-Cy5KAJaE.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-3KXgokKF.js"></script>
+    <script type="module" crossorigin src="/assets/index-DUkGVBl-.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-DnySrlFv.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-Ce8_sN3w.js">

部分文件因为文件数量过多而无法显示