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); declare(strict_types=1);
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request; 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; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
@@ -23,13 +28,63 @@ return Application::configure(basePath: dirname(__DIR__))
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->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) { $exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->is('api/*')) { if ($request->expectsJson() || $request->is('api/*')) {
return response()->json([ return response()->json([
'success' => false, 'message' => 'Resource not found.',
'message' => 'Resource not found',
], 404); ], 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(); })->create();

View File

@@ -4,27 +4,53 @@ import ScrollToTop from '@core/components/ScrollToTop.vue'
import initCore from '@core/initCore' import initCore from '@core/initCore'
import { initConfigStore, useConfigStore } from '@core/stores/config' import { initConfigStore, useConfigStore } from '@core/stores/config'
import { hexToRgb } from '@core/utils/colorConverter' 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() const { global } = useTheme()
// Sync current theme with initial loader theme
initCore() initCore()
initConfigStore() initConfigStore()
const configStore = useConfigStore() const configStore = useConfigStore()
const authStore = useAuthStore()
const notificationStore = useNotificationStore()
// Hydrate auth store on page load (token survives in localStorage, user data does not) // Validate stored token on app startup — must complete before rendering protected content
useMe() authStore.initialize()
</script> </script>
<template> <template>
<VLocaleProvider :rtl="configStore.isAppRTL"> <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)}`"> <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> </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> </VLocaleProvider>
</template> </template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { VForm } from 'vuetify/components/VForm' 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 { requiredValidator } from '@core/utils/validators'
import type { TimeSlot } from '@/types/section'
const props = defineProps<{ const props = defineProps<{
eventId: string eventId: string
timeSlot?: TimeSlot | null
duplicateFrom?: TimeSlot | null
}>() }>()
const modelValue = defineModel<boolean>({ required: true }) const modelValue = defineModel<boolean>({ required: true })
const eventIdRef = computed(() => props.eventId) const eventIdRef = computed(() => props.eventId)
const isEditing = computed(() => !!props.timeSlot)
const form = ref({ const form = ref({
name: '', name: '',
person_type: 'VOLUNTEER', person_type: 'VOLUNTEER',
@@ -23,7 +28,10 @@ const form = ref({
const errors = ref<Record<string, string>>({}) const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>() 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 = [ const personTypeOptions = [
{ title: 'Vrijwilliger', value: 'VOLUNTEER' }, { title: 'Vrijwilliger', value: 'VOLUNTEER' },
@@ -33,6 +41,41 @@ const personTypeOptions = [
{ title: 'Partner', value: 'PARTNER' }, { 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 // Auto-calculate duration from start/end time
watch( watch(
() => [form.value.start_time, form.value.end_time], () => [form.value.start_time, form.value.end_time],
@@ -60,36 +103,44 @@ function resetForm() {
refVForm.value?.resetValidation() 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() { function onSubmit() {
refVForm.value?.validate().then(({ valid }) => { refVForm.value?.validate().then(({ valid }) => {
if (!valid) return if (!valid) return
errors.value = {} errors.value = {}
createTimeSlot( const callbacks = {
{ onSuccess: () => {
name: form.value.name, modelValue.value = false
person_type: form.value.person_type, if (!isEditing.value) resetForm()
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 } : {}),
}, },
{ onError: (err: any) => {
onSuccess: () => { const data = err.response?.data
modelValue.value = false if (data?.errors) {
resetForm() 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> </script>
@@ -98,14 +149,14 @@ function onSubmit() {
<VDialog <VDialog
v-model="modelValue" v-model="modelValue"
max-width="550" max-width="550"
@after-leave="resetForm" @after-leave="(!isEditing) && resetForm()"
> >
<VCard title="Time Slot aanmaken"> <VCard :title="isEditing ? 'Time Slot bewerken' : 'Time Slot aanmaken'">
<VCardText> <VForm
<VForm ref="refVForm"
ref="refVForm" @submit.prevent="onSubmit"
@submit.prevent="onSubmit" >
> <VCardText>
<VRow> <VRow>
<VCol cols="12"> <VCol cols="12">
<AppTextField <AppTextField
@@ -115,6 +166,7 @@ function onSubmit() {
:rules="[requiredValidator]" :rules="[requiredValidator]"
:error-messages="errors.name" :error-messages="errors.name"
autofocus autofocus
autocomplete="one-time-code"
/> />
</VCol> </VCol>
<VCol cols="12"> <VCol cols="12">
@@ -170,24 +222,24 @@ function onSubmit() {
/> />
</VCol> </VCol>
</VRow> </VRow>
</VForm> </VCardText>
</VCardText> <VCardActions>
<VCardActions> <VSpacer />
<VSpacer /> <VBtn
<VBtn variant="text"
variant="text" @click="modelValue = false"
@click="modelValue = false" >
> Annuleren
Annuleren </VBtn>
</VBtn> <VBtn
<VBtn type="submit"
color="primary" color="primary"
:loading="isPending" :loading="isPending"
@click="onSubmit" >
> {{ isEditing ? 'Opslaan' : 'Aanmaken' }}
Aanmaken </VBtn>
</VBtn> </VCardActions>
</VCardActions> </VForm>
</VCard> </VCard>
</VDialog> </VDialog>
</template> </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 axios from 'axios'
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios' import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '@/stores/useAuthStore' import { useAuthStore } from '@/stores/useAuthStore'
import { useNotificationStore } from '@/stores/useNotificationStore'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
const apiClient: AxiosInstance = axios.create({ const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL, baseURL: import.meta.env.VITE_API_URL,
@@ -15,11 +17,16 @@ const apiClient: AxiosInstance = axios.create({
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { (config: InternalAxiosRequestConfig) => {
const authStore = useAuthStore() const authStore = useAuthStore()
const orgStore = useOrganisationStore()
if (authStore.token) { if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}` config.headers.Authorization = `Bearer ${authStore.token}`
} }
if (orgStore.activeOrganisationId) {
config.headers['X-Organisation-Id'] = orgStore.activeOrganisationId
}
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data) 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) 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() 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') { if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/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) 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 type { Router } from 'vue-router'
import { useAuthStore } from '@/stores/useAuthStore' import { useAuthStore } from '@/stores/useAuthStore'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
export function setupGuards(router: Router) { export function setupGuards(router: Router) {
router.beforeEach((to) => { router.beforeEach(async (to) => {
const authStore = useAuthStore() const authStore = useAuthStore()
const isPublic = to.meta.public === true
// Guest-only pages (login): redirect to home if already authenticated // Wait for initialization to complete (only blocks on first navigation)
if (isPublic && authStore.isAuthenticated) { if (!authStore.isInitialized) {
return { name: 'dashboard' } await authStore.initialize()
} }
// Protected pages: redirect to login if not authenticated const isPublic = to.meta.public === true
if (!isPublic && !authStore.isAuthenticated && to.meta.requiresAuth !== false) {
// 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 } } 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 { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { apiClient } from '@/lib/axios'
import { useOrganisationStore } from '@/stores/useOrganisationStore' import { useOrganisationStore } from '@/stores/useOrganisationStore'
import type { MeResponse, Organisation, User } from '@/types/auth' import type { MeResponse, Organisation, User } from '@/types/auth'
@@ -11,8 +12,10 @@ export const useAuthStore = defineStore('auth', () => {
const organisations = ref<Organisation[]>([]) const organisations = ref<Organisation[]>([])
const appRoles = ref<string[]>([]) const appRoles = ref<string[]>([])
const permissions = 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 isSuperAdmin = computed(() => appRoles.value?.includes('super_admin') ?? false)
const currentOrganisation = computed(() => { const currentOrganisation = computed(() => {
@@ -64,6 +67,40 @@ export const useAuthStore = defineStore('auth', () => {
orgStore.clear() 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 { return {
token, token,
user, user,
@@ -71,11 +108,13 @@ export const useAuthStore = defineStore('auth', () => {
appRoles, appRoles,
permissions, permissions,
isAuthenticated, isAuthenticated,
isInitialized,
isSuperAdmin, isSuperAdmin,
currentOrganisation, currentOrganisation,
setToken, setToken,
setUser, setUser,
setActiveOrganisation, setActiveOrganisation,
logout, 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 { defineStore } from 'pinia'
import { ref } from 'vue' import { computed, ref } from 'vue'
const ACTIVE_ORG_KEY = 'crewli_active_org' const ACTIVE_ORG_KEY = 'crewli_active_org'
const ACTIVE_EVENT_KEY = 'crewli_active_event' const ACTIVE_EVENT_KEY = 'crewli_active_event'
@@ -7,6 +7,7 @@ const ACTIVE_EVENT_KEY = 'crewli_active_event'
export const useOrganisationStore = defineStore('organisation', () => { export const useOrganisationStore = defineStore('organisation', () => {
const activeOrganisationId = ref<string | null>(localStorage.getItem(ACTIVE_ORG_KEY)) const activeOrganisationId = ref<string | null>(localStorage.getItem(ACTIVE_ORG_KEY))
const activeEventId = ref<string | null>(localStorage.getItem(ACTIVE_EVENT_KEY)) const activeEventId = ref<string | null>(localStorage.getItem(ACTIVE_EVENT_KEY))
const hasOrganisation = computed(() => !!activeOrganisationId.value)
function setActiveOrganisation(id: string) { function setActiveOrganisation(id: string) {
activeOrganisationId.value = id activeOrganisationId.value = id
@@ -28,6 +29,7 @@ export const useOrganisationStore = defineStore('organisation', () => {
return { return {
activeOrganisationId, activeOrganisationId,
activeEventId, activeEventId,
hasOrganisation,
setActiveOrganisation, setActiveOrganisation,
setActiveEvent, setActiveEvent,
clear, clear,