feat: passwordless registration — defer account creation to approval

Removes password from the volunteer registration form. Account
creation is now deferred to the approval step:

Backend:
- Registration creates Person without User (user_id=null)
- On approval, system finds or creates User by person.email
- New accounts get a "set password" email with activation link
- Existing accounts get a portal link email
- Added registration_source column to persons (self/organizer)
- Fuzzy name matching skipped for self-registered persons
- person.email is always source of truth for account linking

Frontend:
- Registration form no longer collects password
- Email check shows info alert with login suggestion
- New wachtwoord-instellen.vue page for account activation
- PasswordRequirements.vue component (reused on reset page)
- Success page updated with activation messaging

Tests: 837 passed (all updated for new flow)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 03:27:47 +02:00
parent 0221e7f6d3
commit c4a23b6763
22 changed files with 539 additions and 493 deletions

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
const props = defineProps<{ password: string }>()
const requirements = computed(() => [
{ label: 'Minimaal 8 tekens', met: props.password.length >= 8 },
{ label: 'Een hoofdletter', met: /[A-Z]/.test(props.password) },
{ label: 'Een kleine letter', met: /[a-z]/.test(props.password) },
{ label: 'Een cijfer', met: /[0-9]/.test(props.password) },
])
const allMet = computed(() => requirements.value.every(r => r.met))
defineExpose({ allMet })
</script>
<template>
<div class="text-body-2 mt-2">
<div
v-for="req in requirements"
:key="req.label"
:class="req.met ? 'text-success' : 'text-medium-emphasis'"
class="d-flex align-center gap-1 mb-1"
>
<VIcon
:icon="req.met ? 'tabler-check' : 'tabler-x'"
size="14"
/>
<span>{{ req.label }}</span>
</div>
</div>
</template>

View File

