fix(auth): drop competing 401 interceptor in api.ts
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) <noreply@anthropic.com>
This commit is contained in:
@@ -25,49 +25,13 @@ api.interceptors.request.use((config) => {
|
|||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
// On 401 responses, attempt one silent token refresh. If that also
|
// 401 handling lives in AuthContext.tsx, which mounts a response
|
||||||
// fails, clear stored credentials so the AuthContext falls back to the
|
// interceptor that swaps an expired access_token via /auth/refresh and
|
||||||
// login screen on its next render.
|
// retries the original request. We deliberately don't register a
|
||||||
let isRefreshing = false
|
// competing interceptor here — an earlier version did, and it set
|
||||||
let refreshSubscribers: ((token: string) => void)[] = []
|
// `original._retry = true` and nuked localStorage before AuthContext's
|
||||||
|
// interceptor could run, so every 401 forced a logout instead of a
|
||||||
api.interceptors.response.use(
|
// silent refresh.
|
||||||
(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))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in
|
// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in
|
||||||
// .env → bootstrap on backend startup), so the UI only reads them and
|
// .env → bootstrap on backend startup), so the UI only reads them and
|
||||||
|
|||||||
Reference in New Issue
Block a user