fix(client): redirect to login when the session expires mid-use

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-10 16:33:01 -05:00
parent e73f51e91b
commit 9efa74e12b
3 changed files with 66 additions and 0 deletions

View File

@ -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);
});
});

View File

@ -134,6 +134,13 @@ async function _fetch<T = unknown>(
_csrfFetch = null; _csrfFetch = null;
return _fetch<T>(method, path, body, true); return _fetch<T>(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; const err = new Error(data?.message || data?.error || `HTTP ${res.status}`) as ApiError;
err.status = res.status; err.status = res.status;
err.data = data || {}; err.data = data || {};

View File

@ -56,6 +56,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
.catch(() => setUser(null)); .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 () => { const logout = async () => {
await api.logout(); await api.logout();
setUser(null); setUser(null);