diff --git a/frontend/src/helpers/parseValidationErrors.test.ts b/frontend/src/helpers/parseValidationErrors.test.ts new file mode 100644 index 000000000..b1b2541a9 --- /dev/null +++ b/frontend/src/helpers/parseValidationErrors.test.ts @@ -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', + }) + }) +}) diff --git a/frontend/src/helpers/parseValidationErrors.ts b/frontend/src/helpers/parseValidationErrors.ts new file mode 100644 index 000000000..d32c5949f --- /dev/null +++ b/frontend/src/helpers/parseValidationErrors.ts @@ -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 { + if (!error || !error.invalid_fields || error.invalid_fields.length === 0) { + return {} + } + + const fieldErrors: Record = {} + + 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 +} diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index d6bd83afb..7c25671ae 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -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", diff --git a/frontend/src/views/user/Register.vue b/frontend/src/views/user/Register.vue index ebb41c008..3995bf96d 100644 --- a/frontend/src/views/user/Register.vue +++ b/frontend/src/views/user/Register.vue @@ -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" />
authStore.isLoading) const errorMessage = ref('') const validatePasswordInitially = ref(false) +const serverValidationErrors = ref>>({}) 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') + } } }