feat(portal): adaptive email check and passwords on volunteer registration

Made-with: Cursor
This commit is contained in:
2026-04-13 00:42:33 +02:00
parent 8435e74fd3
commit de8ebf724b
2 changed files with 284 additions and 3 deletions

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import { watchDebounced } from '@vueuse/core'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { useDisplay } from 'vuetify'
import { apiClient } from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration'
import { fullRegistrationSchema } from '@/schemas/registrationSchema'
@@ -37,6 +39,21 @@ const submitError = ref<string | null>(null)
const fieldFormData = ref<Record<string, unknown>>({})
const fieldErrors = ref<Record<string, string>>({})
const password = ref('')
const passwordConfirmation = ref('')
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: {
@@ -63,6 +80,139 @@ watch(() => authStore.user, user => {
}
}, { immediate: true })
function isEmailFormatForCheck(value: string): boolean {
return value.includes('@') && value.includes('.')
}
async function runCheckEmail(rawEmail: string): Promise<void> {
if (authStore.isAuthenticated) return
const trimmed = rawEmail.trim()
if (!isEmailFormatForCheck(trimmed)) {
emailExists.value = null
lastCheckedEmail.value = ''
emailChecking.value = false
return
}
const seq = ++checkEmailRequestSeq
emailChecking.value = true
try {
const { data } = await apiClient.post<{ exists: boolean }>('/public/check-email', { email: trimmed })
if (seq !== checkEmailRequestSeq) return
emailExists.value = data.exists
lastCheckedEmail.value = trimmed
}
catch {
if (seq !== checkEmailRequestSeq) return
emailExists.value = null
}
finally {
if (seq === checkEmailRequestSeq)
emailChecking.value = false
}
}
function onEmailBlur(): void {
if (authStore.isAuthenticated) return
const trimmed = (email.value ?? '').trim()
if (!isEmailFormatForCheck(trimmed)) return
if (emailChecking.value) return
if (trimmed === lastCheckedEmail.value && emailExists.value !== null) return
void runCheckEmail(trimmed)
}
watchDebounced(
email,
val => {
void runCheckEmail(val ?? '')
},
{ debounce: 500 },
)
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>>({})
@@ -287,8 +437,9 @@ 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 results.every(r => r.valid)
return validatePasswordsForStep()
}
if (k === 'dynamic')
return validateDynamicFields()
@@ -390,14 +541,30 @@ 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)) {
@@ -405,6 +572,11 @@ async function onSubmit() {
return
}
if (!validatePasswordsForStep()) {
currentStep.value = 0
return
}
if (!validateDynamicFields()) {
currentStep.value = 1
@@ -437,6 +609,12 @@ 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
@@ -464,7 +642,7 @@ async function onSubmit() {
if (serverErrors) {
applyServerValidationErrors(serverErrors)
const keys = Object.keys(serverErrors)
if (keys.some(k => isPersonalFieldKey(k))) {
if (keys.some(k => isRegistrationStepOneServerErrorKey(k))) {
currentStep.value = 0
return
@@ -894,7 +1072,32 @@ async function onSubmit() {
:disabled="authStore.isAuthenticated"
density="comfortable"
hide-details="auto"
@blur="onEmailBlur"
>
<template
v-if="!authStore.isAuthenticated && emailChecking"
#append-inner
>
<VProgressCircular
indeterminate
color="primary"
size="20"
width="2"
/>
</template>
</VTextField>
<p
v-if="!authStore.isAuthenticated && emailExists === true"
class="text-body-2 text-success mt-2 mb-0"
>
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>
</VCol>
<VCol cols="12">
@@ -916,6 +1119,82 @@ 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) -->

View File

@@ -79,6 +79,8 @@ 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[]