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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user