feat(portal): adaptive email check and passwords on volunteer registration
Made-with: Cursor
This commit is contained in:
@@ -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) -->
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user