From 9efa74e12b1b4ad87bb3ddf1c519342787df4d30 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 10 Jul 2026 16:33:01 -0500 Subject: [PATCH] fix(client): redirect to login when the session expires mid-use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useAuth only checked /auth/me on mount, so a session that expired later left the app half-alive: mutations failed with raw 401 toasts and no way back to login. api.ts now dispatches 'auth:expired' on any 401 outside /auth/* (under /auth/* a 401 means wrong credential input — e.g. change-password — and must not log the user out); AuthProvider listens and clears the user, so the existing RequireAuth redirect kicks in preserving state.from. Test: client/api.sessionExpiry.test.ts (both directions). (QA follow-up 1e, error-handling audit) Co-Authored-By: Claude Fable 5 --- client/api.sessionExpiry.test.ts | 51 ++++++++++++++++++++++++++++++++ client/api.ts | 7 +++++ client/hooks/useAuth.tsx | 8 +++++ 3 files changed, 66 insertions(+) create mode 100644 client/api.sessionExpiry.test.ts diff --git a/client/api.sessionExpiry.test.ts b/client/api.sessionExpiry.test.ts new file mode 100644 index 0000000..981136d --- /dev/null +++ b/client/api.sessionExpiry.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment jsdom + +// 1e (QA follow-up) — a 401 outside /auth/* means the session died mid-use: +// api.ts must dispatch 'auth:expired' (AuthProvider clears the user and +// RequireAuth redirects). A 401 under /auth/* is "wrong credential input" +// (e.g. change-password with a bad current password) and must NOT dispatch. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { api } from '@/api'; + +function mock401(): typeof fetch { + return vi.fn( + async () => + new Response( + JSON.stringify({ error: 'AUTH_ERROR', message: 'Unauthorized', code: 'AUTH_ERROR' }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) as unknown as typeof fetch; +} + +describe('session-expiry dispatch on 401', () => { + let expiredEvents: number; + const onExpired = () => { + expiredEvents += 1; + }; + + beforeEach(() => { + expiredEvents = 0; + window.addEventListener('auth:expired', onExpired); + vi.stubGlobal('fetch', mock401()); + }); + + afterEach(() => { + window.removeEventListener('auth:expired', onExpired); + vi.unstubAllGlobals(); + }); + + it('dispatches auth:expired for a 401 on a data endpoint', async () => { + await expect(api.bills()).rejects.toMatchObject({ status: 401 }); + expect(expiredEvents).toBe(1); + }); + + it('does NOT dispatch for a 401 under /auth/* (wrong credential input)', async () => { + await expect( + api.changePassword({ current_password: 'wrong', new_password: 'longenough1' }), + ).rejects.toMatchObject({ status: 401 }); + expect(expiredEvents).toBe(0); + }); +}); diff --git a/client/api.ts b/client/api.ts index ccca27e..4bd386f 100644 --- a/client/api.ts +++ b/client/api.ts @@ -134,6 +134,13 @@ async function _fetch( _csrfFetch = null; return _fetch(method, path, body, true); } + // Session expired mid-use: a 401 outside /auth/* means the session cookie + // died (under /auth/* a 401 is "wrong credential input" — e.g. bad current + // password — and must NOT log the user out). AuthProvider listens and + // clears the user, so RequireAuth redirects to /login preserving state.from. + if (res.status === 401 && !path.startsWith('/auth/')) { + window.dispatchEvent(new Event('auth:expired')); + } const err = new Error(data?.message || data?.error || `HTTP ${res.status}`) as ApiError; err.status = res.status; err.data = data || {}; diff --git a/client/hooks/useAuth.tsx b/client/hooks/useAuth.tsx index 452ed25..d242509 100644 --- a/client/hooks/useAuth.tsx +++ b/client/hooks/useAuth.tsx @@ -56,6 +56,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { .catch(() => setUser(null)); }, []); + // Session expired mid-use (api.ts dispatches on a 401 outside /auth/*): + // clearing the user makes RequireAuth redirect to /login with state.from. + useEffect(() => { + const onExpired = () => setUser(null); + window.addEventListener('auth:expired', onExpired); + return () => window.removeEventListener('auth:expired', onExpired); + }, []); + const logout = async () => { await api.logout(); setUser(null);