fix(auth): redirect to /login when JWT expires mid-session (#1698)

When the JWT expired on an open tab, the next API request hit a 401 with
  "Token has expired"; client.ts cleared the token from storage but
  AuthContext.user stayed populated from the original mount. ProtectedRoute
  only redirects when user === null, so the protected tree kept rendering
  and every subsequent request silently failed with no Authorization
  header — the UI looked like every list was empty until a manual refresh
  remounted AuthProvider.

  The 3 other setAuthToken(null) sites live inside AuthContext itself and
  already pair with setUser(null), so only the client.ts cross-module
  site needed a React-tree signal.

  - client.ts: after setAuthToken(null) on a token-invalidating 401,
    window.dispatchEvent(new CustomEvent('auth:expired')). Guarded on
    `typeof window !== 'undefined'` for SSR / test safety. Generic
    "Authentication required" 401s still don't clear the token or fire
    the event — treated as transient timing issues per the pre-existing
    comment at client.ts:155.

  - AuthContext.tsx: mount useEffect adds a window listener that calls
    setUser(null) under the mountedRef guard; cleanup removes the
    listener so unmount → remount doesn't double-bind.

  Mirrors the patch the reporter shipped on their fork (deec96d1).
This commit is contained in:
maziggy
2026-06-09 09:25:12 +02:00
parent 72044e3a53
commit e9b6fefe95
7 changed files with 136 additions and 5 deletions
+49
View File
@@ -196,6 +196,55 @@ describe('API Client Auth Header', () => {
// Token should NOT be cleared for generic auth errors (might be timing issue)
expect(getAuthToken()).toBe('valid-token');
});
it("dispatches 'auth:expired' event on 401 with invalid token message (#1698)", async () => {
server.use(
http.get('/api/v1/settings/spoolman', () => {
return HttpResponse.json(
{ detail: 'Token has expired' },
{ status: 401 }
);
})
);
setAuthToken('expired-token');
const listener = vi.fn();
window.addEventListener('auth:expired', listener);
try {
await api.getSpoolmanSettings();
} catch {
// Expected to throw
}
expect(listener).toHaveBeenCalledTimes(1);
window.removeEventListener('auth:expired', listener);
});
it("does not dispatch 'auth:expired' on 401 with generic auth error (#1698)", async () => {
server.use(
http.get('/api/v1/settings/spoolman', () => {
return HttpResponse.json(
{ detail: 'Authentication required' },
{ status: 401 }
);
})
);
setAuthToken('valid-token');
const listener = vi.fn();
window.addEventListener('auth:expired', listener);
try {
await api.getSpoolmanSettings();
} catch {
// Expected to throw
}
// Generic 401s might be timing issues, not real expiries — must NOT redirect.
expect(listener).not.toHaveBeenCalled();
window.removeEventListener('auth:expired', listener);
});
});
describe('Slicer download URLs', () => {