fix(auth): guard stale submissions and rework form error mapping (#2358)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-02 17:26:18 +02:00
1 parent f703969e80
commit 25f4332b9e
43 files changed
+739 -683

No files matched your search

@@ -83,7 +83,7 @@ const EmailRevertPage = observer(function EmailRevertPage() {
label={i18n._(NEW_PASSWORD_DESCRIPTOR)}
value={form.getValue('password')}
onChange={(value) => form.setValue('password', value)}
error={form.getError('password') || fieldErrors?.password}
error={form.getError('password') || fieldErrors?.get('password')}
data-flx="auth.email-revert-page.form-field.set-value.password"
/>
<FormField
@@ -29,13 +29,19 @@ const ForgotPasswordPage = observer(function ForgotPasswordPage() {
useFluxerDocumentTitle(i18n._(FORGOT_PASSWORD_DESCRIPTOR));
const form = useForm({
initialValues: {email: ''},
onSubmit: async (values) => {
onSubmit: async (submission) => {
setError(null);
try {
await AuthenticationCommands.forgotPassword(values.email);
await AuthenticationCommands.forgotPassword(submission.getValue('email'));
if (!submission.isCurrent()) {
return;
}
setIsSuccess(true);
} catch (_err) {
form.setErrors({email: 'Failed to send reset link. Try again.'});
} catch {
if (!submission.isCurrent()) {
return;
}
form.setError('email', 'Failed to send reset link. Try again.');
}
},
});
@@ -32,6 +32,24 @@ const CONFIRM_NEW_PASSWORD_DESCRIPTOR = msg({
type TokenStatus = 'validating' | 'valid' | 'invalid';
const API_RENDERED_FIELDS = new Set(['password']);
function resolveBannerError(
error: string | null,
fieldErrors: ReadonlyMap<string, string> | null | undefined,
): string | null {
if (error != null) {
return error;
}
if (fieldErrors == null) {
return null;
}
for (const [fieldName, message] of fieldErrors) {
if (!API_RENDERED_FIELDS.has(fieldName)) {
return message;
}
}
return null;
}
const ResetPasswordPage = observer(function ResetPasswordPage() {
const {i18n} = useLingui();
const passwordId = useId();
@@ -86,12 +104,7 @@ const ResetPasswordPage = observer(function ResetPasswordPage() {
cancelled = true;
};
}, [token]);
const unrenderedFieldErrors = fieldErrors
? Object.entries(fieldErrors)
.filter(([field]) => !API_RENDERED_FIELDS.has(field))
.map(([, message]) => message)
: [];
const bannerError = error ?? unrenderedFieldErrors[0] ?? null;
const bannerError = resolveBannerError(error, fieldErrors);
if (tokenStatus === 'validating') {
return (
<>
@@ -144,7 +157,7 @@ const ResetPasswordPage = observer(function ResetPasswordPage() {
label={i18n._(NEW_PASSWORD_DESCRIPTOR)}
value={form.getValue('password')}
onChange={(value) => form.setValue('password', value)}
error={form.getError('password') || fieldErrors?.password}
error={form.getError('password') || fieldErrors?.get('password')}
data-flx="auth.reset-password-page.form-field.set-value.password"
/>
<FormField
@@ -228,7 +228,7 @@ export const AuthMinimalRegisterFormCore = observer(function AuthMinimalRegister
placeholder={i18n._(WHAT_SHOULD_PEOPLE_CALL_YOU_DESCRIPTOR)}
value={globalNameValue}
onChange={(value) => setDraftedFormValue('global_name', value)}
error={form.getError('global_name') || fieldErrors?.global_name}
error={form.getError('global_name') || fieldErrors?.get('global_name')}
data-flx="auth.flow.auth-minimal-register-form-core.form-field.set-drafted-form-value.text"
/>
{collectDateOfBirth ? (
@@ -239,7 +239,7 @@ export const AuthMinimalRegisterFormCore = observer(function AuthMinimalRegister
onMonthChange={handleMonthChange}
onDayChange={handleDayChange}
onYearChange={handleYearChange}
error={fieldErrors?.date_of_birth}
error={fieldErrors?.get('date_of_birth')}
data-flx="auth.flow.auth-minimal-register-form-core.date-of-birth-field"
/>
) : null}
@@ -331,7 +331,7 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
label={i18n._(EMAIL_DESCRIPTOR)}
value={form.getValue('email')}
onChange={(value) => setDraftedFormValue('email', value)}
error={form.getError('email') || fieldErrors?.email}
error={form.getError('email') || fieldErrors?.get('email')}
data-flx="auth.flow.auth-register-form-core.form-field.set-drafted-form-value.email"
/>
)}
@@ -343,7 +343,7 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
placeholder={i18n._(WHAT_SHOULD_PEOPLE_CALL_YOU_DESCRIPTOR)}
value={form.getValue('global_name')}
onChange={(value) => setDraftedFormValue('global_name', value)}
error={form.getError('global_name') || fieldErrors?.global_name}
error={form.getError('global_name') || fieldErrors?.get('global_name')}
data-flx="auth.flow.auth-register-form-core.form-field.set-drafted-form-value.text"
/>
<div data-flx="auth.flow.auth-register-form-core.div">
@@ -356,7 +356,7 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
placeholder={i18n._(LEAVE_BLANK_FOR_A_RANDOM_USERNAME_DESCRIPTOR)}
value={usernameValue}
onChange={(value) => setDraftedFormValue('username', value)}
error={form.getError('username') || fieldErrors?.username}
error={form.getError('username') || fieldErrors?.get('username')}
data-flx="auth.flow.auth-register-form-core.form-field.set-drafted-form-value.text--2"
/>
<AnimatePresence mode="wait" initial={false} data-flx="auth.flow.auth-register-form-core.animate-presence">
@@ -406,7 +406,7 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
label={i18n._(PASSWORD_DESCRIPTOR)}
value={form.getValue('password')}
onChange={(value) => setDraftedFormValue('password', value)}
error={form.getError('password') || fieldErrors?.password}
error={form.getError('password') || fieldErrors?.get('password')}
data-flx="auth.flow.auth-register-form-core.form-field.set-drafted-form-value.password"
/>
)}
@@ -432,7 +432,7 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
onMonthChange={handleMonthChange}
onDayChange={handleDayChange}
onYearChange={handleYearChange}
error={fieldErrors?.date_of_birth}
error={fieldErrors?.get('date_of_birth')}
data-flx="auth.flow.auth-register-form-core.date-of-birth-field"
/>
) : null}
@@ -89,7 +89,7 @@ const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps)
label={i18n._(CODE_DESCRIPTOR)}
value={form.getValue('code')}
onChange={(value) => form.setValue('code', value)}
error={form.getError('code') || fieldErrors?.code}
error={form.getError('code') || fieldErrors?.get('code')}
data-flx="auth.flow.mfa-screen.form-field.set-value.text"
/>
<Button
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react/macro';
import type React from 'react';
import {useId} from 'react';
type FieldErrors = Record<string, string | undefined> | null | undefined;
type FieldErrors = ReadonlyMap<string, string> | null | undefined;
export interface AuthFormControllerLike {
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
@@ -71,7 +71,7 @@ export default function AuthLoginEmailPasswordForm({
label={i18n._(EMAIL_DESCRIPTOR)}
value={form.getValue('email')}
onChange={(value) => form.setValue('email', value)}
error={form.getError('email') || fieldErrors?.email}
error={form.getError('email') || fieldErrors?.get('email')}
data-flx="auth.flow.auth-login-core.auth-login-email-password-form.form-field.set-value.email"
/>
<FormField
@@ -84,7 +84,7 @@ export default function AuthLoginEmailPasswordForm({
label={i18n._(PASSWORD_DESCRIPTOR)}
value={form.getValue('password')}
onChange={(value) => form.setValue('password', value)}
error={form.getError('password') || fieldErrors?.password}
error={form.getError('password') || fieldErrors?.get('password')}
data-flx="auth.flow.auth-login-core.auth-login-email-password-form.form-field.set-value.password"
/>
{extraFields}
@@ -1,15 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type UseFormReturn, useForm} from '@app/features/app/hooks/useForm';
import {type FormSubmission, type UseFormReturn, useForm} from '@app/features/app/hooks/useForm';
import {CaptchaCancelledError, CaptchaValidationError} from '@app/features/auth/hooks/useCaptcha';
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
import {HttpError} from '@app/features/platform/types/EndpointError';
import type {RestResponse} from '@app/features/platform/types/TransportTypes';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {failureMessage, failureValidationErrors} from '@app/features/platform/utils/ResponseInspection';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {useEffect, useState} from 'react';
import {useState} from 'react';
const AN_UNEXPECTED_ERROR_OCCURRED_DESCRIPTOR = msg({
message: 'An unexpected error occurred',
@@ -25,49 +24,74 @@ interface UseAuthFormOptions {
firstFieldName?: string;
}
interface ValidationError {
path: string;
message: string;
interface ApplyAuthFormErrorsRequest {
error: unknown;
form: UseFormReturn;
i18n: I18n;
firstFieldName: string | undefined;
setError: (error: string | null) => void;
setFieldErrors: (errors: ReadonlyMap<string, string> | null) => void;
}
interface APIErrorResponse {
code: string;
message: string;
errors?: Array<ValidationError>;
}
const isRestResponse = (value: unknown): value is RestResponse<unknown> =>
typeof value === 'object' && value !== null && 'ok' in value && 'status' in value && 'body' in value;
const getErrorData = (error: unknown): APIErrorResponse | undefined => {
if (error instanceof HttpError) {
return error.body as APIErrorResponse | undefined;
const collectSubmittedValues = (
submission: FormSubmission,
initialValues: Record<string, string>,
): Record<string, string> => {
const values: Record<string, string> = {};
for (const fieldName of Object.keys(initialValues)) {
values[fieldName] = submission.getValue(fieldName);
}
if (isRestResponse(error)) {
return error.body as APIErrorResponse | undefined;
return values;
};
const collectFieldErrors = (
violations: ReadonlyArray<{path: string; message: string}>,
): ReadonlyMap<string, string> => {
const fieldErrors = new Map<string, string>();
for (const {path, message} of violations) {
const existingMessage = fieldErrors.get(path);
fieldErrors.set(path, existingMessage ? `${existingMessage} ${message}` : message);
}
if (typeof error === 'object' && error !== null && 'body' in error) {
return (
error as {
body?: APIErrorResponse;
}
).body;
return fieldErrors;
};
const applyAuthFormErrors = ({
error,
form,
i18n,
firstFieldName,
setError,
setFieldErrors,
}: ApplyAuthFormErrorsRequest): void => {
const fieldViolations = failureValidationErrors(error) ?? [];
if (fieldViolations.length > 0) {
const fieldErrors = collectFieldErrors(fieldViolations);
setFieldErrors(fieldErrors);
form.setErrors(fieldErrors);
return;
}
return undefined;
const message = getAuthErrorMessage(error, i18n);
if (!firstFieldName) {
setError(message);
return;
}
const fieldErrors = new Map([[firstFieldName, message]]);
setFieldErrors(fieldErrors);
form.setErrors(fieldErrors);
};
export function useAuthForm({initialValues, onSubmit, redirectPath, firstFieldName}: UseAuthFormOptions) {
const {i18n} = useLingui();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string> | null>(null);
const [fieldErrors, setFieldErrors] = useState<ReadonlyMap<string, string> | null>(null);
const form = useForm({
initialValues,
onSubmit: async (values) => {
setIsLoading(true);
onSubmit: async (submission) => {
setError(null);
setFieldErrors(null);
try {
const shouldRedirect = await onSubmit(values);
const shouldRedirect = await onSubmit(collectSubmittedValues(submission, initialValues));
if (!submission.isCurrent()) {
return;
}
if (shouldRedirect !== false && redirectPath) {
RouterUtils.replaceWith(redirectPath);
}
@@ -78,57 +102,31 @@ export function useAuthForm({initialValues, onSubmit, redirectPath, firstFieldNa
if (err instanceof CaptchaValidationError) {
return;
}
extractErrors(err, setError, setFieldErrors, form, i18n, firstFieldName);
} finally {
setIsLoading(false);
if (!submission.isCurrent()) {
return;
}
applyAuthFormErrors({error: err, form, i18n, firstFieldName, setError, setFieldErrors});
}
},
});
useEffect(() => {
setError(null);
setFieldErrors(null);
}, []);
return {
form,
isLoading,
isLoading: form.isSubmitting,
error,
fieldErrors,
};
}
export const getAuthErrorMessage = (error: unknown, i18n?: I18n): string => {
const errorData = getErrorData(error);
const unexpected = i18n ? i18n._(AN_UNEXPECTED_ERROR_OCCURRED_DESCRIPTOR) : 'An unexpected error occurred';
const fallbackMessage = error instanceof Error ? error.message : unexpected;
return errorData?.message || fallbackMessage;
};
const extractErrors = (
error: unknown,
setError: (error: string | null) => void,
setFieldErrors: (errors: Record<string, string> | null) => void,
form: UseFormReturn,
i18n: I18n,
firstFieldName?: string,
) => {
const errorData = getErrorData(error);
if (errorData?.code === APIErrorCodes.INVALID_FORM_BODY && errorData.errors?.length) {
const fieldErrors = errorData.errors.reduce(
(acc, {path, message}) => {
acc[path] = acc[path] ? `${acc[path]} ${message}` : message;
return acc;
},
{} as Record<string, string>,
);
setFieldErrors(fieldErrors);
form.setErrors(fieldErrors);
return;
export const getAuthErrorMessage = (error: unknown, i18n: I18n): string => {
const message = failureMessage(error);
if (message) {
return message;
}
const message = getAuthErrorMessage(error, i18n);
if (firstFieldName) {
const fieldErrors = {[firstFieldName]: message};
setFieldErrors(fieldErrors);
form.setErrors(fieldErrors);
} else {
setError(message);
if (error instanceof HttpError) {
return i18n._(AN_UNEXPECTED_ERROR_OCCURRED_DESCRIPTOR);
}
if (error instanceof Error) {
return error.message;
}
return i18n._(AN_UNEXPECTED_ERROR_OCCURRED_DESCRIPTOR);
};