feat(portal): login, dashboard, event switcher, password reset flow

Made-with: Cursor
This commit is contained in:
2026-04-13 00:52:04 +02:00
parent ec4ba8733d
commit 34eb790b3e
16 changed files with 1151 additions and 394 deletions

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { usePortalStore } from '@/stores/usePortalStore'
const portal = usePortalStore()
const menuOpen = ref(false)
function statusColor(status: string): string {
if (status === 'approved') return 'success'
if (status === 'pending' || status === 'applied' || status === 'invited') return 'warning'
if (status === 'rejected') return 'error'
return 'secondary'
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
pending: 'In behandeling',
applied: 'In behandeling',
invited: 'Uitgenodigd',
approved: 'Goedgekeurd',
rejected: 'Afgewezen',
no_show: 'Niet verschenen',
}
return map[status] ?? status
}
function selectEvent(id: string) {
portal.setActiveEvent(id)
menuOpen.value = false
}
</script>
<template>
<div
v-if="portal.userEvents.length === 0"
class="text-body-2 text-medium-emphasis ms-2 ms-sm-4 d-flex align-center min-w-0"
>
Geen evenement
</div>
<div
v-else-if="portal.userEvents.length === 1 && portal.activeEvent"
class="ms-2 ms-sm-4 d-flex align-center gap-2 min-w-0 flex-grow-1 flex-sm-grow-0"
>
<VIcon
icon="tabler-calendar-event"
size="20"
class="flex-shrink-0"
/>
<span class="text-body-1 font-weight-medium text-truncate">{{ portal.activeEvent.event_name }}</span>
<VChip
:color="statusColor(portal.activeEvent.person_status)"
size="small"
label
class="flex-shrink-0"
>
{{ statusLabel(portal.activeEvent.person_status) }}
</VChip>
</div>
<VMenu
v-else
v-model="menuOpen"
location="bottom"
:close-on-content-click="true"
>
<template #activator="{ props: menuProps }">
<VBtn
v-bind="menuProps"
variant="text"
class="ms-2 ms-sm-4 text-none min-w-0"
rounded="lg"
>
<VIcon
icon="tabler-calendar-event"
start
size="20"
/>
<span class="text-truncate max-w-[200px] sm:max-w-[280px]">{{ portal.activeEvent?.event_name ?? 'Kies evenement' }}</span>
<VIcon
icon="tabler-chevron-down"
end
size="18"
/>
</VBtn>
</template>
<VList
density="compact"
min-width="280"
>
<VListSubheader class="text-caption">
Jouw evenementen
</VListSubheader>
<VListItem
v-for="ev in portal.userEvents"
:key="ev.event_id"
:active="ev.event_id === portal.activeEventId"
@click="selectEvent(ev.event_id)"
>
<VListItemTitle class="text-wrap">
{{ ev.event_name }}
</VListItemTitle>
<VListItemSubtitle class="d-flex flex-column gap-1 mt-1">
<div class="d-flex align-center gap-2 flex-wrap">
<VChip
:color="statusColor(ev.person_status)"
size="x-small"
label
>
{{ statusLabel(ev.person_status) }}
</VChip>
</div>
<span
v-if="ev.organisation_name"
class="text-caption text-medium-emphasis"
>{{ ev.organisation_name }}</span>
</VListItemSubtitle>
</VListItem>
</VList>
</VMenu>
</template>

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
const props = defineProps<{
variant: 'pending' | 'approved' | 'rejected'
eventName: string
registeredAt?: string | null
nextShiftSummary?: string | null
}>()
const registeredLabel = computed(() => {
if (!props.registeredAt) return null
try {
return new Date(props.registeredAt).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
catch {
return null
}
})
</script>
<template>
<VCard
variant="tonal"
:color="variant === 'approved' ? 'success' : variant === 'pending' ? 'warning' : 'error'"
class="pa-6"
>
<template v-if="variant === 'pending'">
<div class="d-flex align-start gap-3">
<VIcon
icon="tabler-clock"
size="32"
/>
<div>
<h5 class="text-h5 mb-2">
Je registratie wordt beoordeeld
</h5>
<p class="text-body-1 mb-2">
Je hebt je aangemeld voor <strong>{{ eventName }}</strong>.
De organisatie beoordeelt je registratie.
</p>
<p class="text-body-1 mb-2">
Je ontvangt een e-mail zodra er een besluit is.
</p>
<p
v-if="registeredLabel"
class="text-body-2 text-medium-emphasis mb-0"
>
Aangemeld op: {{ registeredLabel }}
</p>
</div>
</div>
</template>
<template v-else-if="variant === 'rejected'">
<div class="d-flex align-start gap-3">
<VIcon
icon="tabler-circle-x"
size="32"
/>
<div>
<h5 class="text-h5 mb-2">
Je aanmelding is niet geselecteerd
</h5>
<p class="text-body-1 mb-0">
Helaas is je aanmelding voor <strong>{{ eventName }}</strong> niet geselecteerd.
Neem contact op met de organisatie als je vragen hebt.
</p>
</div>
</div>
</template>
<template v-else>
<div class="d-flex align-start gap-3 mb-6">
<VIcon
icon="tabler-circle-check"
size="32"
/>
<div>
<h5 class="text-h5 mb-1">
Welkom bij {{ eventName }}!
</h5>
<p class="text-body-2 text-medium-emphasis mb-0">
Je bent goedgekeurd als vrijwilliger.
</p>
</div>
</div>
<VRow class="mb-6">
<VCol
cols="12"
sm="4"
>
<VCard
:to="{ name: 'portal-shifts' }"
variant="outlined"
class="pa-4 h-100 text-decoration-none"
>
<div class="text-subtitle-2 text-medium-emphasis mb-1">
Mijn Shifts
</div>
<div class="text-body-2">
Rooster bekijken
</div>
</VCard>
</VCol>
<VCol
cols="12"
sm="4"
>
<VCard
variant="outlined"
class="pa-4 h-100 text-medium-emphasis"
>
<div class="text-subtitle-2 mb-1">
Shifts claimen
</div>
<div class="text-body-2">
Binnenkort beschikbaar
</div>
</VCard>
</VCol>
<VCol
cols="12"
sm="4"
>
<VCard
:to="{ name: 'portal-profile' }"
variant="outlined"
class="pa-4 h-100 text-decoration-none"
>
<div class="text-subtitle-2 text-medium-emphasis mb-1">
Profiel
</div>
<div class="text-body-2">
Gegevens bekijken
</div>
</VCard>
</VCol>
</VRow>
<div class="text-subtitle-1 font-weight-bold mb-2">
Komende shift
</div>
<p
v-if="nextShiftSummary"
class="text-body-1 mb-0"
>
{{ nextShiftSummary }}
</p>
<p
v-else
class="text-body-2 text-medium-emphasis mb-0"
>
Er is nog geen shift ingepland. Je coördinator houdt je op de hoogte.
</p>
</template>
</VCard>
</template>

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import EventSwitcher from '@/components/portal/EventSwitcher.vue'
import { useAuthStore } from '@/stores/useAuthStore'
const { injectSkinClasses } = useSkins()
@@ -6,6 +7,7 @@ const { injectSkinClasses } = useSkins()
injectSkinClasses()
const authStore = useAuthStore()
const router = useRouter()
const isMobileMenuOpen = ref(false)
@@ -29,6 +31,16 @@ watch([isFallbackStateActive, refLoadingIndicator], () => {
if (!isFallbackStateActive.value && refLoadingIndicator.value)
refLoadingIndicator.value.resolveHandle()
}, { immediate: true })
async function logoutAndRedirect(): Promise<void> {
await authStore.logout()
await router.push('/login')
}
async function logoutFromDrawer(): Promise<void> {
isMobileMenuOpen.value = false
await logoutAndRedirect()
}
</script>
<template>
@@ -49,7 +61,7 @@ watch([isFallbackStateActive, refLoadingIndicator], () => {
<!-- Logo & Brand -->
<RouterLink
to="/"
class="d-flex align-center gap-x-2 text-decoration-none"
class="d-flex align-center gap-x-2 text-decoration-none flex-shrink-0"
>
<VIcon
icon="tabler-users-group"
@@ -61,6 +73,11 @@ watch([isFallbackStateActive, refLoadingIndicator], () => {
</span>
</RouterLink>
<EventSwitcher
v-if="authStore.isAuthenticated"
class="min-w-0 flex-grow-1 flex-sm-grow-0"
/>
<!-- Mobile nav toggle -->
<VAppBarNavIcon
class="d-sm-none ms-auto"
@@ -95,7 +112,7 @@ watch([isFallbackStateActive, refLoadingIndicator], () => {
variant="tonal"
color="primary"
size="small"
@click="authStore.logout(); $router.push('/login')"
@click="logoutAndRedirect"
>
<VIcon
start
@@ -144,7 +161,7 @@ watch([isFallbackStateActive, refLoadingIndicator], () => {
v-if="authStore.isAuthenticated"
prepend-icon="tabler-logout"
title="Uitloggen"
@click="authStore.logout(); $router.push('/login'); isMobileMenuOpen = false"
@click="logoutFromDrawer"
/>
<VListItem

View File

@@ -49,10 +49,14 @@ apiClient.interceptors.response.use(
const authStore = useAuthStore()
if (authStore.isInitialized) {
authStore.logout()
authStore.clearLocalSession()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
if (typeof window !== 'undefined') {
const path = window.location.pathname
const publicPaths = ['/login', '/wachtwoord-vergeten', '/wachtwoord-resetten']
if (!publicPaths.some(p => path.startsWith(p)) && !path.startsWith('/register')) {
window.location.href = '/login'
}
}
}
}

View File

@@ -1,4 +1,8 @@
<script setup lang="ts">
import StatusCard from '@/components/portal/StatusCard.vue'
import { usePortalStore } from '@/stores/usePortalStore'
import type { PortalPersonPayload } from '@/types/portal'
definePage({
name: 'portal-dashboard',
meta: {
@@ -6,23 +10,124 @@ definePage({
requiresAuth: true,
},
})
const portal = usePortalStore()
const effectiveStatus = computed(() => {
const fromPerson = portal.currentPerson?.status
if (fromPerson) return fromPerson
return portal.activeEvent?.person_status ?? 'pending'
})
const statusVariant = computed((): 'pending' | 'approved' | 'rejected' => {
const s = effectiveStatus.value
if (s === 'approved') return 'approved'
if (s === 'rejected') return 'rejected'
return 'pending'
})
const eventTitle = computed(() => portal.activeEvent?.event_name ?? 'dit evenement')
const registeredAt = computed(() => portal.currentPerson?.created_at ?? null)
function formatNextShift(person: PortalPersonPayload | null): string | null {
const list = person?.shift_assignments
if (!list?.length) return null
const usable = list.filter(
a => a.shift?.time_slot?.date && (a.status === 'approved' || a.status === 'pending_approval'),
)
if (!usable.length) return null
usable.sort((a, b) => {
const da = a.shift?.time_slot?.date ?? ''
const db = b.shift?.time_slot?.date ?? ''
return da.localeCompare(db)
})
const a = usable[0]!
const slot = a.shift?.time_slot
const section = a.shift?.festival_section?.name
if (!slot?.date) return null
const dateStr = new Date(`${slot.date}T12:00:00`).toLocaleDateString('nl-NL', {
weekday: 'long',
day: 'numeric',
month: 'long',
})
const start = slot.start_time?.slice(0, 5) ?? ''
const end = slot.end_time?.slice(0, 5) ?? ''
const timePart = start && end ? `${start} ${end}` : start || ''
const place = section ? `${section}` : ''
return `📅 ${dateStr}${timePart ? `, ${timePart}` : ''}${place}`
}
const nextShiftSummary = computed(() => formatNextShift(portal.currentPerson))
onMounted(async () => {
await portal.hydrateAfterAuth()
})
</script>
<template>
<VRow justify="center">
<VCol
cols="12"
md="8"
lg="6"
lg="10"
>
<VCard class="text-center pa-6">
<VCardTitle class="text-h5">
Mijn Dashboard
</VCardTitle>
<VCardSubtitle>
Welkom terug! Hier zie je je shifts en informatie.
</VCardSubtitle>
</VCard>
<VSkeletonLoader
v-if="portal.isLoadingEvents"
type="article"
/>
<VAlert
v-else-if="portal.loadError"
type="warning"
variant="tonal"
class="mb-4"
>
{{ portal.loadError }}
</VAlert>
<VAlert
v-else-if="!portal.userEvents.length"
type="info"
variant="tonal"
class="mb-4"
>
Je hebt nog geen evenementen waarvoor je bent aangemeld, of ze zijn niet gekoppeld aan dit account.
Meld je aan via de link van je organisatie, of log in met hetzelfde e-mailadres als bij je aanmelding.
</VAlert>
<template v-else>
<VSkeletonLoader
v-if="portal.isLoadingPerson && !portal.currentPerson"
type="article"
class="mb-4"
/>
<VAlert
v-else-if="!portal.currentPerson && !portal.isLoadingPerson"
type="warning"
variant="tonal"
class="mb-4"
>
We konden je registratie voor dit evenement niet ophalen. Controleer of je met het juiste account bent ingelogd,
of probeer het later opnieuw.
</VAlert>
<StatusCard
:variant="statusVariant"
:event-name="eventTitle"
:registered-at="registeredAt"
:next-shift-summary="nextShiftSummary"
/>
</template>
</VCol>
</VRow>
</template>

View File

@@ -6,6 +6,8 @@ import authV2LoginIllustrationBorderedLight from '@images/pages/auth-v2-login-il
import authV2LoginIllustrationBorderedDark from '@images/pages/auth-v2-login-illustration-bordered-dark.png'
import miscMaskLight from '@images/pages/misc-mask-light.png'
import miscMaskDark from '@images/pages/misc-mask-dark.png'
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/usePortalStore'
definePage({
name: 'login',
@@ -15,12 +17,21 @@ definePage({
},
})
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const portalStore = usePortalStore()
const form = ref({
email: '',
password: '',
})
const isPasswordVisible = ref(false)
const errorMessage = ref('')
const isSubmitting = ref(false)
const passwordResetDone = computed(() => route.query.reset === '1')
const authThemeImg = useGenerateImageVariant(
authV2LoginIllustrationLight,
@@ -31,6 +42,37 @@ const authThemeImg = useGenerateImageVariant(
)
const authThemeMask = useGenerateImageVariant(miscMaskLight, miscMaskDark)
function mapLoginErrorMessage(message: string | undefined): string {
if (!message) return 'Inloggen mislukt. Controleer je gegevens.'
if (message === 'Invalid credentials' || message.toLowerCase().includes('invalid credentials'))
return 'Ongeldig e-mailadres of wachtwoord.'
return message
}
async function onSubmit(): Promise<void> {
errorMessage.value = ''
isSubmitting.value = true
try {
await authStore.login(form.email.trim(), form.password)
await portalStore.hydrateAfterAuth()
const redirect = typeof route.query.to === 'string' ? route.query.to : '/dashboard'
await router.replace(redirect || '/dashboard')
}
catch (error: unknown) {
if (error instanceof Error && error.message === 'Sessie kon niet worden gestart.') {
errorMessage.value = 'Je sessie kon niet worden geladen. Probeer het opnieuw.'
return
}
const ax = error as { response?: { data?: { message?: string } } }
errorMessage.value = mapLoginErrorMessage(ax.response?.data?.message)
}
finally {
isSubmitting.value = false
}
}
</script>
<template>
@@ -100,7 +142,27 @@ const authThemeMask = useGenerateImageVariant(miscMaskLight, miscMaskDark)
</VCardText>
<VCardText>
<VForm @submit.prevent>
<VAlert
v-if="passwordResetDone"
type="success"
variant="tonal"
class="mb-4"
density="comfortable"
>
Wachtwoord gewijzigd. Je kunt nu inloggen.
</VAlert>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<VForm @submit.prevent="onSubmit">
<VRow>
<VCol cols="12">
<VTextField
@@ -109,6 +171,11 @@ const authThemeMask = useGenerateImageVariant(miscMaskLight, miscMaskDark)
label="E-mailadres"
type="email"
placeholder="je@email.nl"
autocomplete="email"
variant="outlined"
density="comfortable"
hide-details="auto"
required
/>
</VCol>
@@ -117,30 +184,47 @@ const authThemeMask = useGenerateImageVariant(miscMaskLight, miscMaskDark)
v-model="form.password"
label="Wachtwoord"
placeholder="Je wachtwoord"
autocomplete="current-password"
variant="outlined"
density="comfortable"
hide-details="auto"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
required
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
<div class="d-flex align-center flex-wrap justify-end my-6">
<a
<div class="d-flex align-center flex-wrap justify-end my-4">
<RouterLink
to="/wachtwoord-vergeten"
class="text-primary text-body-2"
href="javascript:void(0)"
>
Wachtwoord vergeten?
</a>
</RouterLink>
</div>
<VBtn
block
type="submit"
color="primary"
:loading="isSubmitting"
>
Inloggen
</VBtn>
</VCol>
</VRow>
</VForm>
<p class="text-body-2 text-center text-medium-emphasis mt-6 mb-0">
Nog geen account?
<RouterLink
to="/registreren"
class="text-primary font-weight-medium"
>
Meld je aan als vrijwilliger
</RouterLink>
</p>
</VCardText>
</VCard>
</VCol>

View File

@@ -5,6 +5,7 @@ import { toTypedSchema } from '@vee-validate/zod'
import { useDisplay } from 'vuetify'
import { apiClient } from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/usePortalStore'
import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration'
import { fullRegistrationSchema } from '@/schemas/registrationSchema'
import type {
@@ -28,6 +29,7 @@ definePage({
const route = useRoute('volunteer-register')
const router = useRouter()
const authStore = useAuthStore()
const portalStore = usePortalStore()
const { mdAndUp } = useDisplay()
const eventSlug = computed(() => route.params.eventSlug as string)
@@ -624,6 +626,17 @@ async function onSubmit() {
form: payload,
})
const ev = registrationData.value.event
portalStore.savePendingEventFromRegistration({
event_id: ev.id,
event_name: ev.name,
organisation_name: '',
organisation_id: ev.organisation_id,
person_status: 'pending',
start_date: ev.start_date,
end_date: ev.end_date,
})
router.push({
path: '/register/success',
query: {

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
definePage({
name: 'volunteer-register-info',
meta: {
layout: 'blank',
requiresAuth: false,
},
})
</script>
<template>
<VContainer
class="py-12"
style="max-inline-size: 640px;"
>
<VCard
class="pa-6 pa-sm-8"
variant="flat"
>
<h1 class="text-h4 mb-4">
Aanmelden als vrijwilliger
</h1>
<p class="text-body-1 text-medium-emphasis mb-4">
Vrijwilligers melden zich aan via een persoonlijke link die door de organisatie wordt gedeeld
(bijvoorbeeld op de website van het evenement of in een uitnodiging per e-mail).
</p>
<p class="text-body-1 text-medium-emphasis mb-6">
Heb je al een Crewli-account? Log dan in om je aanmeldingen te volgen.
</p>
<div class="d-flex flex-wrap gap-3">
<VBtn
color="primary"
to="/login"
>
Inloggen
</VBtn>
<VBtn
variant="tonal"
to="/"
>
Startpagina
</VBtn>
</div>
</VCard>
</VContainer>
</template>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import { apiClient } from '@/lib/axios'
definePage({
name: 'reset-password',
meta: {
layout: 'blank',
requiresAuth: false,
},
})
const route = useRoute()
const router = useRouter()
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
const token = ref(typeof route.query.token === 'string' ? route.query.token : '')
const password = ref('')
const passwordConfirmation = ref('')
const showPassword = ref(false)
const showPasswordConfirmation = ref(false)
const errorMessage = ref('')
const isSubmitting = ref(false)
async function onSubmit(): Promise<void> {
errorMessage.value = ''
if (!token.value || !email.value) {
errorMessage.value = 'Ongeldige resetlink. Vraag een nieuwe link aan.'
return
}
isSubmitting.value = true
try {
await apiClient.post('/auth/reset-password', {
email: email.value.trim(),
password: password.value,
password_confirmation: passwordConfirmation.value,
token: token.value,
})
await router.replace({ path: '/login', query: { reset: '1' } })
}
catch (error: unknown) {
const ax = error as { response?: { status?: number; data?: { message?: string } } }
if (ax.response?.status === 404 || ax.response?.status === 422)
errorMessage.value = ax.response?.data?.message ?? 'Resetlink ongeldig of verlopen. Vraag een nieuwe link aan.'
else
errorMessage.value = 'Er ging iets mis. Probeer het later opnieuw.'
}
finally {
isSubmitting.value = false
}
}
</script>
<template>
<div class="auth-wrapper d-flex align-center justify-center pa-4 bg-surface">
<VCard
flat
:max-width="480"
width="100%"
class="pa-6"
>
<VCardTitle class="text-h5 px-0 pt-0">
Nieuw wachtwoord
</VCardTitle>
<VCardSubtitle class="px-0">
Kies een nieuw wachtwoord voor je account.
</VCardSubtitle>
<VCardText class="px-0">
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
>
{{ errorMessage }}
</VAlert>
<VForm @submit.prevent="onSubmit">
<VTextField
v-model="email"
label="E-mailadres"
type="email"
variant="outlined"
density="comfortable"
class="mb-3"
autocomplete="email"
hide-details="auto"
required
/>
<VTextField
v-model="password"
label="Nieuw wachtwoord"
variant="outlined"
density="comfortable"
class="mb-3"
:type="showPassword ? 'text' : 'password'"
:append-inner-icon="showPassword ? 'tabler-eye-off' : 'tabler-eye'"
hide-details="auto"
autocomplete="new-password"
required
@click:append-inner="showPassword = !showPassword"
/>
<VTextField
v-model="passwordConfirmation"
label="Bevestig wachtwoord"
variant="outlined"
density="comfortable"
class="mb-4"
:type="showPasswordConfirmation ? 'text' : 'password'"
:append-inner-icon="showPasswordConfirmation ? 'tabler-eye-off' : 'tabler-eye'"
hide-details="auto"
autocomplete="new-password"
required
@click:append-inner="showPasswordConfirmation = !showPasswordConfirmation"
/>
<VBtn
type="submit"
color="primary"
block
:loading="isSubmitting"
>
Wachtwoord opslaan
</VBtn>
</VForm>
<div class="text-center mt-4">
<RouterLink
to="/login"
class="text-body-2 text-primary"
>
Terug naar inloggen
</RouterLink>
</div>
</VCardText>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { apiClient } from '@/lib/axios'
definePage({
name: 'forgot-password',
meta: {
layout: 'blank',
requiresAuth: false,
},
})
const email = ref('')
const isSubmitting = ref(false)
const done = ref(false)
async function onSubmit(): Promise<void> {
isSubmitting.value = true
try {
await apiClient.post('/auth/forgot-password', { email: email.value.trim() })
}
catch {
// Endpoint may not exist yet — still show generic success (no email enumeration)
}
finally {
isSubmitting.value = false
done.value = true
}
}
</script>
<template>
<div class="auth-wrapper d-flex align-center justify-center pa-4 bg-surface">
<VCard
flat
:max-width="480"
width="100%"
class="pa-6"
>
<VCardTitle class="text-h5 px-0 pt-0">
Wachtwoord vergeten
</VCardTitle>
<VCardSubtitle class="px-0 text-wrap">
Vul je e-mailadres in. Als dit adres bij ons bekend is, ontvang je een link om je wachtwoord te resetten.
</VCardSubtitle>
<VCardText class="px-0">
<VAlert
v-if="done"
type="success"
variant="tonal"
class="mb-4"
>
Als dit e-mailadres bij ons bekend is, ontvang je een link om je wachtwoord te resetten.
</VAlert>
<VForm
v-else
@submit.prevent="onSubmit"
>
<VTextField
v-model="email"
label="E-mailadres"
type="email"
variant="outlined"
density="comfortable"
class="mb-4"
autocomplete="email"
hide-details="auto"
required
/>
<VBtn
type="submit"
color="primary"
block
:loading="isSubmitting"
>
Versturen
</VBtn>
</VForm>
<div class="text-center mt-4">
<RouterLink
to="/login"
class="text-body-2 text-primary"
>
Terug naar inloggen
</RouterLink>
</div>
</VCardText>
</VCard>
</div>
</template>

View File

@@ -1,6 +1,8 @@
import type { Router } from 'vue-router'
import { useAuthStore } from '@/stores/useAuthStore'
const guestOnlyPaths = ['/login', '/wachtwoord-vergeten', '/wachtwoord-resetten']
export function setupGuards(router: Router) {
router.beforeEach(async (to) => {
const authStore = useAuthStore()
@@ -13,10 +15,10 @@ export function setupGuards(router: Router) {
// Public routes — no auth check needed
if (!requiresAuth) {
// Redirect authenticated users away from login
if (authStore.isAuthenticated && to.path === '/login') {
if (authStore.isAuthenticated && guestOnlyPaths.some(p => to.path === p || to.path.startsWith(`${p}/`))) {
return { path: '/dashboard' }
}
return
}

View File

@@ -1,61 +1,106 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { apiClient } from '@/lib/axios'
interface PortalUser {
id: string
first_name: string
last_name: string
full_name: string
email: string
}
import type { AuthMeUser } from '@/types/portal'
const TOKEN_KEY = 'crewli_portal_token'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
const user = ref<PortalUser | null>(null)
const user = ref<AuthMeUser | null>(null)
const isInitialized = ref(false)
const isAuthenticated = computed(() => !!token.value && !!user.value)
const isAuthenticated = computed(() => !!user.value)
function setToken(newToken: string) {
function setToken(newToken: string | null) {
token.value = newToken
localStorage.setItem(TOKEN_KEY, newToken)
if (newToken)
localStorage.setItem(TOKEN_KEY, newToken)
else
localStorage.removeItem(TOKEN_KEY)
}
function setUser(data: PortalUser) {
function setUser(data: AuthMeUser | null) {
user.value = data
}
function logout() {
token.value = null
user.value = null
localStorage.removeItem(TOKEN_KEY)
async function resetPortalStoresSync(): Promise<void> {
const { usePortalStore } = await import('@/stores/usePortalStore')
usePortalStore().reset()
}
async function fetchUser(): Promise<boolean> {
if (!token.value) {
setUser(null)
return false
}
try {
const { data } = await apiClient.get<{ success: boolean; data: AuthMeUser }>('/auth/me')
setUser(data.data)
return true
}
catch {
setToken(null)
setUser(null)
await resetPortalStoresSync()
return false
}
}
async function login(email: string, password: string): Promise<void> {
const { data } = await apiClient.post<{
success: boolean
data: { user: AuthMeUser; token: string }
}>('/auth/login', { email, password })
setToken(data.data.token)
setUser(data.data.user)
const ok = await fetchUser()
if (!ok) throw new Error('Sessie kon niet worden gestart.')
}
function clearLocalSession(): void {
setToken(null)
setUser(null)
void resetPortalStoresSync()
}
async function logout(): Promise<void> {
try {
if (token.value)
await apiClient.post('/auth/logout')
}
catch {
// Ignore network errors; still clear local session
}
setToken(null)
setUser(null)
await resetPortalStoresSync()
}
let initializePromise: Promise<void> | null = null
function initialize(): Promise<void> {
if (isInitialized.value) return Promise.resolve()
if (!initializePromise) {
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: PortalUser }>('/auth/me')
setUser(data.data)
}
catch {
logout()
await fetchUser()
}
finally {
isInitialized.value = true
@@ -69,7 +114,10 @@ export const useAuthStore = defineStore('auth', () => {
isInitialized,
setToken,
setUser,
login,
logout,
fetchUser,
initialize,
clearLocalSession,
}
})

View File

@@ -0,0 +1,203 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { AuthMeUser, PortalEvent, PortalPersonPayload } from '@/types/portal'
const STORAGE_EVENTS = 'crewli_portal_user_events_v1'
const STORAGE_ACTIVE_EVENT = 'crewli_portal_active_event_id_v1'
function readStoredEvents(): PortalEvent[] {
if (typeof localStorage === 'undefined') return []
try {
const raw = localStorage.getItem(STORAGE_EVENTS)
if (!raw) return []
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.filter(
(e): e is PortalEvent =>
typeof e === 'object'
&& e !== null
&& 'event_id' in e
&& 'event_name' in e
&& 'person_status' in e,
)
}
catch {
return []
}
}
function writeStoredEvents(events: PortalEvent[]): void {
if (typeof localStorage === 'undefined') return
localStorage.setItem(STORAGE_EVENTS, JSON.stringify(events))
}
function readStoredActiveEventId(): string | null {
if (typeof localStorage === 'undefined') return null
return localStorage.getItem(STORAGE_ACTIVE_EVENT)
}
function writeStoredActiveEventId(id: string | null): void {
if (typeof localStorage === 'undefined') return
if (id) localStorage.setItem(STORAGE_ACTIVE_EVENT, id)
else localStorage.removeItem(STORAGE_ACTIVE_EVENT)
}
function mergeEvents(apiEvents: PortalEvent[], stored: PortalEvent[]): PortalEvent[] {
const map = new Map<string, PortalEvent>()
for (const e of stored) map.set(e.event_id, { ...e })
for (const e of apiEvents) {
const prev = map.get(e.event_id)
map.set(e.event_id, {
...prev,
...e,
organisation_name: e.organisation_name || prev?.organisation_name || '',
})
}
return Array.from(map.values()).sort((a, b) => b.start_date.localeCompare(a.start_date))
}
export const usePortalStore = defineStore('portal', () => {
const activeEventId = ref<string | null>(readStoredActiveEventId())
const userEvents = ref<PortalEvent[]>([])
const currentPerson = ref<PortalPersonPayload | null>(null)
const isLoadingEvents = ref(false)
const isLoadingPerson = ref(false)
const loadError = ref<string | null>(null)
const activeEvent = computed(() => userEvents.value.find(e => e.event_id === activeEventId.value) ?? null)
function persistActiveEvent(): void {
writeStoredActiveEventId(activeEventId.value)
}
function persistEvents(): void {
writeStoredEvents(userEvents.value)
}
/**
* Call after successful public registration so the volunteer sees the event on the dashboard.
* TODO: replace with `portal_events` from GET /auth/me when the API exposes it.
*/
function savePendingEventFromRegistration(event: PortalEvent): void {
const merged = mergeEvents([], [...readStoredEvents(), ...userEvents.value, event])
userEvents.value = merged
persistEvents()
if (!activeEventId.value || !merged.some(e => e.event_id === activeEventId.value)) {
activeEventId.value = event.event_id
persistActiveEvent()
}
}
async function loadUserEventsFromApiAndStorage(): Promise<void> {
isLoadingEvents.value = true
loadError.value = null
try {
const stored = readStoredEvents()
let apiEvents: PortalEvent[] = []
try {
const { data } = await apiClient.get<{ success: boolean; data: AuthMeUser }>('/auth/me')
apiEvents = data.data.portal_events ?? []
}
catch {
// /auth/me failed — still show locally stored registrations
}
userEvents.value = mergeEvents(apiEvents, stored)
persistEvents()
}
catch (e) {
loadError.value = 'Kon je evenementen niet laden.'
userEvents.value = readStoredEvents()
}
finally {
isLoadingEvents.value = false
}
}
function resolveActiveEventId(): void {
if (userEvents.value.length === 0) {
activeEventId.value = null
persistActiveEvent()
return
}
const current = activeEventId.value
if (current && userEvents.value.some(e => e.event_id === current)) {
persistActiveEvent()
return
}
activeEventId.value = userEvents.value[0]!.event_id
persistActiveEvent()
}
async function fetchCurrentPerson(): Promise<void> {
currentPerson.value = null
const eid = activeEventId.value
if (!eid) return
isLoadingPerson.value = true
try {
const { data } = await apiClient.get<{ success: boolean; data: PortalPersonPayload }>(
'/portal/me',
{ params: { event_id: eid } },
)
currentPerson.value = data.data
const status = data.data.status
const pid = data.data.id
userEvents.value = userEvents.value.map(row =>
row.event_id === eid ? { ...row, person_id: pid, person_status: status } : row,
)
persistEvents()
}
catch {
currentPerson.value = null
}
finally {
isLoadingPerson.value = false
}
}
async function hydrateAfterAuth(): Promise<void> {
await loadUserEventsFromApiAndStorage()
resolveActiveEventId()
await fetchCurrentPerson()
}
function setActiveEvent(eventId: string): void {
if (!userEvents.value.some(e => e.event_id === eventId)) return
activeEventId.value = eventId
persistActiveEvent()
void fetchCurrentPerson()
}
function reset(): void {
activeEventId.value = null
userEvents.value = []
currentPerson.value = null
loadError.value = null
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(STORAGE_EVENTS)
localStorage.removeItem(STORAGE_ACTIVE_EVENT)
}
}
return {
activeEventId,
userEvents,
currentPerson,
activeEvent,
isLoadingEvents,
isLoadingPerson,
loadError,
savePendingEventFromRegistration,
hydrateAfterAuth,
setActiveEvent,
fetchCurrentPerson,
reset,
}
})

View File

@@ -0,0 +1,62 @@
/**
* Volunteer-facing event context for the portal.
* Populated from GET /auth/me when the API adds `portal_events`, merged with
* locally stored events (e.g. after public registration).
*/
export interface PortalEvent {
event_id: string
event_name: string
organisation_name: string
/** Present when the row was saved from the public registration flow */
organisation_id?: string
person_id?: string | null
person_status: string
start_date: string
end_date: string
}
/** GET /auth/me — extend when backend adds portal_events */
export interface AuthMeUser {
id: string
first_name: string
last_name: string
full_name: string
email: string
timezone?: string
locale?: string
avatar?: string | null
email_verified_at?: string | null
organisations?: Array<{
id: string
name: string
slug: string
role: string
}>
app_roles?: string[]
/** Present on login (`UserResource`); `/auth/me` uses `app_roles` */
roles?: string[]
permissions?: string[]
portal_events?: PortalEvent[]
}
/** GET /portal/me?event_id= — person payload (subset used by dashboard) */
export interface PortalPersonPayload {
id: string
event_id: string
status: string
full_name: string
created_at: string
shift_assignments?: Array<{
id: string
status: string
shift?: {
id: string
festival_section?: { name?: string | null } | null
time_slot?: {
date?: string
start_time?: string
end_time?: string
} | null
} | null
}>
}