280 lines
6.5 KiB
JavaScript
280 lines
6.5 KiB
JavaScript
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('<!doctype html') || text.startsWith('<html')
|
|
}
|
|
|
|
function sanitizeApiErrorDetail(detail, status) {
|
|
const normalized = String(detail || '').trim()
|
|
|
|
if (!normalized) {
|
|
if (status === 504) {
|
|
return 'Gateway timeout: origin server did not respond in time.'
|
|
}
|
|
return ''
|
|
}
|
|
|
|
if (isHtmlLike(normalized)) {
|
|
if (status === 504) {
|
|
return 'Gateway timeout: origin server did not respond in time.'
|
|
}
|
|
if (status >= 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
|