@@ -24,7 +24,6 @@ export function useRegistrationData(eventSlug: Ref<string>) {
export interface VolunteerRegistrationResponse {
person: Record<string, unknown>
has_existing_account: boolean
}
export function useSubmitRegistration() {

View File

@@ -32,6 +32,7 @@ const errorMessage = ref('')
const isSubmitting = ref(false)
const passwordResetDone = computed(() => route.query.reset === '1')
const accountActivated = computed(() => route.query.activated === '1')
// MFA challenge state
const showMfaChallenge = ref(false)
@@ -179,7 +180,17 @@ function onMfaCancelled() {
<VCardText>
<VAlert
v-if="passwordResetDone"
v-if="accountActivated"
type="success"
variant="tonal"
class="mb-4"
density="comfortable"
>
Account geactiveerd! Je kunt nu inloggen.
</VAlert>
<VAlert
v-else-if="passwordResetDone"
type="success"
variant="tonal"
class="mb-4"

View File

@@ -42,21 +42,12 @@ const submitError = ref<string | null>(null)
const fieldFormData = ref<Record<string, unknown>>({})
const fieldErrors = ref<Record<string, string>>({})
const password = ref('')
const passwordConfirmation = ref('')
// Email existence check (privacy-safe — only shows boolean)
const emailExists = ref<boolean | null>(null)
const emailChecking = ref(false)
const lastCheckedEmail = ref('')
let checkEmailRequestSeq = 0
const showPassword = ref(false)
const showPasswordConfirmation = ref(false)
const passwordServerError = ref('')
const passwordConfirmationServerError = ref('')
const passwordClientError = ref('')
const passwordConfirmationClientError = ref('')
const { errors, defineField, validateField, setFieldValue, setFieldError } = useForm({
validationSchema: toTypedSchema(fullRegistrationSchema),
initialValues: {
@@ -138,84 +129,12 @@ watchDebounced(
watch(email, val => {
if (authStore.isAuthenticated) return
const t = (val ?? '').trim()
if (t !== lastCheckedEmail.value) {
emailExists.value = null
password.value = ''
passwordConfirmation.value = ''
passwordServerError.value = ''
passwordConfirmationServerError.value = ''
passwordClientError.value = ''
passwordConfirmationClientError.value = ''
}
})
watch(() => authStore.isAuthenticated, authed => {
if (!authed) return
emailExists.value = null
emailChecking.value = false
lastCheckedEmail.value = ''
password.value = ''
passwordConfirmation.value = ''
passwordServerError.value = ''
passwordConfirmationServerError.value = ''
passwordClientError.value = ''
passwordConfirmationClientError.value = ''
})
watch(password, () => {
passwordServerError.value = ''
passwordClientError.value = ''
})
watch(passwordConfirmation, () => {
passwordConfirmationServerError.value = ''
passwordConfirmationClientError.value = ''
})
function validatePasswordsForStep(): boolean {
passwordClientError.value = ''
passwordConfirmationClientError.value = ''
if (authStore.isAuthenticated) return true
if (emailChecking.value) return false
const em = (email.value ?? '').trim()
if (!isEmailFormatForCheck(em)) return true
if (emailExists.value === null) return false
if (emailExists.value === true) {
if (!password.value) {
passwordClientError.value = 'Verplicht'
return false
}
return true
}
if (!password.value) {
passwordClientError.value = 'Verplicht'
return false
}
if (password.value.length < 8) {
passwordClientError.value = 'Minimaal 8 tekens'
return false
}
if (password.value !== passwordConfirmation.value) {
passwordConfirmationClientError.value = 'Wachtwoorden komen niet overeen'
return false
}
return true
}
const selectedSectionIds = ref<string[]>([])
const selectedTimeSlotIds = ref<string[]>([])
const timeSlotPreferences = ref<Record<string, number>>({})
@@ -440,9 +359,8 @@ async function validateCurrentStep(): Promise<boolean> {
const k = currentStepKind.value
if (k === 'personal') {
const results = await Promise.all(personalFieldKeys.map(f => validateField(f)))
if (!results.every(r => r.valid)) return false
return validatePasswordsForStep()
return results.every(r => r.valid)
}
if (k === 'dynamic')
return validateDynamicFields()
@@ -544,30 +462,14 @@ function applyServerValidationErrors(serverErrors: Record<string, string[]>) {
continue
}
if (key === 'password') {
passwordServerError.value = msgs[0] ?? 'Ongeldige waarde.'
continue
}
if (key === 'password_confirmation') {
passwordConfirmationServerError.value = msgs[0] ?? 'Ongeldige waarde.'
continue
}
if (isPersonalFieldKey(key))
setFieldError(key, msgs[0] ?? 'Ongeldige waarde.')
}
}
function isRegistrationStepOneServerErrorKey(key: string): boolean {
return isPersonalFieldKey(key) || key === 'password' || key === 'password_confirmation'
}
async function onSubmit() {
submitError.value = null
fieldErrors.value = {}
passwordServerError.value = ''
passwordConfirmationServerError.value = ''
const rPersonal = await Promise.all(personalFieldKeys.map(f => validateField(f)))
if (!rPersonal.every(x => x.valid)) {
@@ -575,11 +477,6 @@ async function onSubmit() {
return
}
if (!validatePasswordsForStep()) {
currentStep.value = 0
return
}
if (!validateDynamicFields()) {
currentStep.value = 1
@@ -612,17 +509,11 @@ async function onSubmit() {
availabilities,
}
if (!authStore.isAuthenticated) {
payload.password = password.value
if (emailExists.value === false)
payload.password_confirmation = passwordConfirmation.value
}
if (field_values) payload.field_values = field_values
if (section_preferences?.length) payload.section_preferences = section_preferences
try {
const result = await submitRegistration({
await submitRegistration({
eventId: registrationData.value.event.id,
form: payload,
})
@@ -644,7 +535,6 @@ async function onSubmit() {
event: registrationData.value.event.name,
banner: registrationData.value.event.registration_banner_url ?? '',
authenticated: authStore.isAuthenticated ? '1' : '0',
hasAccount: result.has_existing_account ? '1' : '0',
},
})
}
@@ -657,7 +547,7 @@ async function onSubmit() {
if (serverErrors) {
applyServerValidationErrors(serverErrors)
const keys = Object.keys(serverErrors)
if (keys.some(k => isRegistrationStepOneServerErrorKey(k))) {
if (keys.some(k => isPersonalFieldKey(k))) {
currentStep.value = 0
return
@@ -981,7 +871,7 @@ async function onSubmit() {
</p>
</div>
<!-- Step 0: Over jou + contact (Vuexy: vertical fields, form separator, contact block) -->
<!-- Step 0: Over jou + contact -->
<div v-show="currentStep === 0">
<VRow>
<VCol
@@ -1063,7 +953,6 @@ async function onSubmit() {
</h6>
<p class="text-body-2 text-medium-emphasis mb-6">
Vul deze gegevens zorgvuldig in: we gebruiken ze om alle informatie over het evenement naar je te versturen.
Je e-mailadres is ook je gebruikersnaam voor Crewli, het systeem waar je straks alle informatie terugvindt.
</p>
<VRow>
@@ -1099,18 +988,40 @@ async function onSubmit() {
/>
</template>
</VTextField>
<p
v-if="!authStore.isAuthenticated && emailExists === true"
class="text-body-2 text-success mt-2 mb-0"
</VCol>
<!-- Existing account notice -->
<VCol
v-if="!authStore.isAuthenticated && emailExists === true"
cols="12"
>
<VAlert
type="info"
variant="tonal"
>
We herkennen dit emailadres! Vul je wachtwoord in om verder te gaan.
</p>
<p
v-else-if="!authStore.isAuthenticated && emailExists === false"
class="text-caption text-medium-emphasis mt-2 mb-0"
>
Je maakt een nieuw account aan
</p>
<template #title>
Er bestaat al een account met dit e-mailadres
</template>
<template #text>
<p class="mb-2">
Als je al eerder bij een evenement betrokken bent geweest,
kun je inloggen om je registratie automatisch te koppelen
aan je bestaande account.
</p>
<VBtn
size="small"
variant="tonal"
color="primary"
:to="`/login?to=/register/${eventSlug}`"
>
Inloggen en registreren
</VBtn>
<p class="text-caption text-medium-emphasis mt-2 mb-0">
Je kunt ook gewoon doorgaan met registreren we koppelen
je registratie later aan je account.
</p>
</template>
</VAlert>
</VCol>
<VCol cols="12">
@@ -1132,82 +1043,6 @@ async function onSubmit() {
/>
</VCol>
</VRow>
<template v-if="!authStore.isAuthenticated && emailExists !== null">
<VRow v-if="emailExists === false">
<VCol cols="12">
<label
class="text-body-2 d-block mb-1 text-high-emphasis"
for="volunteer-reg-password"
>
Wachtwoord <span class="text-error">*</span>
</label>
<VTextField
id="volunteer-reg-password"
v-model="password"
variant="outlined"
:type="showPassword ? 'text' : 'password'"
density="comfortable"
hide-details="auto"
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
:rules="[(v: string) => !!v || 'Verplicht', (v: string) => v.length >= 8 || 'Minimaal 8 tekens']"
:error-messages="passwordServerError || passwordClientError ? [passwordServerError || passwordClientError] : undefined"
@click:append-inner="showPassword = !showPassword"
/>
</VCol>
<VCol cols="12">
<label
class="text-body-2 d-block mb-1 text-high-emphasis"
for="volunteer-reg-password-confirm"
>
Bevestig wachtwoord <span class="text-error">*</span>
</label>
<VTextField
id="volunteer-reg-password-confirm"
v-model="passwordConfirmation"
variant="outlined"
:type="showPasswordConfirmation ? 'text' : 'password'"
density="comfortable"
hide-details="auto"
:append-inner-icon="showPasswordConfirmation ? 'mdi-eye-off' : 'mdi-eye'"
:rules="[(v: string) => v === password || 'Wachtwoorden komen niet overeen']"
:error-messages="passwordConfirmationServerError || passwordConfirmationClientError ? [passwordConfirmationServerError || passwordConfirmationClientError] : undefined"
@click:append-inner="showPasswordConfirmation = !showPasswordConfirmation"
/>
</VCol>
</VRow>
<VRow v-else-if="emailExists === true">
<VCol cols="12">
<label
class="text-body-2 d-block mb-1 text-high-emphasis"
for="volunteer-reg-password-existing"
>
Wachtwoord <span class="text-error">*</span>
</label>
<VTextField
id="volunteer-reg-password-existing"
v-model="password"
variant="outlined"
:type="showPassword ? 'text' : 'password'"
density="comfortable"
hide-details="auto"
hint="Voer je bestaande wachtwoord in"
persistent-hint
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
:rules="[(v: string) => !!v || 'Verplicht']"
:error-messages="passwordServerError || passwordClientError ? [passwordServerError || passwordClientError] : undefined"
@click:append-inner="showPassword = !showPassword"
/>
<RouterLink
to="/wachtwoord-vergeten"
class="text-caption d-inline-block mt-1"
>
Wachtwoord vergeten?
</RouterLink>
</VCol>
</VRow>
</template>
</div>
<!-- Step 1: Extra informatie (dynamic registration_fields) -->
@@ -1630,7 +1465,7 @@ async function onSubmit() {
}
/*
Vuetifys .v-list uses overflow: auto, which shows scrollbars on this step when
Vuetify's .v-list uses overflow: auto, which shows scrollbars on this step when
list rows are slightly wider than the card (e.g. checkbox + text + rating).
*/
.registration-availability-list {

View File

@@ -16,7 +16,6 @@ const authStore = useAuthStore()
const eventName = computed(() => (route.query.event as string) || 'het evenement')
const bannerUrl = computed(() => (route.query.banner as string) || null)
const isAuthenticated = computed(() => route.query.authenticated === '1' || authStore.isAuthenticated)
const hasExistingAccount = computed(() => route.query.hasAccount === '1' && !isAuthenticated.value)
</script>
<template>
@@ -79,6 +78,7 @@ const hasExistingAccount = computed(() => route.query.hasAccount === '1' && !isA
<p class="text-body-2 text-disabled mb-8">
Je ontvangt een e-mail zodra je aanmelding is goedgekeurd.
Daarin vind je een link om je account te activeren.
</p>
<div class="d-flex flex-wrap justify-center gap-4">
@@ -93,45 +93,15 @@ const hasExistingAccount = computed(() => route.query.hasAccount === '1' && !isA
<VBtn
v-else
to="/login"
to="/"
color="primary"
variant="tonal"
prepend-icon="tabler-login"
prepend-icon="tabler-home"
>
Heb je al een account? Log in
Terug naar startpagina
</VBtn>
</div>
</VCard>
<VAlert
v-if="hasExistingAccount"
type="info"
variant="tonal"
class="mt-4"
>
<div class="font-weight-medium mb-1">
Er bestaat al een account met dit e-mailadres
</div>
<p class="text-body-2 mb-3">
Log in om je aanmelding te koppelen aan je bestaande account.
Zo kun je straks je diensten bekijken in het portaal.
</p>
<VBtn
size="small"
color="primary"
variant="flat"
:to="{ name: 'login' }"
>
<VIcon
start
size="16"
>
tabler-login
</VIcon>
Inloggen
</VBtn>
</VAlert>
</VContainer>
</div>
</template>

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
import authV1BottomShape from '@images/svg/auth-v1-bottom-shape.svg?raw'
import authV1TopShape from '@images/svg/auth-v1-top-shape.svg?raw'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import PasswordRequirements from '@/components/auth/PasswordRequirements.vue'
import { apiClient } from '@/lib/axios'
definePage({
name: 'set-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)
const passwordReqsRef = ref<InstanceType<typeof PasswordRequirements>>()
const confirmationError = computed(() => {
if (!passwordConfirmation.value) return ''
if (password.value !== passwordConfirmation.value) return 'Wachtwoorden komen niet overeen'
return ''
})
const canSubmit = computed(() =>
password.value.length > 0
&& passwordConfirmation.value.length > 0
&& password.value === passwordConfirmation.value
&& (passwordReqsRef.value?.allMet ?? false),
)
async function onSubmit(): Promise<void> {
errorMessage.value = ''
if (!token.value || !email.value) {
errorMessage.value = 'Ongeldige activatielink. Neem contact op met de organisatie.'
return
}
if (!canSubmit.value) 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: { activated: '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 ?? 'Activatielink ongeldig of verlopen. Neem contact op met de organisatie.'
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">
<div class="position-relative my-sm-16">
<VNodeRenderer
:nodes="h('div', { innerHTML: authV1TopShape })"
class="text-primary auth-v1-top-shape d-none d-sm-block"
/>
<VNodeRenderer
:nodes="h('div', { innerHTML: authV1BottomShape })"
class="text-primary auth-v1-bottom-shape d-none d-sm-block"
/>
<VCard
class="auth-card"
max-width="460"
:class="$vuetify.display.smAndUp ? 'pa-6' : 'pa-2'"
>
<VCardItem class="justify-center">
<VCardTitle>
<RouterLink to="/">
<div class="app-logo">
<VNodeRenderer :nodes="themeConfig.app.logo" />
<h1 class="app-logo-title">
{{ themeConfig.app.title }}
</h1>
</div>
</RouterLink>
</VCardTitle>
</VCardItem>
<VCardText>
<h4 class="text-h4 mb-1">
Stel je wachtwoord in
</h4>
<p class="mb-0">
Welkom bij Crewli! Kies een wachtwoord om je account te activeren.
</p>
</VCardText>
<VCardText>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
>
{{ errorMessage }}
</VAlert>
<VForm @submit.prevent="onSubmit">
<VRow>
<VCol cols="12">
<AppTextField
v-model="password"
autofocus
label="Wachtwoord"
placeholder="············"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
:append-inner-icon="showPassword ? 'tabler-eye-off' : 'tabler-eye'"
@click:append-inner="showPassword = !showPassword"
/>
<PasswordRequirements
ref="passwordReqsRef"
:password="password"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="passwordConfirmation"
label="Bevestig wachtwoord"
placeholder="············"
:type="showPasswordConfirmation ? 'text' : 'password'"
autocomplete="new-password"
:error-messages="confirmationError ? [confirmationError] : undefined"
:append-inner-icon="showPasswordConfirmation ? 'tabler-eye-off' : 'tabler-eye'"
@click:append-inner="showPasswordConfirmation = !showPasswordConfirmation"
/>
</VCol>
<VCol cols="12">
<VBtn
block
type="submit"
:loading="isSubmitting"
:disabled="!canSubmit"
>
Account activeren
</VBtn>
</VCol>
<VCol cols="12">
<RouterLink
class="d-flex align-center justify-center"
to="/login"
>
<VIcon
icon="tabler-chevron-left"
size="20"
class="me-1 flip-in-rtl"
/>
<span>Terug naar inloggen</span>
</RouterLink>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</div>
</div>
</template>
<style lang="scss">
@use "@core/scss/template/pages/page-auth";
</style>

View File

@@ -3,6 +3,7 @@ import authV1BottomShape from '@images/svg/auth-v1-bottom-shape.svg?raw'
import authV1TopShape from '@images/svg/auth-v1-top-shape.svg?raw'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import PasswordRequirements from '@/components/auth/PasswordRequirements.vue'
import { apiClient } from '@/lib/axios'
definePage({
@@ -120,6 +121,7 @@ async function onSubmit(): Promise<void> {
:append-inner-icon="showPassword ? 'tabler-eye-off' : 'tabler-eye'"
@click:append-inner="showPassword = !showPassword"
/>
<PasswordRequirements :password="password" />
</VCol>
<VCol cols="12">

View File

@@ -2,7 +2,7 @@ import type { Router } from 'vue-router'
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/usePortalStore'
const guestOnlyPaths = ['/login', '/wachtwoord-vergeten', '/wachtwoord-resetten', '/verify-email-change']
const guestOnlyPaths = ['/login', '/wachtwoord-vergeten', '/wachtwoord-resetten', '/wachtwoord-instellen', '/verify-email-change']
// Old dashboard routes that need backward-compat redirects
const dashboardRedirects: Record<string, string> = {

View File

@@ -79,8 +79,6 @@ export interface VolunteerRegistrationForm {
date_of_birth: string
email: string
phone: string
password?: string
password_confirmation?: string
field_values?: Record<string, unknown>
section_preferences?: SectionPreference[]
availabilities: VolunteerAvailability[]