mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
fix(auth): guard stale submissions and rework form error mapping (#2358)
This commit is contained in:
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);
|
||||
};
|
||||
Reference in new issue
Block a user