diff --git a/resources/scripts/api/client.ts b/resources/scripts/api/client.ts index 8fbfdb73..01e23b50 100644 --- a/resources/scripts/api/client.ts +++ b/resources/scripts/api/client.ts @@ -1,4 +1,11 @@ -import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios' +import axios, { + type AxiosError, + type AxiosInstance, + type AxiosResponse, + type InternalAxiosRequestConfig, +} from 'axios' +import { API } from './endpoints' +import * as localStore from '@/scripts/utils/local-storage' const client: AxiosInstance = axios.create({ withCredentials: true, @@ -25,4 +32,93 @@ client.interceptors.request.use((config: InternalAxiosRequestConfig) => { return config }) +// Collapses concurrent 401s into a single redirect. While a redirect +// to /login is in flight, subsequent 401s re-reject without any +// side-effects, avoiding the N parallel `router.push` thrash that +// parallel API fan-outs would otherwise produce. +let isRedirectingToLogin = false + +// URLs exempt from the 401 → /login redirect. A 401 on these endpoints +// is a legit form/flow signal (bad credentials, already-stale CSRF), +// not a session expiry — consumers handle it via their own .catch(). +const AUTH_EXEMPT_URLS: readonly string[] = [ + API.LOGIN, + API.LOGOUT, + API.CSRF_COOKIE, +] + +function isAuthExemptRequest(url: string | undefined): boolean { + if (!url) { + return false + } + return AUTH_EXEMPT_URLS.some((exempt) => url.endsWith(exempt)) +} + +client.interceptors.response.use( + (response: AxiosResponse) => response, + async (error: AxiosError) => { + const status = error.response?.status + + if (status !== 401) { + return Promise.reject(error) + } + + if (isAuthExemptRequest(error.config?.url)) { + return Promise.reject(error) + } + + if (isRedirectingToLogin) { + return Promise.reject(error) + } + + // Dynamic import to break the client → router → stores → client + // circular dependency. Vite bundles this into the main chunk, so + // it's effectively free at runtime. + const { default: router } = await import('@/scripts/router') + + const currentRoute = router.currentRoute.value + + // Login form handles its own errors — don't self-redirect. + if (currentRoute.name === 'login') { + return Promise.reject(error) + } + + // Installer uses a separate axios client, but belt-and-suspenders + // in case any install-flow code path ends up on this one. + if (typeof currentRoute.path === 'string' && currentRoute.path.startsWith('/installation')) { + return Promise.reject(error) + } + + // Customer portal has its own 401 handling in the router guard + // (see router/guards.ts:handleCustomerPortalRoute). + if (currentRoute.meta?.isCustomerPortal === true) { + return Promise.reject(error) + } + + isRedirectingToLogin = true + + // Clear stale auth state. Keep other keys (language, UI prefs). + localStore.remove('auth.token') + localStore.remove('selectedCompany') + localStore.remove('isAdminMode') + + // Remember where the user was trying to go, so LoginView can + // return them there after re-auth. Same-origin path only. + const nextPath = window.location.pathname + window.location.search + + try { + await router.push({ name: 'login', query: { next: nextPath } }) + } finally { + // Reset the guard on the next microtask so any still-pending + // 401s from the same tick are swallowed but subsequent sessions + // can redirect again. + setTimeout(() => { + isRedirectingToLogin = false + }, 0) + } + + return Promise.reject(error) + }, +) + export { client } diff --git a/resources/scripts/features/auth/views/LoginView.vue b/resources/scripts/features/auth/views/LoginView.vue index e5b21704..0319c584 100644 --- a/resources/scripts/features/auth/views/LoginView.vue +++ b/resources/scripts/features/auth/views/LoginView.vue @@ -58,7 +58,7 @@