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

View File

@ -34,16 +34,35 @@ 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<boolean> | null = null;
// The backend binds every authenticated request to a single-use, rotating
// session nonce: each request must present the current nonce and immediately
// invalidates it for the next one. That makes concurrent authenticated
// requests fundamentally unsafe — if two calls read the same nonce cookie
// 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> {
if (refreshInFlight) return refreshInFlight;
refreshInFlight = (async () => {
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
@ -63,18 +82,11 @@ async function tryRefresh(): Promise<boolean> {
clearAuth();
return false;
}
})();
try {
return await refreshInFlight;
} finally {
refreshInFlight = null;
}
}
export async function apiFetch<T = unknown>(
async function performFetch<T>(
path: string,
options: RequestInit = {}
options: RequestInit
): Promise<ApiResponse<T>> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
@ -94,7 +106,8 @@ export async function apiFetch<T = unknown>(
const refreshed = await tryRefresh();
if (refreshed) {
res = await fetch(path, { ...options, headers });
} else {
}
if (!refreshed || res.status === 401) {
if (typeof window !== "undefined") {
window.location.href = "/login";
}
@ -108,3 +121,10 @@ export async function apiFetch<T = unknown>(
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));
}