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;
|
||||
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;
|
||||
err.status = res.status;
|
||||
err.data = data || {};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue