Files
InvoiceShelf/resources/scripts/features/auth/views/LoginView.vue
Darko Gjorgjijoski a01771ddf4 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.
2026-04-11 20:31:54 +02:00

148 lines
3.9 KiB
Vue

<template>
<form id="loginForm" class="mt-12 text-left" @submit.prevent="onSubmit">
<BaseInputGroup
:error="v$.email.$error && v$.email.$errors[0].$message"
:label="$t('login.email')"
class="mb-4"
required
>
<BaseInput
v-model="authStore.loginData.email"
:invalid="v$.email.$error"
focus
type="email"
name="email"
@input="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:error="v$.password.$error && v$.password.$errors[0].$message"
:label="$t('login.password')"
class="mb-4"
required
>
<BaseInput
v-model="authStore.loginData.password"
:invalid="v$.password.$error"
:type="inputType"
name="password"
@input="v$.password.$touch()"
>
<template #right>
<BaseIcon
:name="isShowPassword ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
<div class="mt-5 mb-8">
<div class="mb-4">
<router-link
to="forgot-password"
class="text-sm text-primary-400 hover:text-body"
>
{{ $t('login.forgot_password') }}
</router-link>
</div>
</div>
<BaseButton :loading="isLoading" type="submit" class="w-full justify-center">
{{ $t('login.login') }}
</BaseButton>
</form>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { required, email, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../../../stores/auth.store'
import { useNotificationStore } from '../../../stores/notification.store'
declare global {
interface Window {
demo_mode?: boolean
}
}
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)
const rules = {
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
password: {
required: helpers.withMessage(t('validation.required'), required),
},
}
const v$ = useVuelidate(
rules,
computed(() => authStore.loginData)
)
const inputType = computed<string>(() => {
return isShowPassword.value ? 'text' : 'password'
})
async function onSubmit(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isLoading.value = true
try {
await authStore.login(authStore.loginData)
// 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',
message: 'Logged in successfully.',
})
} catch (err: unknown) {
const { handleApiError } = await import('../../../utils/error-handling')
const normalized = handleApiError(err)
notificationStore.showNotification({
type: 'error',
message: normalized.message,
})
isLoading.value = false
}
}
onMounted(() => {
if (window.demo_mode) {
authStore.loginData.email = 'demo@invoiceshelf.com'
authStore.loginData.password = 'demo'
}
})
</script>