Compare commits

..

No commits in common. "4e9d9e6048a6d5d441e3612da5a541665407511f" and "136f41020f1f78003cd78e45749eeb97916b5f4d" have entirely different histories.

2 changed files with 80 additions and 91 deletions

View File

@ -3,7 +3,6 @@
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;
@ -76,11 +75,9 @@ 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 payload = await apiFetch<{ linkToken?: string; link_token?: string }>( const res = await fetch("/api/plaid/link-token", { method: "POST" });
"/api/plaid/link-token", const payload = await res.json();
{ method: "POST" } if (!res.ok || payload.error) {
);
if (payload.error) {
setStatus(payload.error?.message ?? "Unable to create link token."); setStatus(payload.error?.message ?? "Unable to create link token.");
return; return;
} }
@ -97,16 +94,19 @@ export default function ConnectPage() {
}, []); }, []);
const loadAccounts = useCallback(async () => { const loadAccounts = useCallback(async () => {
const payload = await apiFetch<{ accounts?: Account[] } | Account[]>("/api/accounts"); const res = await fetch("/api/accounts");
if (payload.error) return; if (!res.ok) {
const data = payload.data as { accounts?: Account[] } | Account[] | undefined; return;
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 payload = await apiFetch<GoogleStatus>("/api/google/status"); const res = await fetch("/api/google/status");
if (payload.error) return; if (!res.ok) return;
setGoogleStatus(payload.data ?? null); const payload = await res.json();
setGoogleStatus(payload.data ?? payload);
}, []); }, []);
const loadTellerScript = useCallback(() => { const loadTellerScript = useCallback(() => {
@ -149,11 +149,13 @@ export default function ConnectPage() {
if (linkMode === "update" && updateAccountRef) { if (linkMode === "update" && updateAccountRef) {
setStatus("Finishing bank reconnection..."); setStatus("Finishing bank reconnection...");
try { try {
const payload = await apiFetch("/api/plaid/repair-complete", { const res = await fetch("/api/plaid/repair-complete", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accountId: updateAccountRef }) body: JSON.stringify({ accountId: updateAccountRef })
}); });
if (payload.error) { const payload = await res.json();
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;
} }
@ -176,11 +178,13 @@ export default function ConnectPage() {
setStatus("Exchanging public token..."); setStatus("Exchanging public token...");
try { try {
const payload = await apiFetch("/api/plaid/exchange", { const res = await fetch("/api/plaid/exchange", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ publicToken }) body: JSON.stringify({ publicToken })
}); });
if (payload.error) { const payload = await res.json();
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Unable to exchange token."); setStatus(payload.error?.message ?? "Unable to exchange token.");
return; return;
} }
@ -217,10 +221,12 @@ export default function ConnectPage() {
accountType: manualType, accountType: manualType,
mask: manualAccount.slice(-4) mask: manualAccount.slice(-4)
}; };
apiFetch("/api/accounts/manual", { fetch("/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.");
@ -241,14 +247,13 @@ 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 payload = await apiFetch<{ linkToken?: string; link_token?: string }>( const res = await fetch("/api/plaid/update-link-token", {
"/api/plaid/update-link-token",
{
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accountId: accountRef }) body: JSON.stringify({ accountId: accountRef })
} });
); const payload = await res.json();
if (payload.error) { if (!res.ok || 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;
} }
@ -278,10 +283,9 @@ export default function ConnectPage() {
return; return;
} }
const configPayload = await apiFetch<{ applicationId: string; environment?: string; products: string[] }>( const configRes = await fetch("/api/teller/config");
"/api/teller/config" const configPayload = await configRes.json();
); 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;
} }
@ -292,11 +296,13 @@ 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 payload = await apiFetch<{ accountCount?: number }>("/api/teller/enrollment", { const res = await fetch("/api/teller/enrollment", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(enrollment) body: JSON.stringify(enrollment)
}); });
if (payload.error) { const payload = await res.json();
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;
} }
@ -311,8 +317,9 @@ export default function ConnectPage() {
const syncTeller = async () => { const syncTeller = async () => {
setStatus("Syncing Teller transactions..."); setStatus("Syncing Teller transactions...");
const payload = await apiFetch<{ created?: number }>("/api/teller/sync", { method: "POST" }); const res = await fetch("/api/teller/sync", { method: "POST" });
if (payload.error) { const payload = await res.json();
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;
} }
@ -322,11 +329,13 @@ export default function ConnectPage() {
const enableSheetsMirror = async () => { const enableSheetsMirror = async () => {
setStatus("Enabling Sheets-first mirror mode..."); setStatus("Enabling Sheets-first mirror mode...");
const payload = await apiFetch("/api/google/data-system-mode", { const res = await fetch("/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" })
}); });
if (payload.error) { const payload = await res.json();
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,35 +34,16 @@ export function clearAuth(): void {
localStorage.removeItem(USER_KEY); localStorage.removeItem(USER_KEY);
} }
// The backend binds every authenticated request to a single-use, rotating // Shared in-flight refresh promise so concurrent 401s (e.g. several widgets
// session nonce: each request must present the current nonce and immediately // loading at once) all await the same refresh call instead of each firing
// invalidates it for the next one. That makes concurrent authenticated // their own — refresh tokens are single-use, so racing calls would otherwise
// requests fundamentally unsafe — if two calls read the same nonce cookie // invalidate each other and force a false session-expired logout.
// before either response comes back to update it (e.g. a dashboard loading let refreshInFlight: Promise<boolean> | null = null;
// 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;
refreshInFlight = (async () => {
try { try {
const res = await fetch("/api/auth/refresh", { const res = await fetch("/api/auth/refresh", {
method: "POST", method: "POST",
@ -82,11 +63,18 @@ async function tryRefresh(): Promise<boolean> {
clearAuth(); clearAuth();
return false; return false;
} }
})();
try {
return await refreshInFlight;
} finally {
refreshInFlight = null;
}
} }
async function performFetch<T>( export async function apiFetch<T = unknown>(
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>),
@ -106,8 +94,7 @@ async function performFetch<T>(
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";
} }
@ -121,10 +108,3 @@ async function performFetch<T>(
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));
}