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);
|
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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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 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)
|
||||||
},
|
},
|
||||||
|
|||||||
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 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' }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
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 { 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user