fix(web): auto-refresh 401s + restore Vietnamese breadcrumb text
Some checks failed
Security Scanning / Trivy Scan — Web Image (push) Failing after 42s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 41s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 8s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 53s
Deploy / Build API Image (push) Failing after 17s
Deploy / Build Web Image (push) Failing after 10s
Deploy / Build AI Services Image (push) Failing after 10s
E2E Tests / Playwright E2E (push) Failing after 10s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m15s
Security Scanning / Trivy Filesystem Scan (push) Failing after 37s
Deploy / Deploy to Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Some checks failed
Security Scanning / Trivy Scan — Web Image (push) Failing after 42s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 41s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 8s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 53s
Deploy / Build API Image (push) Failing after 17s
Deploy / Build Web Image (push) Failing after 10s
Deploy / Build AI Services Image (push) Failing after 10s
E2E Tests / Playwright E2E (push) Failing after 10s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m15s
Security Scanning / Trivy Filesystem Scan (push) Failing after 37s
Deploy / Deploy to Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -81,9 +81,9 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-4 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Link href="/" className="hover:text-foreground">Trang ch\u1ee7</Link>
|
||||
<Link href="/" className="hover:text-foreground">Trang chủ</Link>
|
||||
<span>/</span>
|
||||
<Link href="/search" className="hover:text-foreground">T\u00ecm ki\u1ebfm</Link>
|
||||
<Link href="/search" className="hover:text-foreground">Tìm kiếm</Link>
|
||||
<span>/</span>
|
||||
<span className="truncate text-foreground">{property.title}</span>
|
||||
</nav>
|
||||
|
||||
@@ -22,7 +22,52 @@ function getCsrfToken(): string | undefined {
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||
// 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<boolean> | null = null;
|
||||
|
||||
async function tryRefresh(): Promise<boolean> {
|
||||
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<Response> {
|
||||
const { body, headers, ...rest } = options;
|
||||
const method = options.method?.toUpperCase() ?? 'GET';
|
||||
|
||||
@@ -34,7 +79,7 @@ async function request<T>(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<T>(endpoint: string, options: RequestOptions = {}): Promi
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||
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 }));
|
||||
|
||||
Reference in New Issue
Block a user