Compare commits
2 Commits
136f41020f
...
4e9d9e6048
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e9d9e6048 | |||
| d6b25dd78d |
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
86
lib/api.ts
86
lib/api.ts
@ -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));
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user