fix: token refresh and seed tree visibility
Fix broken JWT token refresh that caused "Failed to load trees" after idle timeout. The refresh endpoint expected token as query param but frontend sent it as Authorization header. Added proper dependency (get_refresh_token_payload) and refresh queue to handle concurrent 401s. Also fix seed trees not being visible to non-admin users by updating the seed script to set is_public/is_default on existing trees. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
@@ -21,45 +22,97 @@ apiClient.interceptors.request.use(
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// Refresh queue: when multiple requests hit 401 simultaneously,
|
||||
// only the first triggers the actual refresh. Others wait for the result.
|
||||
let isRefreshing = false
|
||||
let refreshSubscribers: ((token: string) => void)[] = []
|
||||
let refreshFailSubscribers: ((error: unknown) => void)[] = []
|
||||
|
||||
function onRefreshed(token: string) {
|
||||
refreshSubscribers.forEach(cb => cb(token))
|
||||
refreshSubscribers = []
|
||||
refreshFailSubscribers = []
|
||||
}
|
||||
|
||||
function onRefreshFailed(error: unknown) {
|
||||
refreshFailSubscribers.forEach(cb => cb(error))
|
||||
refreshSubscribers = []
|
||||
refreshFailSubscribers = []
|
||||
}
|
||||
|
||||
// Response interceptor - handle token refresh
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
// If 401 and not already retrying, attempt token refresh
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true
|
||||
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, null, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${refreshToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
const { access_token, refresh_token } = response.data
|
||||
localStorage.setItem('access_token', access_token)
|
||||
localStorage.setItem('refresh_token', refresh_token)
|
||||
|
||||
// Retry original request with new token
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`
|
||||
}
|
||||
return apiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
// Refresh failed - clear tokens and redirect to login
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
}
|
||||
// Only handle 401s that haven't already been retried
|
||||
if (error.response?.status !== 401 || originalRequest._retry) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
originalRequest._retry = true
|
||||
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
if (!refreshToken) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// If a refresh is already in progress, queue this request
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
refreshSubscribers.push((newToken: string) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
}
|
||||
resolve(apiClient(originalRequest))
|
||||
})
|
||||
refreshFailSubscribers.push((refreshError: unknown) => {
|
||||
reject(refreshError)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// This is the first 401 — perform the refresh
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, null, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${refreshToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
const { access_token, refresh_token } = response.data
|
||||
localStorage.setItem('access_token', access_token)
|
||||
localStorage.setItem('refresh_token', refresh_token)
|
||||
|
||||
// Sync Zustand auth store
|
||||
useAuthStore.getState().setTokens({
|
||||
access_token,
|
||||
refresh_token,
|
||||
token_type: 'bearer',
|
||||
})
|
||||
|
||||
isRefreshing = false
|
||||
onRefreshed(access_token)
|
||||
|
||||
// Retry original request with new token
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`
|
||||
}
|
||||
return apiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
isRefreshing = false
|
||||
onRefreshFailed(refreshError)
|
||||
|
||||
// Refresh failed — clear tokens and redirect to login
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
useAuthStore.getState().logout()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user