diff --git a/apps/web/components/listings/listing-detail-client.tsx b/apps/web/components/listings/listing-detail-client.tsx index 1dd5658..ac94e8f 100644 --- a/apps/web/components/listings/listing-detail-client.tsx +++ b/apps/web/components/listings/listing-detail-client.tsx @@ -81,9 +81,9 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
{/* Breadcrumb */} diff --git a/apps/web/lib/api-client.ts b/apps/web/lib/api-client.ts index 8f73270..f148980 100644 --- a/apps/web/lib/api-client.ts +++ b/apps/web/lib/api-client.ts @@ -22,7 +22,52 @@ function getCsrfToken(): string | undefined { const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); -async function request(endpoint: string, options: RequestOptions = {}): Promise { +// Endpoints for which a transparent refresh-and-retry on 401 must NOT run +// (refresh itself, login, logout, exchange-token, etc.). +const AUTH_ENDPOINTS = new Set([ + '/auth/refresh', + '/auth/login', + '/auth/logout', + '/auth/register', + '/auth/exchange-token', + '/auth/forgot-password', + '/auth/reset-password', +]); + +/** + * Coalesce concurrent refresh attempts: if ten requests race a 401 at the same + * moment, we should fire `/auth/refresh` once, not ten times. + */ +let refreshInflight: Promise | null = null; + +async function tryRefresh(): Promise { + if (refreshInflight) return refreshInflight; + refreshInflight = (async () => { + try { + const csrfToken = getCsrfToken(); + const res = await fetch(`${API_BASE_URL}/auth/refresh`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}), + }, + body: JSON.stringify({}), + }); + return res.ok; + } catch { + return false; + } finally { + // Allow a new refresh attempt after this one settles. + setTimeout(() => { + refreshInflight = null; + }, 0); + } + })(); + return refreshInflight; +} + +async function doFetch(endpoint: string, options: RequestOptions): Promise { const { body, headers, ...rest } = options; const method = options.method?.toUpperCase() ?? 'GET'; @@ -34,7 +79,7 @@ async function request(endpoint: string, options: RequestOptions = {}): Promi } } - const res = await fetch(`${API_BASE_URL}${endpoint}`, { + return fetch(`${API_BASE_URL}${endpoint}`, { ...rest, credentials: 'include', headers: { @@ -44,6 +89,19 @@ async function request(endpoint: string, options: RequestOptions = {}): Promi }, body: body ? JSON.stringify(body) : undefined, }); +} + +async function request(endpoint: string, options: RequestOptions = {}): Promise { + let res = await doFetch(endpoint, options); + + // If the access_token expired silently, try one refresh+retry before giving + // up. Skip for auth endpoints themselves to avoid loops. + if (res.status === 401 && !AUTH_ENDPOINTS.has(endpoint)) { + const refreshed = await tryRefresh(); + if (refreshed) { + res = await doFetch(endpoint, options); + } + } if (!res.ok) { const error = await res.json().catch(() => ({ message: res.statusText }));