feat: surface API validation errors to registration form fields (#1902)

This PR surfaces API validation errors from the registration endpoint
directly onto the corresponding form fields, instead of only showing a
generic "invalid data" message. A new `parseValidationErrors` helper
extracts field names and messages from the API's `invalid_fields` array
(e.g. `["email: email is not a valid email address"]`) and maps them to
the appropriate form fields. The Register component integrates this
parser into its error handling, prioritizing client-side validation but
falling back to server-side field errors when present. Errors are
cleared as the user types.

A follow-up commit addressed PR review feedback: the `ValidationError`
interface is now exported from the parser module and reused in
`Register.vue` (eliminating a duplicate `ApiValidationError` interface),
the type guard was tightened to check specifically for `invalid_fields`
rather than broadly matching any object with a `message` property, the
fallback error message always uses the localized translation key instead
of potentially surfacing raw backend messages, and the
`serverValidationErrors` ref uses `Partial<Record>` to accurately
reflect that keys are optional.

🐰 A parser hops through error fields,
Catching field names as each one yields,
Client and server now both agree,
Validation flows harmoniously free!
Whitespace trimmed, no colon? It hops along,
Register form now validated strong! 

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: kolaente <k@knt.li>
This commit is contained in:
Copilot
2026-03-03 14:27:24 +01:00
committed by GitHub
parent f34dab604c
commit c6f0d8babe
4 changed files with 216 additions and 7 deletions

View File

@@ -0,0 +1,96 @@
import {describe, it, expect} from 'vitest'
import {parseValidationErrors} from './parseValidationErrors'
describe('parseValidationErrors', () => {
it('returns empty object when no invalid_fields present', () => {
const error = {
message: 'invalid data',
code: 2002,
}
const result = parseValidationErrors(error)
expect(result).toEqual({})
})
it('parses single field error', () => {
const error = {
message: 'invalid data',
code: 2002,
invalid_fields: ['email: email is not a valid email address'],
}
const result = parseValidationErrors(error)
expect(result).toEqual({
email: 'email is not a valid email address',
})
})
it('parses multiple field errors', () => {
const error = {
message: 'invalid data',
code: 2002,
invalid_fields: [
'email: email is not a valid email address',
'username: username must not contain spaces',
],
}
const result = parseValidationErrors(error)
expect(result).toEqual({
email: 'email is not a valid email address',
username: 'username must not contain spaces',
})
})
it('handles fields without colon separator', () => {
const error = {
message: 'invalid data',
code: 2002,
invalid_fields: ['something went wrong'],
}
const result = parseValidationErrors(error)
// Fields without colon are ignored (can't map to specific field)
expect(result).toEqual({})
})
it('handles errors with whitespace around field names', () => {
const error = {
message: 'invalid data',
code: 2002,
invalid_fields: ['email : not a valid email'],
}
const result = parseValidationErrors(error)
expect(result).toEqual({
email: 'not a valid email',
})
})
it('returns empty object for null/undefined error', () => {
expect(parseValidationErrors(null)).toEqual({})
expect(parseValidationErrors(undefined)).toEqual({})
})
it('handles last occurrence when same field appears multiple times', () => {
const error = {
message: 'invalid data',
code: 2002,
invalid_fields: [
'email: first error',
'email: second error',
],
}
const result = parseValidationErrors(error)
expect(result).toEqual({
email: 'second error',
})
})
})

View File

@@ -0,0 +1,44 @@
export interface ValidationError {
message?: string
code?: number
invalid_fields?: string[]
}
/**
* Parses validation errors from API responses into a field-to-error map.
* Extracts field names and messages from the invalid_fields array.
*
* @param error - The error object from API response
* @returns Object mapping field names to error messages
*
* @example
* // Returns: { email: "email is not a valid email address" }
* parseValidationErrors({
* message: 'invalid data',
* invalid_fields: ['email: email is not a valid email address']
* })
*/
export function parseValidationErrors(error: ValidationError | null | undefined): Record<string, string> {
if (!error || !error.invalid_fields || error.invalid_fields.length === 0) {
return {}
}
const fieldErrors: Record<string, string> = {}
for (const fieldError of error.invalid_fields) {
// Split on first colon to separate field name from message
const colonIndex = fieldError.indexOf(':')
if (colonIndex === -1) {
// No field prefix, can't map to a specific field, skip it
continue
}
// Extract field name and error message
const fieldName = fieldError.substring(0, colonIndex).trim()
const errorMessage = fieldError.substring(colonIndex + 1).trim()
fieldErrors[fieldName] = errorMessage
}
return fieldErrors
}

View File

@@ -68,7 +68,8 @@
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in",
"registrationDisabled": "Registration is disabled.",
"passwordResetTokenMissing": "Password reset token is missing."
"passwordResetTokenMissing": "Password reset token is missing.",
"registrationFailed": "An error occurred during registration. Please check your input and try again."
},
"settings": {
"title": "Settings",

View File

@@ -21,10 +21,10 @@
required
type="text"
autocomplete="username"
:error="usernameValid !== true ? usernameValid : null"
:error="usernameError"
@keyup.enter="submit"
@focusout="validateUsername(); validateUsernameAfterFirst = true"
@keyup="validateUsernameAfterFirst && validateUsername()"
@keyup="handleUsernameKeyup"
/>
<FormField
id="email"
@@ -34,11 +34,11 @@
:placeholder="$t('user.auth.emailPlaceholder')"
required
type="email"
:error="emailValid ? null : $t('user.auth.emailInvalid')"
:error="emailError"
autocomplete="email"
@keyup.enter="submit"
@focusout="validateEmail(); validateEmailAfterFirst = true"
@keyup="validateEmailAfterFirst && validateEmail()"
@keyup="handleEmailKeyup"
/>
<div class="field">
<label
@@ -50,6 +50,12 @@
@submit="submit"
@update:modelValue="v => credentials.password = v"
/>
<p
v-if="passwordError"
class="help is-danger"
>
{{ passwordError }}
</p>
</div>
<XButton
@@ -98,6 +104,7 @@ import Message from '@/components/misc/Message.vue'
import {isEmail} from '@/helpers/isEmail'
import Password from '@/components/input/Password.vue'
import FormField from '@/components/input/FormField.vue'
import {parseValidationErrors, type ValidationError} from '@/helpers/parseValidationErrors'
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
import {useAuthStore} from '@/stores/auth'
@@ -126,6 +133,7 @@ const credentials = reactive({
const isLoading = computed(() => authStore.isLoading)
const errorMessage = ref('')
const validatePasswordInitially = ref(false)
const serverValidationErrors = ref<Partial<Record<string, string>>>({})
const DEBOUNCE_TIME = 100
@@ -165,8 +173,52 @@ const everythingValid = computed(() => {
usernameValid.value === true
})
const usernameError = computed(() => {
// Client-side validation takes priority
if (usernameValid.value !== true) {
return usernameValid.value
}
// Show server-side error if present
return serverValidationErrors.value.username || null
})
const emailError = computed(() => {
// Client-side validation takes priority
if (!emailValid.value) {
return t('user.auth.emailInvalid')
}
// Show server-side error if present
return serverValidationErrors.value.email || null
})
const passwordError = computed(() => {
// Show server-side error if present
return serverValidationErrors.value.password || null
})
function handleUsernameKeyup() {
if (validateUsernameAfterFirst.value) {
validateUsername()
}
delete serverValidationErrors.value.username
}
function handleEmailKeyup() {
if (validateEmailAfterFirst.value) {
validateEmail()
}
delete serverValidationErrors.value.email
}
function isApiValidationError(error: unknown): error is ValidationError {
return error !== null &&
typeof error === 'object' &&
'invalid_fields' in error
}
async function submit() {
errorMessage.value = ''
serverValidationErrors.value = {}
validatePasswordInitially.value = true
if (!everythingValid.value) {
@@ -176,8 +228,24 @@ async function submit() {
try {
await authStore.register(toRaw(credentials))
redirectIfSaved()
} catch (e) {
errorMessage.value = e?.message
} catch (e: unknown) {
// Parse field-specific validation errors
if (isApiValidationError(e)) {
const fieldErrors = parseValidationErrors(e)
if (Object.keys(fieldErrors).length > 0) {
// Apply field-level errors (computed properties will display them)
serverValidationErrors.value = fieldErrors
} else {
// Fallback to general error message if no field errors
errorMessage.value = t('user.auth.registrationFailed')
}
} else if (e instanceof Object && 'message' in e && typeof e.message === 'string') {
// Non-validation backend errors (e.g. duplicate username) - show their message
errorMessage.value = e.message
} else {
errorMessage.value = t('user.auth.registrationFailed')
}
}
}
</script>