From a01771ddf4ac371ef29e8aaa9329cc450b087859 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Sat, 11 Apr 2026 20:31:54 +0200 Subject: [PATCH] fix(auth): redirect to login on 401 instead of hanging on bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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. --- resources/scripts/api/client.ts | 98 ++++++++++++++++++- .../scripts/features/auth/views/LoginView.vue | 16 ++- 2 files changed, 111 insertions(+), 3 deletions(-) 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 @@