Compare commits

...

2 Commits

Author SHA1 Message Date
4e9d9e6048 Route bank-connection page through the queued auth client
/app/connect fired 3 authenticated requests concurrently on mount
(link token, accounts, Google status) via raw fetch, bypassing both
the new request queue and any refresh-on-401 handling entirely. Same
nonce-race class of bug as the dashboard, plus no recovery path at
all when a call failed. Converted every call on this page to
apiFetch.
2026-08-26 20:41:23 +05:30
d6b25dd78d Serialize authenticated requests to fix persistent login redirect loop
The last fix only deduplicated concurrent refresh calls, but the
underlying problem is broader: the backend's session nonce is
single-use and rotates on every authenticated request, so ANY two
concurrent authenticated calls race for it, not just refresh calls.
The dashboard fires ~5 authenticated requests in parallel (summary,
cashflow, merchants, accounts, transactions) plus the app shell's
own profile fetch, all reading the same nonce cookie before any
response updates it. Only the first to reach the server can win;
losers get a nonce-mismatch 401, and if that race happens to catch
the refresh call itself against another still-rotating request,
refresh legitimately fails and forces a hard logout. That's what
produced the "works for a while, then dumped back to login" pattern.

Fix: queue every apiFetch call so only one nonce-consuming request
is ever in flight per tab, refresh-and-retry included. This fully
removes the race within a tab (a second tab open to the same
account is a separate, much rarer case and not what was happening
here). Also redirect to login if a post-refresh retry still 401s,
since under serialization that means the session is genuinely
invalid rather than a timing collision.
2026-08-26 20:41:23 +05:30
2 changed files with 89 additions and 78 deletions

View File

