From 4709df9189487d284ba1599f2699dcd15895ed52 Mon Sep 17 00:00:00 2001 From: Zuliyat Date: Wed, 30 Sep 2026 00:21:55 +0100 Subject: [PATCH] fix(auth): handle expired sessions in apiFetch --- frontend/components/ToastProvider.tsx | 17 ++++++++---- frontend/lib/apiFetch.test.ts | 38 +++++++++++++++++++++++++++ frontend/lib/apiFetch.ts | 12 ++++++++- 3 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 frontend/lib/apiFetch.test.ts diff --git a/frontend/components/ToastProvider.tsx b/frontend/components/ToastProvider.tsx index 15c982d..84b7b34 100644 --- a/frontend/components/ToastProvider.tsx +++ b/frontend/components/ToastProvider.tsx @@ -94,19 +94,26 @@ export function ToastProvider({ children }: { children: ReactNode }) { // createPortal needs `document`, which doesn't exist during SSR — mirrors // the mounted-guard pattern already used in ThemeToggle.tsx. - useEffect(() => setMounted(true), []); + useEffect(() => setMounted(true), []); const dismiss = useCallback((id: string) => { setToasts((prev) => prev.filter((t) => t.id !== id)); }, []); - const push = useCallback( + const push = useCallback( (type: ToastType, message: string, duration = DEFAULT_DURATION_MS) => { const id = `toast-${nextId.current++}`; setToasts((prev) => [...prev, { id, type, message, duration }]); }, - [], - ); + [], + ); + + useEffect(() => { + const onSessionExpired = () => + push("error", "Your session has expired — please log in again", 0); + window.addEventListener("airflex:session-expired", onSessionExpired); + return () => window.removeEventListener("airflex:session-expired", onSessionExpired); + }, [push]); const value = useMemo( () => ({ @@ -135,4 +142,4 @@ export function ToastProvider({ children }: { children: ReactNode }) { )} ); -} \ No newline at end of file +} diff --git a/frontend/lib/apiFetch.test.ts b/frontend/lib/apiFetch.test.ts new file mode 100644 index 0000000..18c6187 --- /dev/null +++ b/frontend/lib/apiFetch.test.ts @@ -0,0 +1,38 @@ +import { apiFetch, ApiError } from "./apiFetch"; +import { clearToken, getToken } from "../app/lib/auth"; + +jest.mock("../app/lib/auth", () => ({ + clearToken: jest.fn(), + getToken: jest.fn(), +})); + +describe("apiFetch session expiry", () => { + beforeEach(() => { + jest.clearAllMocks(); + (getToken as jest.Mock).mockReturnValue("expired-token"); + global.fetch = jest.fn(); + window.history.replaceState({}, "", "/wallet?tab=offers"); + }); + + it("clears the session, emits the toast event, and redirects with returnTo on 401", async () => { + const expired = jest.fn(); + const redirect = jest.spyOn(window.location, "assign").mockImplementation(() => {}); + window.addEventListener("airflex:session-expired", expired); + (global.fetch as jest.Mock).mockResolvedValue( + new Response(JSON.stringify({ message: "expired" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect(apiFetch("/api/private")).rejects.toBeInstanceOf(ApiError); + + expect(clearToken).toHaveBeenCalledTimes(1); + expect(expired).toHaveBeenCalledTimes(1); + expect(redirect).toHaveBeenCalledWith( + "/auth/signup?returnTo=%2Fwallet%3Ftab%3Doffers", + ); + window.removeEventListener("airflex:session-expired", expired); + redirect.mockRestore(); + }); +}); diff --git a/frontend/lib/apiFetch.ts b/frontend/lib/apiFetch.ts index f219bf0..6880e5d 100644 --- a/frontend/lib/apiFetch.ts +++ b/frontend/lib/apiFetch.ts @@ -2,7 +2,7 @@ * apiFetch.ts — Centralized API fetch wrapper and error type for frontend. */ -import { getToken } from "../app/lib/auth"; +import { clearToken, getToken } from "../app/lib/auth"; export class ApiError extends Error { status: number; @@ -63,6 +63,16 @@ export async function apiFetch( // response was not JSON } + if (response.status === 401 && typeof window !== "undefined") { + clearToken(); + window.dispatchEvent(new CustomEvent("airflex:session-expired")); + + const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (!window.location.pathname.startsWith("/auth/signup")) { + window.location.assign(`/auth/signup?returnTo=${encodeURIComponent(returnTo)}`); + } + } + throw new ApiError(errorMessage, response.status, errorData); }