feat(login): migrate login form to FormField + Zod (F3 sample, validates FormField API)

Replaces the Vuetify VForm + AppTextField + VBtn stack with the F3
form pattern: @primevue/forms' <Form> with a Zod resolver, the
project-owned <FormField> wrapper from B5, and PrimeVue InputText /
Password / Checkbox / Button at the input layer. Surrounding chrome
(VRow / VCol illustration column, VCard, VAlert reset-success banner,
auth-logo link, MfaChallengeCard) stays Vuetify until F4b migrates
the auth surface in full.

Zod schema:
- email: required, valid email format
- password: required

Both messages are Dutch (per F3 sprint plan convention).

422 error handling routes through useFormError() from B5. The Laravel
response shape (errors.<field>: string[]) feeds applyApiErrors directly.
rate_limited and other reason-only failures are synthesized into the
email field's error map so they surface visually under the email input,
preserving the existing UX.

The remember-me checkbox is rendered with PrimeVue Checkbox (no schema
coverage — it's UI state, not validated input). The password visibility
toggle is delegated to PrimeVue's Password component's built-in
toggle-mask prop (replaces the previous manual isPasswordVisible ref
and append-inner-icon plumbing).

Verification:
- pnpm typecheck — clean.
- pnpm test — 402 tests pass unchanged.
- pnpm build — succeeds; login chunk grew from ~21 KB to ~84 KB raw
  due to @primevue/forms + Password/Checkbox component code (gzip 22 KB).
  Will normalize during F4 as more pages share these modules.
- Manual browser test deferred to Phase C brand-review screenshot
  capture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 01:14:15 +02:00
parent 4391550140
commit ad82110a69

View File

@@ -1,5 +1,11 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { Form } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import { z } from 'zod'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import { useGenerateImageVariant } from '@core/composable/useGenerateImageVariant'
import authV2LoginIllustrationBorderedDark from '@images/pages/auth-v2-login-illustration-bordered-dark.png'
import authV2LoginIllustrationBorderedLight from '@images/pages/auth-v2-login-illustration-bordered-light.png'
@@ -10,7 +16,8 @@ import authV2MaskLight from '@images/pages/misc-mask-light.png'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { useAuthStore } from '@/stores/useAuthStore'
import { emailValidator, requiredValidator } from '@core/utils/validators'
import { useFormError } from '@/composables/useFormError'
import FormField from '@/components/forms/FormField.vue'
import { resolvePostLoginTarget as resolvePostLoginPath } from '@/utils/postLoginRedirect'
import type { LoginCredentials } from '@/types/auth'
import type { MfaMethod } from '@/types/mfa'
@@ -28,15 +35,7 @@ const authStore = useAuthStore()
const passwordResetDone = computed(() => route.query.reset === '1')
const form = ref<LoginCredentials & { remember: boolean }>({
email: '',
password: '',
remember: false,
})
const isPasswordVisible = ref(false)
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const remember = ref(false)
const isPending = ref(false)
// MFA challenge state
@@ -51,24 +50,39 @@ const authThemeImg = useGenerateImageVariant(
authV2LoginIllustrationDark,
authV2LoginIllustrationBorderedLight,
authV2LoginIllustrationBorderedDark,
true)
true,
)
const authThemeMask = useGenerateImageVariant(authV2MaskLight, authV2MaskDark)
const loginSchema = z.object({
email: z
.string()
.min(1, 'Vul je e-mailadres in')
.email('Geen geldig e-mailadres'),
password: z.string().min(1, 'Vul je wachtwoord in'),
})
const resolver = zodResolver(loginSchema)
const { applyApiErrors, clearApiErrors } = useFormError()
function resolvePostLoginTarget(): string {
const rawTo = route.query.to ? String(route.query.to) : ''
return resolvePostLoginPath(rawTo, () => authStore.resolveLandingRoute())
}
async function handleLogin() {
errors.value = {}
async function onSubmit({ valid, values }: { valid: boolean; values: Record<string, unknown> }) {
if (!valid)
return
clearApiErrors()
isPending.value = true
try {
const credentials: LoginCredentials = {
email: form.value.email,
password: form.value.password,
email: String(values.email ?? ''),
password: String(values.password ?? ''),
}
const result = await authStore.login(credentials)
@@ -94,25 +108,19 @@ async function handleLogin() {
return
case 'must-set-password':
// Forward-compatible — backend doesn't surface this kind today.
// Placeholder route until the must-set-password flow is wired.
router.replace('/account-settings?tab=security')
return
case 'failed':
if (result.errors) {
errors.value = {
email: result.errors.email?.[0] ?? '',
password: result.errors.password?.[0] ?? '',
}
}
else if (result.reason === 'rate_limited') {
errors.value = { email: 'Te veel pogingen. Wacht even en probeer opnieuw.' }
}
else {
errors.value = { email: result.reason || 'Er is een fout opgetreden. Probeer het opnieuw.' }
}
if (result.errors)
applyApiErrors(result.errors)
else if (result.reason === 'rate_limited')
applyApiErrors({ email: ['Te veel pogingen. Wacht even en probeer opnieuw.'] })
else
applyApiErrors({ email: [result.reason || 'Er is een fout opgetreden. Probeer het opnieuw.'] })
}
}
finally {
@@ -121,8 +129,6 @@ async function handleLogin() {
}
function onMfaVerified() {
// useAuthStore.verifyMfa() already refreshed the store post-cookie;
// the page just needs to route to the resolved landing target.
router.replace(resolvePostLoginTarget())
}
@@ -130,14 +136,6 @@ function onMfaCancelled() {
showMfaChallenge.value = false
mfaSessionToken.value = ''
}
function onSubmit() {
refVForm.value?.validate()
.then(({ valid: isValid }) => {
if (isValid)
handleLogin()
})
}
</script>
<template>
@@ -226,68 +224,73 @@ function onSubmit() {
Wachtwoord gewijzigd. Je kunt nu inloggen.
</VAlert>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
<Form
:resolver="resolver"
class="flex flex-col gap-2"
@submit="onSubmit"
>
<VRow>
<VCol cols="12">
<AppTextField
v-model="form.email"
autofocus
label="E-mailadres"
type="email"
placeholder="naam@voorbeeld.nl"
:rules="[requiredValidator, emailValidator]"
:error-messages="errors.email"
<FormField
name="email"
label="E-mailadres"
required
>
<InputText
name="email"
type="email"
autocomplete="email"
autofocus
placeholder="naam@voorbeeld.nl"
class="w-full"
/>
</FormField>
<FormField
name="password"
label="Wachtwoord"
required
>
<Password
name="password"
:feedback="false"
toggle-mask
autocomplete="current-password"
placeholder="············"
input-class="w-full"
class="w-full"
/>
</FormField>
<div class="my-2 flex items-center justify-between">
<div class="flex items-center gap-2">
<Checkbox
v-model="remember"
input-id="remember"
:binary="true"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="form.password"
label="Wachtwoord"
placeholder="············"
:type="isPasswordVisible ? 'text' : 'password'"
autocomplete="current-password"
:rules="[requiredValidator]"
:error-messages="errors.password"
:append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
<div class="d-flex align-center flex-wrap justify-space-between my-6">
<VCheckbox
v-model="form.remember"
label="Onthoud mij"
/>
<RouterLink
class="text-primary ms-2 mb-1"
:to="{ name: 'forgot-password' }"
>
Wachtwoord vergeten?
</RouterLink>
</div>
<VBtn
block
type="submit"
:loading="isPending"
>
Inloggen
</VBtn>
</VCol>
<VCol
cols="12"
class="text-body-1 text-center"
<label
for="remember"
class="text-sm"
>Onthoud mij</label>
</div>
<RouterLink
class="text-sm text-primary-500 hover:text-primary-600"
:to="{ name: 'forgot-password' }"
>
<span class="d-inline-block text-medium-emphasis">
Nog geen account? Neem contact op met je organisatie.
</span>
</VCol>
</VRow>
</VForm>
Wachtwoord vergeten?
</RouterLink>
</div>
<Button
type="submit"
label="Inloggen"
:loading="isPending"
class="w-full"
/>
<p class="mt-4 text-center text-sm text-surface-600">
Nog geen account? Neem contact op met je organisatie.
</p>
</Form>
</VCardText>
</VCard>
</VCol>