@ -3,6 +3,7 @@
import { AppShell } from "../../../components/app-shell"; import { AppShell } from "../../../components/app-shell";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { usePlaidLink } from "react-plaid-link"; import { usePlaidLink } from "react-plaid-link";
import { apiFetch } from "@/lib/api";
type Account = { type Account = {
viewRef: string; viewRef: string;
@ -75,9 +76,11 @@ export default function ConnectPage() {
const createLinkToken = useCallback(async () => { const createLinkToken = useCallback(async () => {
setStatus("Requesting Plaid link token..."); setStatus("Requesting Plaid link token...");
try { try {
const res = await fetch("/api/plaid/link-token", { method: "POST" }); const payload = await apiFetch<{ linkToken?: string; link_token?: string }>(
const payload = await res.json(); "/api/plaid/link-token",
if (!res.ok || payload.error) { { method: "POST" }
);
if (payload.error) {
setStatus(payload.error?.message ?? "Unable to create link token."); setStatus(payload.error?.message ?? "Unable to create link token.");
return; return;
} }
@ -94,19 +97,16 @@ export default function ConnectPage() {
}, []); }, []);
const loadAccounts = useCallback(async () => { const loadAccounts = useCallback(async () => {
const res = await fetch("/api/accounts"); const payload = await apiFetch<{ accounts?: Account[] } | Account[]>("/api/accounts");
if (!res.ok) { if (payload.error) return;
return; const data = payload.data as { accounts?: Account[] } | Account[] | undefined;
} setAccounts((Array.isArray(data) ? data : data?.accounts) ?? []);
const payload = await res.json();
setAccounts(payload.data?.accounts ?? payload.data ?? []);
}, []); }, []);
const loadGoogleStatus = useCallback(async () => { const loadGoogleStatus = useCallback(async () => {
const res = await fetch("/api/google/status"); const payload = await apiFetch<GoogleStatus>("/api/google/status");
if (!res.ok) return; if (payload.error) return;
const payload = await res.json(); setGoogleStatus(payload.data ?? null);
setGoogleStatus(payload.data ?? payload);
}, []); }, []);
const loadTellerScript = useCallback(() => { const loadTellerScript = useCallback(() => {
@ -149,13 +149,11 @@ export default function ConnectPage() {
if (linkMode === "update" && updateAccountRef) { if (linkMode === "update" && updateAccountRef) {
setStatus("Finishing bank reconnection..."); setStatus("Finishing bank reconnection...");
try { try {
const res = await fetch("/api/plaid/repair-complete", { const payload = await apiFetch("/api/plaid/repair-complete", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accountId: updateAccountRef }) body: JSON.stringify({ accountId: updateAccountRef })
}); });
const payload = await res.json(); if (payload.error) {
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Unable to finish bank reconnection."); setStatus(payload.error?.message ?? "Unable to finish bank reconnection.");
return; return;
} }
@ -178,13 +176,11 @@ export default function ConnectPage() {
setStatus("Exchanging public token..."); setStatus("Exchanging public token...");
try { try {
const res = await fetch("/api/plaid/exchange", { const payload = await apiFetch("/api/plaid/exchange", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ publicToken }) body: JSON.stringify({ publicToken })
}); });
const payload = await res.json(); if (payload.error) {
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Unable to exchange token."); setStatus(payload.error?.message ?? "Unable to exchange token.");
return; return;
} }
@ -221,12 +217,10 @@ export default function ConnectPage() {
accountType: manualType, accountType: manualType,
mask: manualAccount.slice(-4) mask: manualAccount.slice(-4)
}; };
fetch("/api/accounts/manual", { apiFetch("/api/accounts/manual", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
.then((res) => res.json())
.then((data) => { .then((data) => {
if (data?.error) { if (data?.error) {
setStatus(data.error?.message ?? "Unable to save manual account."); setStatus(data.error?.message ?? "Unable to save manual account.");
@ -247,13 +241,14 @@ export default function ConnectPage() {
const startUpdateMode = async (accountRef: string) => { const startUpdateMode = async (accountRef: string) => {
setStatus("Requesting Plaid update-mode link token..."); setStatus("Requesting Plaid update-mode link token...");
try { try {
const res = await fetch("/api/plaid/update-link-token", { const payload = await apiFetch<{ linkToken?: string; link_token?: string }>(
method: "POST", "/api/plaid/update-link-token",
headers: { "Content-Type": "application/json" }, {
body: JSON.stringify({ accountId: accountRef }) method: "POST",
}); body: JSON.stringify({ accountId: accountRef })
const payload = await res.json(); }
if (!res.ok || payload.error) { );
if (payload.error) {
setStatus(payload.error?.message ?? "Unable to create update-mode link token."); setStatus(payload.error?.message ?? "Unable to create update-mode link token.");
return; return;
} }
@ -283,9 +278,10 @@ export default function ConnectPage() {
return; return;
} }
const configRes = await fetch("/api/teller/config"); const configPayload = await apiFetch<{ applicationId: string; environment?: string; products: string[] }>(
const configPayload = await configRes.json(); "/api/teller/config"
if (!configRes.ok || configPayload.error) { );
if (configPayload.error) {
setStatus(configPayload.error?.message ?? "Teller is not configured."); setStatus(configPayload.error?.message ?? "Teller is not configured.");
return; return;
} }
@ -296,13 +292,11 @@ export default function ConnectPage() {
products: configPayload.data.products, products: configPayload.data.products,
onSuccess: async (enrollment) => { onSuccess: async (enrollment) => {
setStatus("Importing Teller accounts..."); setStatus("Importing Teller accounts...");
const res = await fetch("/api/teller/enrollment", { const payload = await apiFetch<{ accountCount?: number }>("/api/teller/enrollment", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(enrollment) body: JSON.stringify(enrollment)
}); });
const payload = await res.json(); if (payload.error) {
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Unable to import Teller accounts."); setStatus(payload.error?.message ?? "Unable to import Teller accounts.");
return; return;
} }
@ -317,9 +311,8 @@ export default function ConnectPage() {
const syncTeller = async () => { const syncTeller = async () => {
setStatus("Syncing Teller transactions..."); setStatus("Syncing Teller transactions...");
const res = await fetch("/api/teller/sync", { method: "POST" }); const payload = await apiFetch<{ created?: number }>("/api/teller/sync", { method: "POST" });
const payload = await res.json(); if (payload.error) {
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Unable to sync Teller transactions."); setStatus(payload.error?.message ?? "Unable to sync Teller transactions.");
return; return;
} }
@ -329,13 +322,11 @@ export default function ConnectPage() {
const enableSheetsMirror = async () => { const enableSheetsMirror = async () => {
setStatus("Enabling Sheets-first mirror mode..."); setStatus("Enabling Sheets-first mirror mode...");
const res = await fetch("/api/google/data-system-mode", { const payload = await apiFetch("/api/google/data-system-mode", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "google_sheets_mirror" }) body: JSON.stringify({ mode: "google_sheets_mirror" })
}); });
const payload = await res.json(); if (payload.error) {
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Unable to enable Sheets-first mirror mode."); setStatus(payload.error?.message ?? "Unable to enable Sheets-first mirror mode.");
return; return;
} }

View File

@ -34,47 +34,59 @@ export function clearAuth(): void {
localStorage.removeItem(USER_KEY); localStorage.removeItem(USER_KEY);
} }
// Shared in-flight refresh promise so concurrent 401s (e.g. several widgets // The backend binds every authenticated request to a single-use, rotating
// loading at once) all await the same refresh call instead of each firing // session nonce: each request must present the current nonce and immediately
// their own — refresh tokens are single-use, so racing calls would otherwise // invalidates it for the next one. That makes concurrent authenticated
// invalidate each other and force a false session-expired logout. // requests fundamentally unsafe — if two calls read the same nonce cookie
let refreshInFlight: Promise<boolean> | null = null; // before either response comes back to update it (e.g. a dashboard loading
// several widgets in parallel with Promise.all), only the first one to reach
// the server can win; every other one, including a refresh call caught in
// the same race, gets rejected and can cascade into a forced logout.
//
// The only fully correct fix on this side is to never let more than one
// nonce-consuming request be in flight at once: every apiFetch call is
// queued and runs strictly after the previous one (refresh-and-retry
// included) has completely finished, so the nonce cookie is always settled
// before the next request reads it. This only serializes requests within
// this browser tab — a second tab open to the same account still shares the
// same nonce cookie and could race against this one; that's a separate,
// rarer case and not what was happening here.
let requestQueue: Promise<unknown> = Promise.resolve();
function enqueue<T>(task: () => Promise<T>): Promise<T> {
const result = requestQueue.then(task, task);
requestQueue = result.then(
() => undefined,
() => undefined
);
return result;
}
async function tryRefresh(): Promise<boolean> { async function tryRefresh(): Promise<boolean> {
if (refreshInFlight) return refreshInFlight; try {
const res = await fetch("/api/auth/refresh", {
refreshInFlight = (async () => { method: "POST",
try { headers: { "Content-Type": "application/json" },
const res = await fetch("/api/auth/refresh", { });
method: "POST", if (!res.ok) {
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
clearAuth();
return false;
}
const payload = (await res.json()) as ApiResponse<unknown>;
if (payload.error) {
clearAuth();
return false;
}
return true;
} catch {
clearAuth(); clearAuth();
return false; return false;
} }
})(); const payload = (await res.json()) as ApiResponse<unknown>;
if (payload.error) {
try { clearAuth();
return await refreshInFlight; return false;
} finally { }
refreshInFlight = null; return true;
} catch {
clearAuth();
return false;
} }
} }
export async function apiFetch<T = unknown>( async function performFetch<T>(
path: string, path: string,
options: RequestInit = {} options: RequestInit
): Promise<ApiResponse<T>> { ): Promise<ApiResponse<T>> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
...(options.headers as Record<string, string>), ...(options.headers as Record<string, string>),
@ -94,7 +106,8 @@ export async function apiFetch<T = unknown>(
const refreshed = await tryRefresh(); const refreshed = await tryRefresh();
if (refreshed) { if (refreshed) {
res = await fetch(path, { ...options, headers }); res = await fetch(path, { ...options, headers });
} else { }
if (!refreshed || res.status === 401) {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.location.href = "/login"; window.location.href = "/login";
} }
@ -108,3 +121,10 @@ export async function apiFetch<T = unknown>(
return res.json() as Promise<ApiResponse<T>>; return res.json() as Promise<ApiResponse<T>>;
} }
export function apiFetch<T = unknown>(
path: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
return enqueue(() => performFetch<T>(path, options));
}