From f1e8d303d6be01595c0b4ff5283ab4d2ca3060eb Mon Sep 17 00:00:00 2001 From: "sajid.mannikeri" Date: Thu, 9 Jul 2026 13:04:02 +0530 Subject: [PATCH] Fix input validation in react sdk Signed-off-by: sajid.mannikeri --- .../auth/Recovery/BaseRecovery.tsx | 21 +++++---- .../presentation/auth/SignIn/BaseSignIn.tsx | 44 +++++++++++++++++-- .../presentation/auth/SignUp/BaseSignUp.tsx | 15 ++++--- 3 files changed, 62 insertions(+), 18 deletions(-) diff --git a/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx b/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx index 95c6d8d..b21adb7 100644 --- a/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx +++ b/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx @@ -28,7 +28,7 @@ import { Preferences, FlowMetadataResponse, } from '@thunderid/browser'; -import {FC, ReactElement, ReactNode, useContext, useEffect, useState, useCallback, useRef} from 'react'; +import {FC, ReactElement, ReactNode, useContext, useEffect, useMemo, useState, useCallback, useRef} from 'react'; import ComponentRendererContext, { ComponentRendererMap, } from '../../../../contexts/ComponentRenderer/ComponentRendererContext'; @@ -226,7 +226,10 @@ const BaseRecoveryContent: FC = ({ [t], ); - const formFields: any = currentFlow?.data?.components ? extractFormFields(currentFlow.data.components) : []; + const formFields: any = useMemo( + () => (currentFlow?.data?.components ? extractFormFields(currentFlow.data.components) : []), + [currentFlow, extractFormFields], + ); const form: any = useForm>({ fields: formFields, @@ -243,6 +246,7 @@ const BaseRecoveryContent: FC = ({ isValid: isFormValid, setValue: setFormValue, setTouched: setFormTouched, + setTouchedFields, setErrors: setFormErrors, clearErrors: clearFormErrors, validateForm, @@ -250,10 +254,9 @@ const BaseRecoveryContent: FC = ({ reset: resetForm, } = form; - /** - * Project server-side validation errors from the most recent flow response into the - * form's `errors` state. See BaseSignIn for the same pattern. - */ + // Project server-side fieldErrors from the flow response into form state. + // `setTouchedFields` avoids re-running client-side validation that would wipe + // server errors when the client rules happen to pass. useEffect(() => { clearFormErrors(); const responseFieldErrors: FieldError[] | undefined = (currentFlow?.data as any)?.fieldErrors; @@ -261,14 +264,16 @@ const BaseRecoveryContent: FC = ({ return; } const errors: Record = {}; + const touched: Record = {}; for (const fe of responseFieldErrors) { if (!(fe.identifier in errors)) { errors[fe.identifier] = fe.message; + touched[fe.identifier] = true; } } + setTouchedFields(touched); setFormErrors(errors); - Object.keys(errors).forEach((field: string) => setFormTouched(field, true)); - }, [currentFlow, setFormErrors, setFormTouched, clearFormErrors]); + }, [currentFlow, setFormErrors, setTouchedFields, clearFormErrors]); const setupFormFields: any = useCallback( (flowResponse: EmbeddedRecoveryFlowResponse) => { diff --git a/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx index 6d7ce50..ace52b5 100644 --- a/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx @@ -24,8 +24,9 @@ import { FieldError, FlowMetadataResponse, Preferences, + buildValidatorFromRules, } from '@thunderid/browser'; -import {FC, useState, useCallback, useContext, ReactElement, ReactNode} from 'react'; +import {FC, useEffect, useMemo, useState, useCallback, useContext, ReactElement, ReactNode} from 'react'; import useStyles from './BaseSignIn.styles'; import ComponentRendererContext, { ComponentRendererMap, @@ -248,6 +249,7 @@ const BaseSignInContent: FC = ({ children, additionalData = {}, isTimeoutDisabled = false, + serverFieldErrors = null, }: BaseSignInProps): ReactElement => { const {meta} = useThunderID(); const {theme} = useTheme(); @@ -296,9 +298,12 @@ const BaseSignInContent: FC = ({ component.type === 'PASSWORD_INPUT' || component.type === 'EMAIL_INPUT' || component.type === 'PHONE_INPUT' || - component.type === 'OTP_INPUT' + component.type === 'OTP_INPUT' || + component.type === 'SELECT' || + component.type === 'DATE_INPUT' ) { const identifier: string = component.ref; + const ruleValidator = buildValidatorFromRules(component.validation); fields.push({ initialValue: '', name: identifier, @@ -315,6 +320,13 @@ const BaseSignInContent: FC = ({ ) { return t('field.email.invalid'); } + // Run declarative rules from meta.components[].validation. + if (ruleValidator && value) { + const ruleMessage = ruleValidator(value); + if (ruleMessage) { + return t(ruleMessage); + } + } return null; }, @@ -332,7 +344,10 @@ const BaseSignInContent: FC = ({ [t], ); - const formFields: FormField[] = components ? extractFormFields(components) : []; + const formFields: FormField[] = useMemo( + () => (components ? extractFormFields(components) : []), + [components, extractFormFields], + ); const form: ReturnType = useForm>({ fields: formFields, @@ -349,10 +364,33 @@ const BaseSignInContent: FC = ({ isValid: isFormValid, setValue: setFormValue, setTouched: setFormTouched, + setTouchedFields, + setErrors: setFormErrors, + clearErrors: clearFormErrors, validateForm, touchAllFields, } = form; + // Project server-side fieldErrors into form state. `setTouchedFields` is used instead + // of `setTouched` so client-side `validateField` doesn't wipe server errors when the + // client rules happen to pass. + useEffect(() => { + clearFormErrors(); + if (!serverFieldErrors || serverFieldErrors.length === 0) { + return; + } + const errors: Record = {}; + const touched: Record = {}; + for (const fe of serverFieldErrors) { + if (!(fe.identifier in errors)) { + errors[fe.identifier] = fe.message; + touched[fe.identifier] = true; + } + } + setTouchedFields(touched); + setFormErrors(errors); + }, [serverFieldErrors, setFormErrors, setTouchedFields, clearFormErrors]); + /** * Handle input value changes. * Only updates the value without marking as touched. diff --git a/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx index 4199750..e103f4a 100644 --- a/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx +++ b/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx @@ -475,6 +475,7 @@ const BaseSignUpContent: FC = ({ isValid: isFormValid, setValue: setFormValue, setTouched: setFormTouched, + setTouchedFields, setErrors: setFormErrors, clearErrors: clearFormErrors, validateForm, @@ -482,11 +483,9 @@ const BaseSignUpContent: FC = ({ reset: resetForm, } = form; - /** - * Project server-side validation errors from the most recent flow response into the - * form's `errors` state. See BaseSignIn for the same pattern: first error per field - * wins, and the affected fields are marked touched so the error renders immediately. - */ + // Project server-side fieldErrors from the flow response into form state. + // `setTouchedFields` avoids re-running client-side validation that would wipe + // server errors when the client rules happen to pass. useEffect(() => { clearFormErrors(); const responseFieldErrors: FieldError[] | undefined = (currentFlow?.data as any)?.fieldErrors; @@ -494,14 +493,16 @@ const BaseSignUpContent: FC = ({ return; } const errors: Record = {}; + const touched: Record = {}; for (const fe of responseFieldErrors) { if (!(fe.identifier in errors)) { errors[fe.identifier] = fe.message; + touched[fe.identifier] = true; } } + setTouchedFields(touched); setFormErrors(errors); - Object.keys(errors).forEach((field: string) => setFormTouched(field, true)); - }, [currentFlow, setFormErrors, setFormTouched, clearFormErrors]); + }, [currentFlow, setFormErrors, setTouchedFields, clearFormErrors]); /** * Setup form fields based on the current flow.