import axios from 'axios' import qs from 'qs' import { useAuthStore } from 'stores/authStore' const rawBaseUrl = (typeof process !== 'undefined' && process.env?.VITE_API_BASE_URL) || '/api' export const API_BASE_URL = String(rawBaseUrl).trim().replace(/\/+$/, '') const AUTH_REFRESH_PATH = '/auth/refresh' const api = axios.create({ baseURL: API_BASE_URL, timeout: 180000, paramsSerializer: params => qs.stringify(params, { arrayFormat: 'repeat' }), withCredentials: true }) function isPublicPath(url) { return ( url.startsWith('/auth/login') || url.startsWith(AUTH_REFRESH_PATH) || url.startsWith('/password/forgot') || url.startsWith('/password/reset') ) } function extractToken(payload) { return ( payload?.token || payload?.access_token || payload?.data?.token || payload?.data?.access_token || '' ) } function isHtmlLike(value) { const text = String(value || '').trim().toLowerCase() return text.startsWith('= 500) { return `Upstream server error (${status}).` } return `Unexpected HTML error response (${status || '-'})` } const compact = normalized.replace(/\s+/g, ' ').trim() if (compact.length > 320) { return `${compact.slice(0, 320)}...` } return compact } function redirectToLogin() { if (typeof window === 'undefined') return if (window.location.hash === '#/login') return window.location.hash = '/login' } api.interceptors.request.use((config) => { const auth = useAuthStore() const url = config.url || '' if (!isPublicPath(url) && auth?.token) { config.headers ||= {} config.headers.Authorization = `Bearer ${auth.token}` } return config }) let isLoggingOut = false let refreshPromise = null async function refreshAccessToken() { if (refreshPromise) { return refreshPromise } refreshPromise = (async () => { const auth = useAuthStore() const response = await api.post( AUTH_REFRESH_PATH, {}, { skipAuthRefresh: true, skipAutoLogout: true } ) const rawToken = extractToken(response?.data) const token = typeof rawToken === 'string' ? rawToken.trim() : '' const isJwt = token.split('.').length === 3 if (!token || !isJwt) { throw new Error('Invalid refresh token response') } auth.setSession({ token, user: auth.user || null }) return token })().finally(() => { refreshPromise = null }) return refreshPromise } function clearSessionAndRedirect() { if (isLoggingOut) return isLoggingOut = true try { useAuthStore().clearSession() } finally { isLoggingOut = false redirectToLogin() } } api.interceptors.response.use( r => r, async (error) => { const requestConfig = error?.config || {} const status = error?.response?.status const requestUrl = String(requestConfig.url || '') const hasBlob = typeof Blob !== 'undefined' && error?.response?.data instanceof Blob const isPasswordChangeRequest = requestUrl.startsWith('/password/change') || requestUrl.startsWith('/me/password') const isPublicRequest = isPublicPath(requestUrl) if ((status >= 500 || hasBlob) && error) { const method = String(requestConfig.method || 'GET').toUpperCase() const detail = sanitizeApiErrorDetail( await extractApiErrorDetail(error), status ) error.parsedMessage = detail console.error(`API ${status || '-'} ${method} ${requestUrl}: ${detail}`) } const shouldTryRefresh = status === 401 && !requestConfig._retry && !requestConfig.skipAuthRefresh && !isPasswordChangeRequest && !isPublicRequest if (shouldTryRefresh) { requestConfig._retry = true try { const newToken = await refreshAccessToken() requestConfig.headers ||= {} requestConfig.headers.Authorization = `Bearer ${newToken}` return api(requestConfig) } catch { // fall through to logout } } // Password change endpoints may return 401 for business reasons // (for example current password mismatch). Keep session in that case. if ( status === 401 && !isPasswordChangeRequest && !requestConfig.skipAutoLogout ) { clearSessionAndRedirect() } return Promise.reject(error) } ) export const get = (u, p = {}, c = {}) => api.get(u, { params: p, ...c }).then(r => r.data) export const post = (u, b = {}, c = {}) => api.post(u, b, c).then(r => r.data) export const put = (u, b = {}, c = {}) => api.put(u, b, c).then(r => r.data) export const del = (u, p = {}, c = {}) => api.delete(u, { params: p, ...c }).then(r => r.data) async function parseBlobErrorMessage(data) { if (!data) return '' if (typeof Blob !== 'undefined' && data instanceof Blob) { try { const text = (await data.text())?.trim() if (!text) return '' try { const json = JSON.parse(text) return ( json?.detail || json?.message || json?.error || text ) } catch { return text } } catch { return '' } } if (typeof data === 'string') return data.trim() if (typeof data === 'object') { return ( data?.detail || data?.message || data?.error || '' ) } return '' } export async function extractApiErrorDetail(err) { const status = err?.response?.status let detail = err?.parsedMessage || err?.response?.data?.detail || err?.response?.data?.message || err?.response?.data?.error || '' if (!detail) { detail = await parseBlobErrorMessage(err?.response?.data) } if (!detail) { detail = err?.message || 'Request failed' } return sanitizeApiErrorDetail(detail, status) } export const download = async (u, p = {}, c = {}) => { try { const r = await api.get(u, { params: p, responseType: 'blob', ...c }) return r.data } catch (err) { const detail = await extractApiErrorDetail(err) const wrapped = new Error(detail) wrapped.status = err?.response?.status wrapped.original = err throw wrapped } } export default api