feat(portal): login, dashboard, event switcher, password reset flow
Made-with: Cursor
This commit is contained in:
123
apps/portal/src/components/portal/EventSwitcher.vue
Normal file
123
apps/portal/src/components/portal/EventSwitcher.vue
Normal 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>
|
||||
161
apps/portal/src/components/portal/StatusCard.vue
Normal file
161
apps/portal/src/components/portal/StatusCard.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
46
apps/portal/src/pages/registreren/index.vue
Normal file
46
apps/portal/src/pages/registreren/index.vue
Normal 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>
|
||||
139
apps/portal/src/pages/wachtwoord-resetten.vue
Normal file
139
apps/portal/src/pages/wachtwoord-resetten.vue
Normal 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>
|
||||
92
apps/portal/src/pages/wachtwoord-vergeten.vue
Normal file
92
apps/portal/src/pages/wachtwoord-vergeten.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
203
apps/portal/src/stores/usePortalStore.ts
Normal file
203
apps/portal/src/stores/usePortalStore.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
62
apps/portal/src/types/portal.ts
Normal file
62
apps/portal/src/types/portal.ts
Normal 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
|
||||
}>
|
||||
}
|
||||
Reference in New Issue
Block a user