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:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
205
apps/app/src/components/sections/EditSectionDialog.vue
Normal file
205
apps/app/src/components/sections/EditSectionDialog.vue
Normal 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>
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
15
apps/app/src/lib/dutch-plural.ts
Normal file
15
apps/app/src/lib/dutch-plural.ts
Normal 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`
|
||||
}
|
||||
84
apps/app/src/pages/select-organisation.vue
Normal file
84
apps/app/src/pages/select-organisation.vue
Normal 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>
|
||||
@@ -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' }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
24
apps/app/src/stores/useNotificationStore.ts
Normal file
24
apps/app/src/stores/useNotificationStore.ts
Normal 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 }
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user