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:
parent
e73f51e91b
commit
9efa74e12b
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 || {};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue