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:
Darko Gjorgjijoski
2026-04-11 20:31:54 +02:00
parent 6aca53786b
commit a01771ddf4
2 changed files with 111 additions and 3 deletions

View File

@@ -58,7 +58,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { required, email, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
@@ -75,6 +75,7 @@ const notificationStore = useNotificationStore()
const authStore = useAuthStore()
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const isLoading = ref<boolean>(false)
const isShowPassword = ref<boolean>(false)
@@ -109,7 +110,18 @@ async function onSubmit(): Promise<void> {
try {
await authStore.login(authStore.loginData)
router.push('/admin/dashboard')
// Honour a ?next= query param if present (set by the 401 response
// interceptor so users land back on the page they were trying to
// reach). Sanitize to same-origin paths only — reject protocol-
// relative (`//evil.com`) and absolute URLs so a crafted link
// can never open-redirect.
const nextRaw = typeof route.query.next === 'string' ? route.query.next : ''
const safeNext =
nextRaw.startsWith('/') && !nextRaw.startsWith('//')
? nextRaw
: '/admin/dashboard'
router.push(safeNext)
notificationStore.showNotification({
type: 'success',