From 4b137989c674443b01db07a01c22fe9d616e11db Mon Sep 17 00:00:00 2001 From: Claudio Date: Sun, 26 Apr 2026 11:14:45 +0200 Subject: [PATCH] fix(auth): drop competing 401 interceptor in api.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two response interceptors were stomping on each other: 1. api.ts (this file, registered at module import) — on 401, set original._retry = true, removed access_token from localStorage, and rejected. The comment claimed it relied on a "scheduled refresh in AuthContext" that does not exist in the codebase. 2. AuthContext useEffect — proper refresh: POST /auth/refresh, swap both tokens, retry the original request. Axios runs response interceptors in registration order, so api.ts ran first and pre-emptively burned the _retry flag + access_token before AuthContext could see the 401. Result: every expired-token request forced a re-login instead of a silent refresh. Drop api.ts's response interceptor entirely. AuthContext owns the refresh dance; the request interceptor here just attaches the bearer. Companion bump in .env (gitignored): ACCESS_TOKEN_EXPIRE_MINUTES=10080 (7 days), REFRESH_TOKEN_EXPIRE_DAYS=365 — homelab posture, fewer refresh round-trips per session even when the silent refresh works. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/services/api.ts | 50 +++++------------------------------- 1 file changed, 7 insertions(+), 43 deletions(-) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4479995..918dea8 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -25,49 +25,13 @@ api.interceptors.request.use((config) => { return config }) -// On 401 responses, attempt one silent token refresh. If that also -// fails, clear stored credentials so the AuthContext falls back to the -// login screen on its next render. -let isRefreshing = false -let refreshSubscribers: ((token: string) => void)[] = [] - -api.interceptors.response.use( - (response) => response, - async (error) => { - const original = error.config - if (error.response?.status !== 401 || original._retry) { - return Promise.reject(error) - } - - // Skip retry for auth endpoints themselves to avoid loops. - if (original.url?.startsWith('/auth/')) { - return Promise.reject(error) - } - - original._retry = true - - if (!isRefreshing) { - isRefreshing = true - // The refresh token lives in AuthContext memory, not in - // localStorage. The interceptor can't access it directly, so we - // rely on the AuthContext's scheduled refresh to keep the access - // token fresh. If the access token is truly expired and no - // refresh has happened, we just force a logout. - localStorage.removeItem('access_token') - isRefreshing = false - // Reject — AuthContext will detect the missing token and show login. - return Promise.reject(error) - } - - // Another request is already refreshing — queue this one. - return new Promise((resolve) => { - refreshSubscribers.push((token: string) => { - original.headers.Authorization = `Bearer ${token}` - resolve(api(original)) - }) - }) - }, -) +// 401 handling lives in AuthContext.tsx, which mounts a response +// interceptor that swaps an expired access_token via /auth/refresh and +// retries the original request. We deliberately don't register a +// competing interceptor here — an earlier version did, and it set +// `original._retry = true` and nuked localStorage before AuthContext's +// interceptor could run, so every 401 forced a logout instead of a +// silent refresh. // Source Folders API. Source roots are config-driven now (PHOTO_DIRS in // .env → bootstrap on backend startup), so the UI only reads them and