diff --git a/lib/api.ts b/lib/api.ts index 75fe0ed..28334df 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -34,25 +34,41 @@ export function clearAuth(): void { localStorage.removeItem(USER_KEY); } +// Shared in-flight refresh promise so concurrent 401s (e.g. several widgets +// loading at once) all await the same refresh call instead of each firing +// their own — refresh tokens are single-use, so racing calls would otherwise +// invalidate each other and force a false session-expired logout. +let refreshInFlight: Promise | null = null; + async function tryRefresh(): Promise { + if (refreshInFlight) return refreshInFlight; + + refreshInFlight = (async () => { + try { + const res = await fetch("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) { + clearAuth(); + return false; + } + const payload = (await res.json()) as ApiResponse; + if (payload.error) { + clearAuth(); + return false; + } + return true; + } catch { + clearAuth(); + return false; + } + })(); + try { - const res = await fetch("/api/auth/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); - if (!res.ok) { - clearAuth(); - return false; - } - const payload = (await res.json()) as ApiResponse; - if (payload.error) { - clearAuth(); - return false; - } - return true; - } catch { - clearAuth(); - return false; + return await refreshInFlight; + } finally { + refreshInFlight = null; } }