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">
|
<script setup lang="ts">
|
||||||
|
import { watchDebounced } from '@vueuse/core'
|
||||||
import { useForm } from 'vee-validate'
|
import { useForm } from 'vee-validate'
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { apiClient } from '@/lib/axios'
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration'
|
import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration'
|
||||||
import { fullRegistrationSchema } from '@/schemas/registrationSchema'
|
import { fullRegistrationSchema } from '@/schemas/registrationSchema'
|
||||||
@@ -37,6 +39,21 @@ const submitError = ref<string | null>(null)
|
|||||||
const fieldFormData = ref<Record<string, unknown>>({})
|
const fieldFormData = ref<Record<string, unknown>>({})
|
||||||
const fieldErrors = ref<Record<string, string>>({})
|
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({
|
const { errors, defineField, validateField, setFieldValue, setFieldError } = useForm({
|
||||||
validationSchema: toTypedSchema(fullRegistrationSchema),
|
validationSchema: toTypedSchema(fullRegistrationSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@@ -63,6 +80,139 @@ watch(() => authStore.user, user => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { 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 selectedSectionIds = ref<string[]>([])
|
||||||
const selectedTimeSlotIds = ref<string[]>([])
|
const selectedTimeSlotIds = ref<string[]>([])
|
||||||
const timeSlotPreferences = ref<Record<string, number>>({})
|
const timeSlotPreferences = ref<Record<string, number>>({})
|
||||||
@@ -287,8 +437,9 @@ async function validateCurrentStep(): Promise<boolean> {
|
|||||||
const k = currentStepKind.value
|
const k = currentStepKind.value
|
||||||
if (k === 'personal') {
|
if (k === 'personal') {
|
||||||
const results = await Promise.all(personalFieldKeys.map(f => validateField(f)))
|
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')
|
if (k === 'dynamic')
|
||||||
return validateDynamicFields()
|
return validateDynamicFields()
|
||||||
@@ -390,14 +541,30 @@ function applyServerValidationErrors(serverErrors: Record<string, string[]>) {
|
|||||||
|
|
||||||
continue
|
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))
|
if (isPersonalFieldKey(key))
|
||||||
setFieldError(key, msgs[0] ?? 'Ongeldige waarde.')
|
setFieldError(key, msgs[0] ?? 'Ongeldige waarde.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRegistrationStepOneServerErrorKey(key: string): boolean {
|
||||||
|
return isPersonalFieldKey(key) || key === 'password' || key === 'password_confirmation'
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
submitError.value = null
|
submitError.value = null
|
||||||
fieldErrors.value = {}
|
fieldErrors.value = {}
|
||||||
|
passwordServerError.value = ''
|
||||||
|
passwordConfirmationServerError.value = ''
|
||||||
|
|
||||||
const rPersonal = await Promise.all(personalFieldKeys.map(f => validateField(f)))
|
const rPersonal = await Promise.all(personalFieldKeys.map(f => validateField(f)))
|
||||||
if (!rPersonal.every(x => x.valid)) {
|
if (!rPersonal.every(x => x.valid)) {
|
||||||
@@ -405,6 +572,11 @@ async function onSubmit() {
|
|||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!validatePasswordsForStep()) {
|
||||||
|
currentStep.value = 0
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!validateDynamicFields()) {
|
if (!validateDynamicFields()) {
|
||||||
currentStep.value = 1
|
currentStep.value = 1
|
||||||
|
|
||||||
@@ -437,6 +609,12 @@ async function onSubmit() {
|
|||||||
availabilities,
|
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 (field_values) payload.field_values = field_values
|
||||||
if (section_preferences?.length) payload.section_preferences = section_preferences
|
if (section_preferences?.length) payload.section_preferences = section_preferences
|
||||||
|
|
||||||
@@ -464,7 +642,7 @@ async function onSubmit() {
|
|||||||
if (serverErrors) {
|
if (serverErrors) {
|
||||||
applyServerValidationErrors(serverErrors)
|
applyServerValidationErrors(serverErrors)
|
||||||
const keys = Object.keys(serverErrors)
|
const keys = Object.keys(serverErrors)
|
||||||
if (keys.some(k => isPersonalFieldKey(k))) {
|
if (keys.some(k => isRegistrationStepOneServerErrorKey(k))) {
|
||||||
currentStep.value = 0
|
currentStep.value = 0
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -894,7 +1072,32 @@ async function onSubmit() {
|
|||||||
:disabled="authStore.isAuthenticated"
|
:disabled="authStore.isAuthenticated"
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
hide-details="auto"
|
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>
|
||||||
|
|
||||||
<VCol cols="12">
|
<VCol cols="12">
|
||||||
@@ -916,6 +1119,82 @@ async function onSubmit() {
|
|||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Step 1: Extra informatie (dynamic registration_fields) -->
|
<!-- Step 1: Extra informatie (dynamic registration_fields) -->
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export interface VolunteerRegistrationForm {
|
|||||||
date_of_birth: string
|
date_of_birth: string
|
||||||
email: string
|
email: string
|
||||||
phone: string
|
phone: string
|
||||||
|
password?: string
|
||||||
|
password_confirmation?: string
|
||||||
field_values?: Record<string, unknown>
|
field_values?: Record<string, unknown>
|
||||||
section_preferences?: SectionPreference[]
|
section_preferences?: SectionPreference[]
|
||||||
availabilities: VolunteerAvailability[]
|
availabilities: VolunteerAvailability[]
|
||||||
|
|||||||
Reference in New Issue
Block a user