mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-18 06:45:20 +00:00
fix(auth): redirect to login on 401 instead of hanging on bootstrap
When the Sanctum session/token expires, /api/v1/bootstrap returns 401 Unauthenticated, CompanyLayout's initializeLayout() throws, and isAppLoaded stays false — leaving the user on a spinning loader with no way out but a hard refresh to /login. Adds a response interceptor to the main axios client that catches any 401, clears stale auth state (auth.token, selectedCompany, isAdminMode), and navigates to /login?next=<original-path> so the user lands back where they were after re-auth. Exempts /login, /logout, /sanctum/csrf- cookie (where a 401 is a legitimate form/flow signal, not a session expiry), and guards against redirect loops via a module-level flag that collapses concurrent 401s into a single navigation. Also bails out on the login route itself, on /installation, and on customer- portal routes (which already have their own handling in the router guard). LoginView reads the ?next query param on successful login (sanitized to same-origin paths only, rejecting protocol-relative and absolute URLs so a crafted link can never open-redirect) and redirects there, falling back to /admin/dashboard. The router is imported dynamically inside the interceptor to break the client → router → guards → stores → client circular that a top-level import would create. Vite bundles the dynamic import into the main chunk, so it's free at runtime.
This commit is contained in:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user