fix: auth race condition on refresh, section edit dialog, time slot duplicate, autocomplete disable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 11:16:22 +02:00
parent 03545c570c
commit 37fecf7181
15 changed files with 733 additions and 168 deletions

View File

@@ -2,10 +2,15 @@
declare(strict_types=1);
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
return Application::configure(basePath: dirname(__DIR__))
@@ -23,13 +28,63 @@ return Application::configure(basePath: dirname(__DIR__))
]);
})
->withExceptions(function (Exceptions $exceptions): void {
// Return JSON for all API exceptions
// Database connection / query errors → 503
$exceptions->render(function (QueryException|PDOException $e, Request $request) {
if ($request->expectsJson() || $request->is('api/*')) {
Log::error('Database error', [
'exception' => get_class($e),
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$response = ['message' => 'Service temporarily unavailable. Please try again later.'];
if (config('app.debug')) {
$response['debug'] = [
'exception' => get_class($e),
'message' => $e->getMessage(),
];
}
return response()->json($response, 503);
}
});
// 404 Not Found → friendly message
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->is('api/*')) {
if ($request->expectsJson() || $request->is('api/*')) {
return response()->json([
'success' => false,
'message' => 'Resource not found',
'message' => 'Resource not found.',
], 404);
}
});
// All other unhandled exceptions → 500
// (ValidationException, AuthenticationException, and HttpException are handled by Laravel)
$exceptions->render(function (Throwable $e, Request $request) {
if ($request->expectsJson() || $request->is('api/*')) {
if ($e instanceof ValidationException
|| $e instanceof AuthenticationException
|| $e instanceof HttpException) {
return null; // Let Laravel handle these normally
}
Log::error('Unhandled exception', [
'exception' => get_class($e),
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$response = ['message' => 'An unexpected error occurred.'];
if (config('app.debug')) {
$response['debug'] = [
'exception' => get_class($e),
'message' => $e->getMessage(),
];
}
return response()->json($response, 500);
}
});
})->create();

View File

@@ -4,27 +4,53 @@ import ScrollToTop from '@core/components/ScrollToTop.vue'
import initCore from '@core/initCore'
import { initConfigStore, useConfigStore } from '@core/stores/config'
import { hexToRgb } from '@core/utils/colorConverter'
import { useMe } from '@/composables/api/useAuth'
import { useAuthStore } from '@/stores/useAuthStore'
import { useNotificationStore } from '@/stores/useNotificationStore'
const { global } = useTheme()
// Sync current theme with initial loader theme
initCore()
initConfigStore()
const configStore = useConfigStore()
const authStore = useAuthStore()
const notificationStore = useNotificationStore()
// Hydrate auth store on page load (token survives in localStorage, user data does not)
useMe()
// Validate stored token on app startup — must complete before rendering protected content
authStore.initialize()
</script>
<template>
<VLocaleProvider :rtl="configStore.isAppRTL">
<!-- This is required to set the background color of active nav link based on currently active global theme's primary -->
<VApp :style="`--v-global-theme-primary: ${hexToRgb(global.current.value.colors.primary)}`">
<RouterView />
<!-- Show loading state while validating auth token -->
<template v-if="!authStore.isInitialized">
<div class="d-flex align-center justify-center" style="min-height: 100vh;">
<VProgressCircular indeterminate color="primary" size="48" />
</div>
</template>
<ScrollToTop />
<!-- Only render app shell after auth is resolved -->
<template v-else>
<RouterView />
<ScrollToTop />
</template>
</VApp>
<!-- Global notification snackbar -->
<VSnackbar
v-model="notificationStore.visible"
:color="notificationStore.type"
:timeout="notificationStore.timeout"
location="top end"
>
{{ notificationStore.message }}
<template #actions>
<VBtn variant="text" @click="notificationStore.hide()">
Close
</VBtn>
</template>
</VSnackbar>
</VLocaleProvider>
</template>

View File

@@ -72,22 +72,22 @@ function onSubmit() {
max-width="500"
>
<VCard title="Rol wijzigen">
<VCardText>
<VRow class="mb-4">
<VCol cols="12">
<div class="text-body-1">
<strong>{{ member.name }}</strong>
</div>
<div class="text-body-2 text-disabled">
{{ member.email }}
</div>
</VCol>
</VRow>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VCardText>
<VRow class="mb-4">
<VCol cols="12">
<div class="text-body-1">
<strong>{{ member.name }}</strong>
</div>
<div class="text-body-2 text-disabled">
{{ member.email }}
</div>
</VCol>
</VRow>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VSelect
v-model="role"
label="Rol"
@@ -95,40 +95,40 @@ function onSubmit() {
:rules="[requiredValidator]"
:error-messages="errors.role"
/>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VTooltip
v-if="isSelf"
text="Je kunt je eigen rol niet wijzigen"
>
<template #activator="{ props: tooltipProps }">
<div v-bind="tooltipProps">
<VBtn
color="primary"
disabled
>
Opslaan
</VBtn>
</div>
</template>
</VTooltip>
<VBtn
v-else
color="primary"
:loading="isPending"
@click="onSubmit"
>
Opslaan
</VBtn>
</VCardActions>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VTooltip
v-if="isSelf"
text="Je kunt je eigen rol niet wijzigen"
>
<template #activator="{ props: tooltipProps }">
<div v-bind="tooltipProps">
<VBtn
color="primary"
disabled
>
Opslaan
</VBtn>
</div>
</template>
</VTooltip>
<VBtn
v-else
type="submit"
color="primary"
:loading="isPending"
>
Opslaan
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>

View File

@@ -76,11 +76,11 @@ function onSubmit() {
@after-leave="resetForm"
>
<VCard title="Lid uitnodigen">
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VCardText>
<VRow>
<VCol cols="12">
<AppTextField
@@ -102,24 +102,24 @@ function onSubmit() {
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Uitnodigen
</VBtn>
</VCardActions>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isPending"
>
Uitnodigen
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>

View File

@@ -55,36 +55,37 @@ function onSubmit() {
max-width="450"
>
<VCard title="Naam bewerken">
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VCardText>
<AppTextField
v-model="name"
label="Organisatienaam"
:rules="[requiredValidator]"
:error-messages="errors.name"
autofocus
autocomplete="one-time-code"
/>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Opslaan
</VBtn>
</VCardActions>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isPending"
>
Opslaan
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>

View File

@@ -116,6 +116,7 @@ function onSubmit() {
:error-messages="errors.person_id"
clearable
no-data-text="Geen goedgekeurde personen gevonden"
autocomplete="one-time-code"
>
<template #item="{ props: itemProps, item }">
<VListItem

View File

@@ -1,16 +1,21 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useCreateTimeSlot } from '@/composables/api/useTimeSlots'
import { useCreateTimeSlot, useUpdateTimeSlot } from '@/composables/api/useTimeSlots'
import { requiredValidator } from '@core/utils/validators'
import type { TimeSlot } from '@/types/section'
const props = defineProps<{
eventId: string
timeSlot?: TimeSlot | null
duplicateFrom?: TimeSlot | null
}>()
const modelValue = defineModel<boolean>({ required: true })
const eventIdRef = computed(() => props.eventId)
const isEditing = computed(() => !!props.timeSlot)
const form = ref({
name: '',
person_type: 'VOLUNTEER',
@@ -23,7 +28,10 @@ const form = ref({
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const { mutate: createTimeSlot, isPending } = useCreateTimeSlot(eventIdRef)
const { mutate: createTimeSlot, isPending: isCreating } = useCreateTimeSlot(eventIdRef)
const { mutate: updateTimeSlot, isPending: isUpdating } = useUpdateTimeSlot(eventIdRef)
const isPending = computed(() => isCreating.value || isUpdating.value)
const personTypeOptions = [
{ title: 'Vrijwilliger', value: 'VOLUNTEER' },
@@ -33,6 +41,41 @@ const personTypeOptions = [
{ title: 'Partner', value: 'PARTNER' },
]
// Populate form when editing
watch(
() => props.timeSlot,
(ts) => {
if (ts) {
form.value = {
name: ts.name,
person_type: ts.person_type,
date: ts.date,
start_time: ts.start_time,
end_time: ts.end_time,
duration_hours: ts.duration_hours,
}
}
},
{ immediate: true },
)
// Populate form when duplicating
watch(
() => props.duplicateFrom,
(ts) => {
if (ts) {
form.value = {
name: `${ts.name} (kopie)`,
person_type: ts.person_type,
date: ts.date,
start_time: ts.start_time,
end_time: ts.end_time,
duration_hours: ts.duration_hours,
}
}
},
)
// Auto-calculate duration from start/end time
watch(
() => [form.value.start_time, form.value.end_time],
@@ -60,36 +103,44 @@ function resetForm() {
refVForm.value?.resetValidation()
}
function buildPayload() {
return {
name: form.value.name,
person_type: form.value.person_type,
date: form.value.date,
start_time: form.value.start_time,
end_time: form.value.end_time,
...(form.value.duration_hours != null ? { duration_hours: form.value.duration_hours } : {}),
}
}
function onSubmit() {
refVForm.value?.validate().then(({ valid }) => {
if (!valid) return
errors.value = {}
createTimeSlot(
{
name: form.value.name,
person_type: form.value.person_type,
date: form.value.date,
start_time: form.value.start_time,
end_time: form.value.end_time,
...(form.value.duration_hours != null ? { duration_hours: form.value.duration_hours } : {}),
const callbacks = {
onSuccess: () => {
modelValue.value = false
if (!isEditing.value) resetForm()
},
{
onSuccess: () => {
modelValue.value = false
resetForm()
},
onError: (err: any) => {
const data = err.response?.data
if (data?.errors) {
errors.value = Object.fromEntries(
Object.entries(data.errors).map(([k, v]) => [k, (v as string[])[0]]),
)
}
},
onError: (err: any) => {
const data = err.response?.data
if (data?.errors) {
errors.value = Object.fromEntries(
Object.entries(data.errors).map(([k, v]) => [k, (v as string[])[0]]),
)
}
},
)
}
if (isEditing.value && props.timeSlot) {
updateTimeSlot({ id: props.timeSlot.id, ...buildPayload() }, callbacks)
}
else {
createTimeSlot(buildPayload(), callbacks)
}
})
}
</script>
@@ -98,14 +149,14 @@ function onSubmit() {
<VDialog
v-model="modelValue"
max-width="550"
@after-leave="resetForm"
@after-leave="(!isEditing) && resetForm()"
>
<VCard title="Time Slot aanmaken">
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VCard :title="isEditing ? 'Time Slot bewerken' : 'Time Slot aanmaken'">
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VCardText>
<VRow>
<VCol cols="12">
<AppTextField
@@ -115,6 +166,7 @@ function onSubmit() {
:rules="[requiredValidator]"
:error-messages="errors.name"
autofocus
autocomplete="one-time-code"
/>
</VCol>
<VCol cols="12">
@@ -170,24 +222,24 @@ function onSubmit() {
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Aanmaken
</VBtn>
</VCardActions>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isPending"
>
{{ isEditing ? 'Opslaan' : 'Aanmaken' }}
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,205 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useUpdateSection, useSectionCategories } from '@/composables/api/useSections'
import { useAuthStore } from '@/stores/useAuthStore'
import { requiredValidator } from '@core/utils/validators'
import type { FestivalSection, SectionType } from '@/types/section'
const props = defineProps<{
eventId: string
section: FestivalSection | null
}>()
const emit = defineEmits<{
updated: []
}>()
const modelValue = defineModel<boolean>({ required: true })
const eventIdRef = computed(() => props.eventId)
const authStore = useAuthStore()
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
const { data: categorySuggestions } = useSectionCategories(orgId)
const form = ref({
name: '',
category: null as string | null,
icon: null as string | null,
type: 'standard' as SectionType,
crew_auto_accepts: false,
responder_self_checkin: true,
})
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const { mutate: updateSection, isPending } = useUpdateSection(eventIdRef)
const typeOptions = [
{ title: 'Standaard', value: 'standard' },
{ title: 'Overkoepelend', value: 'cross_event' },
]
// Pre-fill form when section changes or dialog opens
watch(
() => props.section,
(section) => {
if (section) {
form.value = {
name: section.name,
category: section.category,
icon: section.icon,
type: section.type,
crew_auto_accepts: section.crew_auto_accepts,
responder_self_checkin: section.responder_self_checkin,
}
}
},
{ immediate: true },
)
function resetForm() {
errors.value = {}
refVForm.value?.resetValidation()
}
function onSubmit() {
if (!props.section) return
refVForm.value?.validate().then(({ valid }) => {
if (!valid) return
errors.value = {}
updateSection(
{
id: props.section!.id,
name: form.value.name,
category: form.value.category || null,
icon: form.value.icon || null,
crew_auto_accepts: form.value.crew_auto_accepts,
responder_self_checkin: form.value.responder_self_checkin,
},
{
onSuccess: () => {
modelValue.value = false
emit('updated')
resetForm()
},
onError: (err: any) => {
const data = err.response?.data
if (data?.errors) {
errors.value = Object.fromEntries(
Object.entries(data.errors).map(([k, v]) => [k, (v as string[])[0]]),
)
}
else if (data?.message) {
errors.value = { name: data.message }
}
},
},
)
})
}
</script>
<template>
<VDialog
v-model="modelValue"
max-width="500"
@after-leave="resetForm"
>
<VCard title="Sectie bewerken">
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VCardText>
<VRow>
<VCol cols="12">
<AppTextField
v-model="form.name"
label="Naam"
:rules="[requiredValidator]"
:error-messages="errors.name"
autofocus
autocomplete="one-time-code"
/>
</VCol>
<VCol cols="12">
<VCombobox
v-model="form.category"
label="Categorie"
:items="categorySuggestions ?? []"
placeholder="Bijv. Bar, Podium, Operationeel..."
clearable
:error-messages="errors.category"
autocomplete="one-time-code"
/>
</VCol>
<VCol cols="12">
<div class="d-flex align-center gap-x-2">
<AppTextField
v-model="form.icon"
label="Icoon"
placeholder="tabler-beer"
clearable
:error-messages="errors.icon"
class="flex-grow-1"
autocomplete="one-time-code"
/>
<VIcon
v-if="form.icon"
:icon="form.icon"
size="24"
/>
</div>
</VCol>
<VCol cols="12">
<AppSelect
v-model="form.type"
label="Type"
:items="typeOptions"
disabled
hint="Type kan niet worden gewijzigd na aanmaken"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="form.crew_auto_accepts"
label="Crew auto-accepteren"
hint="Toewijzingen worden automatisch goedgekeurd"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="form.responder_self_checkin"
label="Zelfstandig inchecken"
hint="Vrijwilligers kunnen zelf inchecken via QR"
persistent-hint
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isPending"
>
Opslaan
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>
</template>

View File

@@ -1,6 +1,8 @@
import axios from 'axios'
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '@/stores/useAuthStore'
import { useNotificationStore } from '@/stores/useNotificationStore'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
@@ -15,11 +17,16 @@ const apiClient: AxiosInstance = axios.create({
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const authStore = useAuthStore()
const orgStore = useOrganisationStore()
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
}
if (orgStore.activeOrganisationId) {
config.headers['X-Organisation-Id'] = orgStore.activeOrganisationId
}
if (import.meta.env.DEV) {
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
}
@@ -42,15 +49,40 @@ apiClient.interceptors.response.use(
console.error(`${error.response?.status} ${error.config?.url}`, error.response?.data)
}
if (error.response?.status === 401) {
const status = error.response?.status
const notificationStore = useNotificationStore()
if (status === 401) {
const authStore = useAuthStore()
authStore.logout()
// During initialization, let initialize() handle the 401 — skip redirect
if (authStore.isInitialized) {
authStore.logout()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
}
}
else if (status === 403) {
notificationStore.show('You don\'t have permission for this action.', 'error')
}
else if (status === 404) {
notificationStore.show('The requested item was not found.', 'warning')
}
else if (status === 422) {
// Validation errors — pass through to calling component
}
else if (status === 503) {
notificationStore.show('Service temporarily unavailable. Please try again later.', 'error')
}
else if (status && status >= 500) {
notificationStore.show('An unexpected error occurred. Please try again later.', 'error')
}
else if (!error.response) {
// Network error — no response received
notificationStore.show('Unable to connect to the server. Check your internet connection.', 'error')
}
return Promise.reject(error)
},

View File

@@ -0,0 +1,15 @@
/**
* Basic Dutch pluralisation for configurable event labels.
* Covers the known sub_event_label values: Dag, Programmaonderdeel, Editie, Locatie, Ronde.
*/
export function dutchPlural(word: string): string {
// Words ending in -ie: add -s (editie → edities, locatie → locaties)
if (word.endsWith('ie')) return `${word}s`
// Words ending in -e: add -s (ronde → rondes)
if (word.endsWith('e')) return `${word}s`
// Double vowel before final consonant(s): single vowel + en (onderdeel → onderdelen)
const match = word.match(/^(.*)([aeiou])\2([^aeiou]+)$/i)
if (match) return `${match[1]}${match[2]}${match[3]}en`
// Default: add -en (dag → dagen)
return `${word}en`
}

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { useAuthStore } from '@/stores/useAuthStore'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
definePage({
meta: {
layout: 'blank',
},
})
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const orgStore = useOrganisationStore()
function selectOrganisation(orgId: string) {
orgStore.setActiveOrganisation(orgId)
const redirectTo = route.query.to ? String(route.query.to) : '/dashboard'
router.replace(redirectTo)
}
// Auto-select if user has exactly one organisation
if (authStore.organisations.length === 1) {
selectOrganisation(authStore.organisations[0].id)
}
</script>
<template>
<div class="auth-wrapper bg-surface d-flex align-center justify-center" style="min-height: 100vh;">
<VCard
:max-width="500"
class="pa-6"
width="100%"
>
<VCardText class="text-center">
<VNodeRenderer :nodes="themeConfig.app.logo" />
<h4 class="text-h4 mt-4 mb-1">
Kies je organisatie
</h4>
<p class="text-body-1 mb-0">
Selecteer de organisatie waarmee je wilt werken.
</p>
</VCardText>
<VCardText v-if="authStore.organisations.length === 0">
<VAlert
type="info"
variant="tonal"
>
Je bent nog niet gekoppeld aan een organisatie. Neem contact op met je beheerder.
</VAlert>
</VCardText>
<VCardText v-else>
<VList>
<VListItem
v-for="org in authStore.organisations"
:key="org.id"
:title="org.name"
:subtitle="org.role"
rounded
class="mb-2"
@click="selectOrganisation(org.id)"
>
<template #prepend>
<VAvatar
color="primary"
variant="tonal"
size="40"
>
<span class="text-h6">{{ org.name.charAt(0).toUpperCase() }}</span>
</VAvatar>
</template>
<template #append>
<VIcon icon="tabler-chevron-right" />
</template>
</VListItem>
</VList>
</VCardText>
</VCard>
</div>
</template>

View File

@@ -1,19 +1,48 @@
import type { Router } from 'vue-router'
import { useAuthStore } from '@/stores/useAuthStore'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
export function setupGuards(router: Router) {
router.beforeEach((to) => {
router.beforeEach(async (to) => {
const authStore = useAuthStore()
const isPublic = to.meta.public === true
// Guest-only pages (login): redirect to home if already authenticated
if (isPublic && authStore.isAuthenticated) {
return { name: 'dashboard' }
// Wait for initialization to complete (only blocks on first navigation)
if (!authStore.isInitialized) {
await authStore.initialize()
}
// Protected pages: redirect to login if not authenticated
if (!isPublic && !authStore.isAuthenticated && to.meta.requiresAuth !== false) {
const isPublic = to.meta.public === true
// Allow public routes (login, 404) — but redirect authenticated users away from login
if (isPublic) {
if (authStore.isAuthenticated && to.path === '/login') {
return { name: 'dashboard' }
}
return
}
// Routes that opt out of auth (e.g. invitations)
if (to.meta.requiresAuth === false) {
return
}
// Not authenticated → redirect to login with return URL
if (!authStore.isAuthenticated) {
return { path: '/login', query: { to: to.fullPath } }
}
// Authenticated — check organisation selection for routes that need it
const orgStore = useOrganisationStore()
const isSelectOrgPage = to.path === '/select-organisation'
if (isSelectOrgPage) {
// Already on the org selection page — allow
return
}
// If user has organisations but none selected → redirect to selection
if (authStore.organisations.length > 0 && !orgStore.hasOrganisation) {
return { path: '/select-organisation' }
}
})
}

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { apiClient } from '@/lib/axios'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
import type { MeResponse, Organisation, User } from '@/types/auth'
@@ -11,8 +12,10 @@ export const useAuthStore = defineStore('auth', () => {
const organisations = ref<Organisation[]>([])
const appRoles = ref<string[]>([])
const permissions = ref<string[]>([])
const isInitialized = ref(false)
const isAuthenticated = computed(() => !!token.value)
// Requires both a token AND a validated user — token alone is not enough
const isAuthenticated = computed(() => !!token.value && !!user.value)
const isSuperAdmin = computed(() => appRoles.value?.includes('super_admin') ?? false)
const currentOrganisation = computed(() => {
@@ -64,6 +67,40 @@ export const useAuthStore = defineStore('auth', () => {
orgStore.clear()
}
/**
* Called once on app startup. If a token exists in localStorage,
* validates it by calling GET /auth/me. On 401, clears everything.
* Safe to call multiple times — subsequent calls return the same promise.
*/
let initializePromise: Promise<void> | null = null
function initialize(): Promise<void> {
if (isInitialized.value) return Promise.resolve()
if (!initializePromise) {
initializePromise = doInitialize()
}
return initializePromise
}
async function doInitialize(): Promise<void> {
if (!token.value) {
isInitialized.value = true
return
}
try {
const { data } = await apiClient.get<{ success: boolean; data: MeResponse }>('/auth/me')
setUser(data.data)
}
catch {
// Token invalid/expired — clear everything
logout()
}
finally {
isInitialized.value = true
}
}
return {
token,
user,
@@ -71,11 +108,13 @@ export const useAuthStore = defineStore('auth', () => {
appRoles,
permissions,
isAuthenticated,
isInitialized,
isSuperAdmin,
currentOrganisation,
setToken,
setUser,
setActiveOrganisation,
logout,
initialize,
}
})

View File

@@ -0,0 +1,24 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export type NotificationType = 'success' | 'error' | 'warning' | 'info'
export const useNotificationStore = defineStore('notification', () => {
const visible = ref(false)
const message = ref('')
const type = ref<NotificationType>('info')
const timeout = ref(5000)
function show(msg: string, color: NotificationType = 'error', duration = 5000) {
message.value = msg
type.value = color
timeout.value = duration
visible.value = true
}
function hide() {
visible.value = false
}
return { visible, message, type, timeout, show, hide }
})

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { computed, ref } from 'vue'
const ACTIVE_ORG_KEY = 'crewli_active_org'
const ACTIVE_EVENT_KEY = 'crewli_active_event'
@@ -7,6 +7,7 @@ const ACTIVE_EVENT_KEY = 'crewli_active_event'
export const useOrganisationStore = defineStore('organisation', () => {
const activeOrganisationId = ref<string | null>(localStorage.getItem(ACTIVE_ORG_KEY))
const activeEventId = ref<string | null>(localStorage.getItem(ACTIVE_EVENT_KEY))
const hasOrganisation = computed(() => !!activeOrganisationId.value)
function setActiveOrganisation(id: string) {
activeOrganisationId.value = id
@@ -28,6 +29,7 @@ export const useOrganisationStore = defineStore('organisation', () => {
return {
activeOrganisationId,
activeEventId,
hasOrganisation,
setActiveOrganisation,
setActiveEvent,
clear,