From d2488b1cc197eb29d9ab9c78cc8e5d8745a78282 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sun, 19 Apr 2026 10:20:04 +0700 Subject: [PATCH] fix(web): auto-refresh 401s + restore Vietnamese breadcrumb text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api-client: on 401 (non-auth endpoints), call /auth/refresh once and retry the original request. Coalesce concurrent refreshes via a shared in-flight promise so burst traffic only fires one refresh. Skip retry for /auth/* to avoid loops. Surfaced by the /listings/new wizard where an expired access_token cookie made the first submit throw "Unauthorized" even though goodgo_authenticated=1 was still set. - listing-detail-client: breadcrumb was `Trang ch\u1ee7` / `T\u00ecm ki\u1ebfm` written as JSX text, not a string literal — rendered the raw escape sequence. Replaced with "Trang chủ" / "Tìm kiếm". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../listings/listing-detail-client.tsx | 4 +- apps/web/lib/api-client.ts | 62 ++++++++++++++++++- 2 files changed, 62 insertions(+), 4 deletions(-) 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 }));