From cee37444f21587bc6c9af144bd91d57153d7ca56 Mon Sep 17 00:00:00 2001 From: Sajid Mannikeri Date: Thu, 14 May 2026 03:17:34 +0530 Subject: [PATCH] Add input validation support to flow components Signed-off-by: Sajid Mannikeri --- .changeset/input-validation-flow-inputs.md | 18 +++ packages/i18n/src/models/i18n.ts | 3 + packages/i18n/src/translations/en-US.ts | 3 + packages/i18n/src/translations/fr-FR.ts | 3 + packages/i18n/src/translations/hi-IN.ts | 3 + packages/i18n/src/translations/ja-JP.ts | 3 + packages/i18n/src/translations/pt-BR.ts | 3 + packages/i18n/src/translations/pt-PT.ts | 3 + packages/i18n/src/translations/si-LK.ts | 3 + packages/i18n/src/translations/ta-IN.ts | 3 + packages/i18n/src/translations/te-IN.ts | 3 + packages/javascript/src/index.ts | 8 ++ .../src/models/v2/embedded-flow-v2.ts | 78 +++++++++++++ .../__tests__/buildValidatorFromRules.test.ts | 73 ++++++++++++ .../__tests__/evaluateValidationRule.test.ts | 110 ++++++++++++++++++ .../src/utils/v2/buildValidatorFromRules.ts | 49 ++++++++ .../src/utils/v2/evaluateValidationRule.ts | 80 +++++++++++++ .../auth/AcceptInvite/v2/BaseAcceptInvite.tsx | 40 ++++++- .../presentation/auth/AuthOptionFactory.tsx | 24 ++++ .../auth/InviteUser/v2/BaseInviteUser.tsx | 50 ++++++-- .../auth/Recovery/v2/BaseRecovery.tsx | 35 +++++- .../auth/SignIn/v2/BaseSignIn.tsx | 62 +++++++++- .../presentation/auth/SignIn/v2/SignIn.tsx | 37 ++++++ .../auth/SignUp/v2/BaseSignUp.tsx | 36 +++++- 24 files changed, 714 insertions(+), 16 deletions(-) create mode 100644 .changeset/input-validation-flow-inputs.md create mode 100644 packages/javascript/src/utils/__tests__/buildValidatorFromRules.test.ts create mode 100644 packages/javascript/src/utils/__tests__/evaluateValidationRule.test.ts create mode 100644 packages/javascript/src/utils/v2/buildValidatorFromRules.ts create mode 100644 packages/javascript/src/utils/v2/evaluateValidationRule.ts diff --git a/.changeset/input-validation-flow-inputs.md b/.changeset/input-validation-flow-inputs.md new file mode 100644 index 000000000..f865700e9 --- /dev/null +++ b/.changeset/input-validation-flow-inputs.md @@ -0,0 +1,18 @@ +--- +"@asgardeo/javascript": minor +"@asgardeo/react": minor +"@asgardeo/i18n": minor +--- + +Add SDK support for declarative input validation rules on flow prompts. + +- New `ValidationRule` and `FieldError` types in `@asgardeo/javascript`. +- New framework-agnostic utilities `evaluateValidationRule` and `buildValidatorFromRules`. +- `EmbeddedFlowComponent.validation` and `EmbeddedFlowResponseData.fieldErrors` added to the v2 flow models. +- Wires client-side rule evaluation and server-side `fieldErrors` surfacing into `BaseSignIn`, `BaseSignUp`, `BaseAcceptInvite`, `BaseInviteUser`, `BaseRecovery`. +- Adds `fieldErrors` to `SignInRenderProps` so render-prop consumers can display server-side validation errors. +- Adds default validation message i18n keys (`validation.pattern.invalid`, `validation.minLength.invalid`, `validation.maxLength.invalid`) to all locale bundles. +- Adds `DATE_INPUT` as a recognised flow component: new `DateInput` member on `EmbeddedFlowComponentType`, new optional `dateFormat` field on `EmbeddedFlowComponent`, render case in `AuthOptionFactory` that uses the existing `DatePicker` primitive, and validator wiring in all five `Base*` flow components. Date format enforcement reuses the existing `regex` rule (no new rule type). +- Closes the `SELECT` validation gap in `BaseSignIn` for consistency with the other four `Base*` flow components. + +Refs asgardeo/thunder#2410. diff --git a/packages/i18n/src/models/i18n.ts b/packages/i18n/src/models/i18n.ts index a3f8340a6..a0d0b4be2 100644 --- a/packages/i18n/src/models/i18n.ts +++ b/packages/i18n/src/models/i18n.ts @@ -63,6 +63,9 @@ export interface I18nTranslations { /* Validation */ 'validations.required.field.error': string; + 'validation.pattern.invalid': string; + 'validation.minLength.invalid': string; + 'validation.maxLength.invalid': string; /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/en-US.ts b/packages/i18n/src/translations/en-US.ts index dd12f1a11..362cb0781 100644 --- a/packages/i18n/src/translations/en-US.ts +++ b/packages/i18n/src/translations/en-US.ts @@ -66,6 +66,9 @@ const translations: I18nTranslations = { /* Validation */ 'validations.required.field.error': 'This field is required', + 'validation.pattern.invalid': 'This value does not match the required format.', + 'validation.minLength.invalid': 'This value is too short.', + 'validation.maxLength.invalid': 'This value is too long.', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/fr-FR.ts b/packages/i18n/src/translations/fr-FR.ts index 0a1b190c5..53a7f4961 100644 --- a/packages/i18n/src/translations/fr-FR.ts +++ b/packages/i18n/src/translations/fr-FR.ts @@ -66,6 +66,9 @@ const translations: I18nTranslations = { /* Validation */ 'validations.required.field.error': 'Ce champ est obligatoire', + 'validation.pattern.invalid': "Cette valeur ne correspond pas au format requis.", + 'validation.minLength.invalid': 'Cette valeur est trop courte.', + 'validation.maxLength.invalid': 'Cette valeur est trop longue.', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/hi-IN.ts b/packages/i18n/src/translations/hi-IN.ts index 0a46a1d25..44033cd61 100644 --- a/packages/i18n/src/translations/hi-IN.ts +++ b/packages/i18n/src/translations/hi-IN.ts @@ -66,6 +66,9 @@ const translations: I18nTranslations = { /* Validation */ 'validations.required.field.error': 'यह फील्ड आवश्यक है', + 'validation.pattern.invalid': 'यह मान आवश्यक प्रारूप से मेल नहीं खाता।', + 'validation.minLength.invalid': 'यह मान बहुत छोटा है।', + 'validation.maxLength.invalid': 'यह मान बहुत लंबा है।', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/ja-JP.ts b/packages/i18n/src/translations/ja-JP.ts index 511b2abc6..c01682c3f 100644 --- a/packages/i18n/src/translations/ja-JP.ts +++ b/packages/i18n/src/translations/ja-JP.ts @@ -66,6 +66,9 @@ const translations: I18nTranslations = { /* Validation */ 'validations.required.field.error': 'この項目は必須です', + 'validation.pattern.invalid': 'この値は必要な形式と一致しません。', + 'validation.minLength.invalid': 'この値は短すぎます。', + 'validation.maxLength.invalid': 'この値は長すぎます。', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/pt-BR.ts b/packages/i18n/src/translations/pt-BR.ts index 3a233115b..38bb1e4db 100644 --- a/packages/i18n/src/translations/pt-BR.ts +++ b/packages/i18n/src/translations/pt-BR.ts @@ -66,6 +66,9 @@ const translations: I18nTranslations = { /* Validation */ 'validations.required.field.error': 'Este campo é obrigatório', + 'validation.pattern.invalid': 'Este valor não corresponde ao formato necessário.', + 'validation.minLength.invalid': 'Este valor é muito curto.', + 'validation.maxLength.invalid': 'Este valor é muito longo.', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/pt-PT.ts b/packages/i18n/src/translations/pt-PT.ts index da86b174f..df6cf1f08 100644 --- a/packages/i18n/src/translations/pt-PT.ts +++ b/packages/i18n/src/translations/pt-PT.ts @@ -66,6 +66,9 @@ const translations: I18nTranslations = { /* Validation */ 'validations.required.field.error': 'Este campo é obrigatório', + 'validation.pattern.invalid': 'Este valor não corresponde ao formato necessário.', + 'validation.minLength.invalid': 'Este valor é demasiado curto.', + 'validation.maxLength.invalid': 'Este valor é demasiado longo.', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/si-LK.ts b/packages/i18n/src/translations/si-LK.ts index 0b1b4103f..0c6bf36fa 100644 --- a/packages/i18n/src/translations/si-LK.ts +++ b/packages/i18n/src/translations/si-LK.ts @@ -66,6 +66,9 @@ const translations: I18nTranslations = { /* Validation */ 'validations.required.field.error': 'මෙම ක්ෂේත්‍රය අවශ්‍යයි', + 'validation.pattern.invalid': 'මෙම අගය අවශ්‍ය ආකෘතියට නොගැලපේ.', + 'validation.minLength.invalid': 'මෙම අගය ඉතා කෙටියි.', + 'validation.maxLength.invalid': 'මෙම අගය ඉතා දිගයි.', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/ta-IN.ts b/packages/i18n/src/translations/ta-IN.ts index f9a2d6351..0d46a70e5 100644 --- a/packages/i18n/src/translations/ta-IN.ts +++ b/packages/i18n/src/translations/ta-IN.ts @@ -66,6 +66,9 @@ const translations: I18nTranslations = { /* Validation */ 'validations.required.field.error': 'இந்த புலம் தேவை', + 'validation.pattern.invalid': 'இந்த மதிப்பு தேவையான வடிவத்துடன் பொருந்தவில்லை.', + 'validation.minLength.invalid': 'இந்த மதிப்பு மிகவும் குறுகியது.', + 'validation.maxLength.invalid': 'இந்த மதிப்பு மிகவும் நீளமானது.', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/te-IN.ts b/packages/i18n/src/translations/te-IN.ts index 509cbf2e9..4cdfd2b49 100644 --- a/packages/i18n/src/translations/te-IN.ts +++ b/packages/i18n/src/translations/te-IN.ts @@ -66,6 +66,9 @@ const translations: I18nTranslations = { /* Validation */ 'validations.required.field.error': 'ఈ ఫీల్డ్ అవసరం', + 'validation.pattern.invalid': 'ఈ విలువ అవసరమైన ఆకృతికి సరిపోలడం లేదు.', + 'validation.minLength.invalid': 'ఈ విలువ చాలా చిన్నది.', + 'validation.maxLength.invalid': 'ఈ విలువ చాలా పెద్దది.', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index ac913d7e7..95a0147f7 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -102,6 +102,9 @@ export type { ConsentDecisions as ConsentDecisionsV2, ConsentPurposeData as ConsentPurposeDataV2, ConsentPromptData as ConsentPromptDataV2, + ValidationRule as ValidationRuleV2, + ValidationRuleType as ValidationRuleTypeV2, + FieldError as FieldErrorV2, } from './models/v2/embedded-flow-v2'; export { EmbeddedSignInFlowStatus as EmbeddedSignInFlowStatusV2, @@ -255,6 +258,11 @@ export {default as removeTrailingSlash} from './utils/removeTrailingSlash'; export {default as resolveFieldType} from './utils/resolveFieldType'; export {default as resolveFieldName} from './utils/resolveFieldName'; export {default as resolveMeta} from './utils/v2/resolveMeta'; +export { + default as evaluateValidationRule, + DEFAULT_VALIDATION_MESSAGE_KEYS, +} from './utils/v2/evaluateValidationRule'; +export {default as buildValidatorFromRules} from './utils/v2/buildValidatorFromRules'; export {default as resolveFlowTemplateLiterals} from './utils/v2/resolveFlowTemplateLiterals'; export {default as countryCodeToFlagEmoji} from './utils/v2/countryCodeToFlagEmoji'; export {default as resolveLocaleDisplayName} from './utils/v2/resolveLocaleDisplayName'; diff --git a/packages/javascript/src/models/v2/embedded-flow-v2.ts b/packages/javascript/src/models/v2/embedded-flow-v2.ts index a479ae36d..560c530d6 100644 --- a/packages/javascript/src/models/v2/embedded-flow-v2.ts +++ b/packages/javascript/src/models/v2/embedded-flow-v2.ts @@ -50,6 +50,9 @@ export enum EmbeddedFlowComponentType { /** Copyable text display component that shows text with a copy-to-clipboard action */ CopyableText = 'COPYABLE_TEXT', + /** Date input field for selecting a calendar date */ + DateInput = 'DATE_INPUT', + /** Divider component for visual separation of content */ Divider = 'DIVIDER', @@ -242,6 +245,13 @@ export interface EmbeddedFlowComponent { */ components?: EmbeddedFlowComponent[]; + /** + * Display format hint for DateInput components (e.g., 'yyyy-MM-dd'). Used as the + * placeholder rendered by the date picker primitive. Pattern-level validation is + * declared separately via a `regex` rule in the `validation` array. + */ + dateFormat?: string; + /** * Layout direction for Stack components ('row' | 'column'). */ @@ -350,6 +360,13 @@ export interface EmbeddedFlowComponent { */ variant?: EmbeddedFlowActionVariant | EmbeddedFlowTextVariant | string; + /** + * Declarative validation rules for input components. Evaluated client-side by the SDK + * (best-effort UX) before submission, and authoritatively re-evaluated server-side. + * Each rule represents exactly one constraint. + */ + validation?: ValidationRule[]; + /** * Width of the component (for Image components, can be string with units or number for pixels). * The value depends on the component type (e.g., for Image components). @@ -357,6 +374,55 @@ export interface EmbeddedFlowComponent { width?: string | number; } +/** + * Supported validation rule types for `ValidationRule.type`. + * + * - `regex`: value must be a string regex pattern; the input must match. + * - `minLength`: value must be a number; input length must be >= value. + * - `maxLength`: value must be a number; input length must be <= value. + * + * @experimental Additional rule types (`oneOf`, `format`, ...) may be added later. + */ +export type ValidationRuleType = 'regex' | 'minLength' | 'maxLength'; + +/** + * A single-constraint validation rule attached to an input component. + * Mirrors the server-side `ValidationRule` returned by Thunder. + * + * @experimental This interface may change in future versions + */ +export interface ValidationRule { + /** + * The constraint kind. Drives interpretation of `value` and the default fallback message. + */ + type: ValidationRuleType; + + /** + * The constraint parameter. String for `regex`, number for `minLength` / `maxLength`. + */ + value: string | number; + + /** + * Optional message returned when this rule fails. May be an i18n key (e.g. + * `"{{i18n(validation:email.invalid)}}"`) or a literal string. The server passes + * this through unchanged; the SDK substitutes a default i18n key when omitted. + */ + message?: string; +} + +/** + * A single validation failure for a specific input field returned by the server in + * `data.fieldErrors` when one or more rules fail. + * + * @experimental This interface may change in future versions + */ +export interface FieldError { + /** The `identifier` of the input that failed validation. */ + identifier: string; + /** The failing rule's message (i18n key or literal string). */ + message: string; +} + /** * Response data structure for embedded flow API. * @@ -434,6 +500,16 @@ export interface EmbeddedFlowResponseData { */ additionalData?: Record; + /** + * Per-field validation errors returned by the server when a submission fails one or + * more `validation` rules. Multiple failing rules on the same field appear as + * multiple entries, in the order the rules were declared. + * + * Present only on `INCOMPLETE` responses caused by validation failures; absent on + * successful submissions and on `INCOMPLETE` responses caused by missing required fields. + */ + fieldErrors?: FieldError[]; + /** * Legacy input definitions for backward compatibility. * @deprecated Use meta.components for new implementations @@ -447,6 +523,8 @@ export interface EmbeddedFlowResponseData { required: boolean; /** Input type (TEXT_INPUT, PASSWORD_INPUT, etc.) */ type: string; + /** Server-side validation rules for the input (also returned for API-only customers). */ + validation?: ValidationRule[]; }[]; /** diff --git a/packages/javascript/src/utils/__tests__/buildValidatorFromRules.test.ts b/packages/javascript/src/utils/__tests__/buildValidatorFromRules.test.ts new file mode 100644 index 000000000..ce7c0c9a4 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/buildValidatorFromRules.test.ts @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {describe, it, expect} from 'vitest'; +import buildValidatorFromRules from '../v2/buildValidatorFromRules'; + +describe('buildValidatorFromRules', () => { + it('returns null when no rules are provided', () => { + expect(buildValidatorFromRules(undefined)).toBeNull(); + expect(buildValidatorFromRules([])).toBeNull(); + }); + + it('composes a validator that returns null when all rules pass', () => { + const validate = buildValidatorFromRules([ + {type: 'minLength', value: 3, message: 'short'}, + {type: 'maxLength', value: 10, message: 'long'}, + ]); + expect(validate).not.toBeNull(); + expect(validate!('hello')).toBeNull(); + }); + + it('returns the first failing rule message — first failure wins', () => { + const validate = buildValidatorFromRules([ + {type: 'minLength', value: 8, message: 'too short'}, + {type: 'regex', value: '[0-9]', message: 'must contain a digit'}, + ]); + // "abc" fails BOTH rules. We expect the first one's message. + expect(validate!('abc')).toBe('too short'); + }); + + it('skips rules that pass and reports the first failing one', () => { + const validate = buildValidatorFromRules([ + {type: 'minLength', value: 3, message: 'too short'}, + {type: 'regex', value: '[0-9]', message: 'no digit'}, + {type: 'maxLength', value: 20, message: 'too long'}, + ]); + // "hello" passes minLength and maxLength but fails the regex. + expect(validate!('hello')).toBe('no digit'); + }); + + it('enforces a date format via a regex rule (DATE_INPUT use case)', () => { + const validate = buildValidatorFromRules([ + {type: 'regex', value: '^\\d{4}-\\d{2}-\\d{2}$', message: 'validation.dateFormat.invalid'}, + ]); + expect(validate!('1990-01-15')).toBeNull(); + expect(validate!('2026/02/30')).toBe('validation.dateFormat.invalid'); + expect(validate!('not-a-date')).toBe('validation.dateFormat.invalid'); + }); + + it('returns a function with a FormField.validator-compatible signature', () => { + // Sanity check: the validator returned matches the shape useForm expects + // (value: string) => string | null — so it can be plugged in directly. + const validate = buildValidatorFromRules([{type: 'minLength', value: 1, message: 'm'}]); + expect(typeof validate).toBe('function'); + expect(validate!('x')).toBeNull(); + expect(validate!('')).toBe('m'); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/evaluateValidationRule.test.ts b/packages/javascript/src/utils/__tests__/evaluateValidationRule.test.ts new file mode 100644 index 000000000..37dace02b --- /dev/null +++ b/packages/javascript/src/utils/__tests__/evaluateValidationRule.test.ts @@ -0,0 +1,110 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {describe, it, expect} from 'vitest'; +import evaluateValidationRule, {DEFAULT_VALIDATION_MESSAGE_KEYS} from '../v2/evaluateValidationRule'; + +describe('evaluateValidationRule', () => { + describe('regex', () => { + it('returns null when the value matches the pattern', () => { + expect( + evaluateValidationRule({type: 'regex', value: '^[a-z]+$', message: 'bad'}, 'hello'), + ).toBeNull(); + }); + + it('returns the rule message when the value does not match', () => { + expect( + evaluateValidationRule({type: 'regex', value: '^[a-z]+$', message: 'bad'}, 'Hello'), + ).toBe('bad'); + }); + + it('falls back to the default i18n key when no message is provided', () => { + expect(evaluateValidationRule({type: 'regex', value: '^X+$'}, 'abc')).toBe( + DEFAULT_VALIDATION_MESSAGE_KEYS.regex, + ); + }); + + it('treats an invalid regex pattern as passing (lenient client-side)', () => { + // `[` is an unterminated character class — RegExp constructor throws on this. + // We expect the rule to be skipped on the client; the server stays authoritative. + expect(evaluateValidationRule({type: 'regex', value: '[', message: 'bad'}, 'anything')).toBeNull(); + }); + + it('treats a non-string value as passing', () => { + expect(evaluateValidationRule({type: 'regex', value: 42 as any, message: 'bad'}, 'a')).toBeNull(); + }); + }); + + describe('minLength', () => { + it('returns null when the value length is at least value', () => { + expect( + evaluateValidationRule({type: 'minLength', value: 5, message: 'too short'}, '12345'), + ).toBeNull(); + expect( + evaluateValidationRule({type: 'minLength', value: 3, message: 'too short'}, 'abcd'), + ).toBeNull(); + }); + + it('returns the message when the value is shorter than required', () => { + expect( + evaluateValidationRule({type: 'minLength', value: 8, message: 'too short'}, 'abc'), + ).toBe('too short'); + }); + + it('falls back to the default i18n key when no message is provided', () => { + expect(evaluateValidationRule({type: 'minLength', value: 5}, 'a')).toBe( + DEFAULT_VALIDATION_MESSAGE_KEYS.minLength, + ); + }); + + it('treats a non-numeric value as passing', () => { + expect( + evaluateValidationRule({type: 'minLength', value: 'oops' as any, message: 'bad'}, 'a'), + ).toBeNull(); + }); + }); + + describe('maxLength', () => { + it('returns null when the value length is at or below value', () => { + expect( + evaluateValidationRule({type: 'maxLength', value: 5, message: 'too long'}, '12345'), + ).toBeNull(); + expect(evaluateValidationRule({type: 'maxLength', value: 5, message: 'too long'}, 'ab')).toBeNull(); + }); + + it('returns the message when the value exceeds the max', () => { + expect( + evaluateValidationRule({type: 'maxLength', value: 3, message: 'too long'}, 'abcdef'), + ).toBe('too long'); + }); + + it('falls back to the default i18n key when no message is provided', () => { + expect(evaluateValidationRule({type: 'maxLength', value: 2}, 'abcdef')).toBe( + DEFAULT_VALIDATION_MESSAGE_KEYS.maxLength, + ); + }); + }); + + describe('unknown rule types', () => { + it('returns null for forward-compatibility', () => { + expect( + evaluateValidationRule({type: 'unknown' as any, value: 'x', message: 'bad'}, 'anything'), + ).toBeNull(); + }); + }); +}); diff --git a/packages/javascript/src/utils/v2/buildValidatorFromRules.ts b/packages/javascript/src/utils/v2/buildValidatorFromRules.ts new file mode 100644 index 000000000..bd514fb5c --- /dev/null +++ b/packages/javascript/src/utils/v2/buildValidatorFromRules.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {ValidationRule} from '../../models/v2/embedded-flow-v2'; +import evaluateValidationRule from './evaluateValidationRule'; + +/** + * Composes an array of `ValidationRule`s into a single validator function suitable for + * `useForm`'s `FormField.validator` slot. + * + * The composed validator evaluates rules in declaration order and returns the **first** + * failing rule's message — matching the SDK's render-prop shape of a single string per + * field. When all rules pass it returns `null`. + * + * Returns `null` when no rules are supplied so callers can compose conditionally. + */ +const buildValidatorFromRules = ( + rules: ValidationRule[] | undefined, +): ((value: string) => string | null) | null => { + if (!rules || rules.length === 0) { + return null; + } + return (value: string): string | null => { + for (const rule of rules) { + const message: string | null = evaluateValidationRule(rule, value); + if (message !== null) { + return message; + } + } + return null; + }; +}; + +export default buildValidatorFromRules; diff --git a/packages/javascript/src/utils/v2/evaluateValidationRule.ts b/packages/javascript/src/utils/v2/evaluateValidationRule.ts new file mode 100644 index 000000000..eab780802 --- /dev/null +++ b/packages/javascript/src/utils/v2/evaluateValidationRule.ts @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {ValidationRule, ValidationRuleType} from '../../models/v2/embedded-flow-v2'; + +/** + * Default i18n fallback keys returned when a `ValidationRule.message` is not provided. + * Match the server-side defaults so a flow author who omits `message` sees the same + * string regardless of whether the rule was evaluated client-side or server-side. + */ +export const DEFAULT_VALIDATION_MESSAGE_KEYS: Record = { + regex: 'validation.pattern.invalid', + minLength: 'validation.minLength.invalid', + maxLength: 'validation.maxLength.invalid', +}; + +/** + * Evaluates a single validation rule against the given input value. + * + * Returns `null` when the rule passes, or the rule's `message` (or the default + * fallback key if `message` is absent) when it fails. + * + * Behavior notes: + * - **regex**: an invalid regex pattern (one that cannot be compiled) is treated as + * **passing** on the client. This is lenient — the server is authoritative and + * will still enforce the rule if it can compile the pattern. Failing closed in the + * SDK risks denial-of-service for misconfigured flows. + * - **minLength / maxLength**: compared against `value.length`. A non-numeric `value` + * on the rule is treated as the rule passing. + * - Unknown rule types are treated as passing (forward compatibility with future types). + */ +const evaluateValidationRule = (rule: ValidationRule, value: string): string | null => { + const fail = (): string => rule.message ?? DEFAULT_VALIDATION_MESSAGE_KEYS[rule.type]; + + switch (rule.type) { + case 'regex': { + if (typeof rule.value !== 'string' || rule.value === '') { + return null; + } + let re: RegExp; + try { + re = new RegExp(rule.value); + } catch { + return null; + } + return re.test(value) ? null : fail(); + } + case 'minLength': { + if (typeof rule.value !== 'number' || Number.isNaN(rule.value)) { + return null; + } + return value.length >= rule.value ? null : fail(); + } + case 'maxLength': { + if (typeof rule.value !== 'number' || Number.isNaN(rule.value)) { + return null; + } + return value.length <= rule.value ? null : fail(); + } + default: + return null; + } +}; + +export default evaluateValidationRule; diff --git a/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx b/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx index f32d45a7b..a269eaa48 100644 --- a/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx +++ b/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {FlowMetadataResponse, Preferences} from '@asgardeo/browser'; +import {FieldErrorV2 as FieldError, FlowMetadataResponse, Preferences, buildValidatorFromRules} from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {FC, ReactElement, ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react'; import useStyles from './BaseAcceptInvite.styles'; @@ -45,6 +45,7 @@ export interface AcceptInviteFlowResponse { data?: { additionalData?: Record; components?: any[]; + fieldErrors?: FieldError[]; meta?: { components?: any[]; }; @@ -284,6 +285,28 @@ const BaseAcceptInvite: FC = ({ const [isStorageReady, setIsStorageReady] = useState(false); const challengeTokenRef: any = useRef(null); + /** + * Project server-side validation errors from the most recent flow response into + * the local formErrors state. First error per field wins; affected fields are + * marked touched so errors render immediately. + */ + useEffect(() => { + const responseFieldErrors: FieldError[] | undefined = (currentFlow?.data as any)?.fieldErrors; + if (!responseFieldErrors || responseFieldErrors.length === 0) { + 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; + } + } + setFormErrors(errors); + setTouchedFields((prev: Record) => ({...prev, ...touched})); + }, [currentFlow]); + const tokenValidationAttemptedRef: any = useRef(false); /** @@ -461,13 +484,22 @@ const BaseAcceptInvite: FC = ({ comp.type === 'TEXT_INPUT' || comp.type === 'EMAIL_INPUT' || comp.type === 'PHONE_INPUT' || - comp.type === 'OTP_INPUT') && - comp.required && + comp.type === 'OTP_INPUT' || + comp.type === 'DATE_INPUT') && comp.ref ) { const value: any = formValues[comp.ref]; - if (!value || value.trim() === '') { + if (comp.required && (!value || value.trim() === '')) { errors[comp.ref] = t('validations.required.field.error'); + } else if (value) { + // Evaluate declarative validation rules from meta.components[].validation. + const ruleValidator = buildValidatorFromRules(comp.validation); + if (ruleValidator) { + const message = ruleValidator(value); + if (message) { + errors[comp.ref] = t(message); + } + } } } if (comp.components && Array.isArray(comp.components)) { diff --git a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx index 7fef3e0eb..247bd1b5d 100644 --- a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx +++ b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx @@ -59,6 +59,7 @@ import SmsOtpButton from '../../adapters/SmsOtpButton'; import {createField} from '../../factories/FieldFactory'; import Button from '../../primitives/Button/Button'; import CopyableText from '../../primitives/CopyableText/CopyableText'; +import DatePicker from '../../primitives/DatePicker/DatePicker'; import Divider from '../../primitives/Divider/Divider'; import flowIconRegistry from '../../primitives/Icons/flowIconRegistry'; import Select from '../../primitives/Select/Select'; @@ -468,6 +469,29 @@ const createAuthComponentFromFlow = ( ); } + case EmbeddedFlowComponentType.DateInput: { + const identifier: string = component.ref; + const value: string = formValues[identifier] || ''; + const isTouched: boolean = touchedFields[identifier] || false; + const error: string = isTouched ? formErrors[identifier] : undefined; + + return ( + onInputChange(identifier, e.target.value)} + onBlur={(): any => options.onInputBlur?.(identifier)} + className={options.inputClassName} + /> + ); + } + case EmbeddedFlowComponentType.OuSelect: { const identifier: string = component.ref ?? component.id; const rootOuId: string | undefined = options.additionalData?.['rootOuId'] as string | undefined; diff --git a/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx b/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx index db3b3af0e..9fb666e95 100644 --- a/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx +++ b/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx @@ -18,7 +18,9 @@ import { EmbeddedFlowType, + FieldErrorV2 as FieldError, FlowMetadataResponse, + buildValidatorFromRules, logger, OrganizationUnitListResponse, Preferences, @@ -48,6 +50,7 @@ export interface InviteUserFlowResponse { data?: { additionalData?: Record; components?: any[]; + fieldErrors?: FieldError[]; meta?: { components?: any[]; }; @@ -273,6 +276,28 @@ const BaseInviteUser: FC = ({ const [isFormValid, setIsFormValid] = useState(true); const challengeTokenRef: any = useRef(null); + /** + * Project server-side validation errors from the most recent flow response into the + * local formErrors state so they render alongside client-side errors. First error + * per field wins, matching the SDK's single-string-per-field render-prop shape. + */ + useEffect(() => { + const responseFieldErrors: FieldError[] | undefined = (currentFlow?.data as any)?.fieldErrors; + if (!responseFieldErrors || responseFieldErrors.length === 0) { + 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; + } + } + setFormErrors(errors); + setTouchedFields((prev: Record) => ({...prev, ...touched})); + }, [currentFlow]); + const initializationAttemptedRef: any = useRef(false); /** @@ -403,17 +428,28 @@ const BaseInviteUser: FC = ({ comp.type === 'EMAIL_INPUT' || comp.type === 'SELECT' || comp.type === 'PHONE_INPUT' || - comp.type === 'OTP_INPUT') && - comp.required && + comp.type === 'OTP_INPUT' || + comp.type === 'DATE_INPUT') && comp.ref ) { const value: any = formValues[comp.ref]; - if (!value || value.trim() === '') { + if (comp.required && (!value || value.trim() === '')) { errors[comp.ref] = `${comp.label || comp.ref} is required`; - } - // Email validation - if (comp.type === 'EMAIL_INPUT' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { - errors[comp.ref] = 'Please enter a valid email address'; + } else { + // Email validation + if (comp.type === 'EMAIL_INPUT' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + errors[comp.ref] = 'Please enter a valid email address'; + } + // Evaluate declarative validation rules from meta.components[].validation. + if (value && !errors[comp.ref]) { + const ruleValidator = buildValidatorFromRules(comp.validation); + if (ruleValidator) { + const message = ruleValidator(value); + if (message) { + errors[comp.ref] = message; + } + } + } } } if (comp.components && Array.isArray(comp.components)) { diff --git a/packages/react/src/components/presentation/auth/Recovery/v2/BaseRecovery.tsx b/packages/react/src/components/presentation/auth/Recovery/v2/BaseRecovery.tsx index 56416e518..232434a02 100644 --- a/packages/react/src/components/presentation/auth/Recovery/v2/BaseRecovery.tsx +++ b/packages/react/src/components/presentation/auth/Recovery/v2/BaseRecovery.tsx @@ -21,7 +21,9 @@ import { EmbeddedFlowExecuteResponse, EmbeddedFlowStatus, EmbeddedFlowComponentTypeV2 as EmbeddedFlowComponentType, + FieldErrorV2 as FieldError, withVendorCSSClassPrefix, + buildValidatorFromRules, Preferences, FlowMetadataResponse, } from '@asgardeo/browser'; @@ -180,9 +182,11 @@ const BaseRecoveryContent: FC = ({ component.type === EmbeddedFlowComponentType.TextInput || component.type === EmbeddedFlowComponentType.PasswordInput || component.type === EmbeddedFlowComponentType.EmailInput || - component.type === EmbeddedFlowComponentType.Select + component.type === EmbeddedFlowComponentType.Select || + component.type === EmbeddedFlowComponentType.DateInput ) { const fieldName: any = component.ref || component.id; + const ruleValidator = buildValidatorFromRules(component.validation); fields.push({ initialValue: '', name: fieldName, @@ -198,6 +202,13 @@ const BaseRecoveryContent: FC = ({ ) { return t('field.email.invalid'); } + // Evaluate declarative validation rules from meta.components[].validation. + if (ruleValidator && value) { + const ruleMessage = ruleValidator(value); + if (ruleMessage) { + return t(ruleMessage); + } + } return null; }, }); @@ -232,11 +243,33 @@ const BaseRecoveryContent: FC = ({ isValid: isFormValid, setValue: setFormValue, setTouched: setFormTouched, + setErrors: setFormErrors, + clearErrors: clearFormErrors, validateForm, touchAllFields, 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. + */ + useEffect(() => { + clearFormErrors(); + const responseFieldErrors: FieldError[] | undefined = (currentFlow?.data as any)?.fieldErrors; + if (!responseFieldErrors || responseFieldErrors.length === 0) { + return; + } + const errors: Record = {}; + for (const fe of responseFieldErrors) { + if (!(fe.identifier in errors)) { + errors[fe.identifier] = fe.message; + } + } + setFormErrors(errors); + Object.keys(errors).forEach((field: string) => setFormTouched(field, true)); + }, [currentFlow, setFormErrors, setFormTouched, clearFormErrors]); + const setupFormFields: any = useCallback( (flowResponse: EmbeddedFlowExecuteResponse) => { const fields: any = extractFormFields(flowResponse.data?.components || []); diff --git a/packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx index eac7693b6..8f594e694 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx @@ -20,11 +20,13 @@ import { withVendorCSSClassPrefix, EmbeddedSignInFlowRequestV2 as EmbeddedSignInFlowRequest, EmbeddedFlowComponentV2 as EmbeddedFlowComponent, + FieldErrorV2 as FieldError, FlowMetadataResponse, Preferences, + buildValidatorFromRules, } from '@asgardeo/browser'; import {cx} from '@emotion/css'; -import {FC, useState, useCallback, useContext, ReactElement, ReactNode} from 'react'; +import {FC, useEffect, useRef, useState, useCallback, useContext, ReactElement, ReactNode} from 'react'; import useAsgardeo from '../../../../../contexts/Asgardeo/useAsgardeo'; import ComponentRendererContext, { ComponentRendererMap, @@ -59,7 +61,12 @@ export interface BaseSignInRenderProps { error?: Error | null; /** - * Field validation errors + * Field validation errors keyed by component ref. Populated from BOTH: + * - Client-side rule evaluation (component.validation rules in meta.components) + * - Server-side validation failures (data.fieldErrors in the flow response) + * When the server returns multiple failing rules for one field, only the first + * message is exposed here. The full FieldError[] array is available on the raw + * response object (and is reflected into the BaseSignIn `serverFieldErrors` prop). */ fieldErrors: Record; @@ -209,6 +216,15 @@ export interface BaseSignInProps { */ preferences?: Preferences; + /** + * Field-level validation errors returned by the server in `data.fieldErrors` on the + * most recent flow response. The component collapses these into the form's + * `fieldErrors` state (first error per field wins), surfacing them through the same + * render-prop / UI path as client-side validation errors. The full array is preserved + * here for advanced consumers that want every failing rule per field. + */ + serverFieldErrors?: FieldError[] | null; + /** * Size variant for the component. */ @@ -238,6 +254,7 @@ const BaseSignInContent: FC = ({ children, additionalData = {}, isTimeoutDisabled = false, + serverFieldErrors = null, }: BaseSignInProps): ReactElement => { const {meta} = useAsgardeo(); const {theme} = useTheme(); @@ -287,9 +304,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, @@ -306,6 +326,15 @@ const BaseSignInContent: FC = ({ ) { return t('field.email.invalid'); } + // Evaluate declarative validation rules from meta.components[].validation. + // The composed validator returns the first failing rule's message (i18n key or + // literal string) so it can be passed straight to the i18n layer for display. + if (ruleValidator && value) { + const ruleMessage = ruleValidator(value); + if (ruleMessage) { + return t(ruleMessage); + } + } return null; }, @@ -340,10 +369,37 @@ const BaseSignInContent: FC = ({ isValid: isFormValid, setValue: setFormValue, setTouched: setFormTouched, + setErrors: setFormErrors, + clearErrors: clearFormErrors, validateForm, touchAllFields, } = form; + /** + * Project server-side validation errors (from `data.fieldErrors`) into the form's + * `errors` state so they surface through the same render-prop / UI as client-side + * errors. When the server returns multiple failing rules for one field, only the + * first message is shown — matching the SDK's single-string-per-field contract. + * The full FieldError[] remains available via the `serverFieldErrors` prop. + * + * Also marks each affected field as `touched` so the error renders immediately — + * `useForm` only shows errors for touched fields by default. + */ + useEffect(() => { + clearFormErrors(); + if (!serverFieldErrors || serverFieldErrors.length === 0) { + return; + } + const errors: Record = {}; + for (const fe of serverFieldErrors) { + if (!(fe.identifier in errors)) { + errors[fe.identifier] = fe.message; + } + } + setFormErrors(errors); + Object.keys(errors).forEach((field: string) => setFormTouched(field, true)); + }, [serverFieldErrors, setFormErrors, setFormTouched, clearFormErrors]); + /** * Handle input value changes. * Only updates the value without marking as touched. diff --git a/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx index aa0203b63..b479b9f6e 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx @@ -24,6 +24,7 @@ import { EmbeddedSignInFlowRequestV2, EmbeddedSignInFlowStatusV2, EmbeddedSignInFlowTypeV2, + FieldErrorV2 as FieldError, FlowMetadataResponse, Preferences, logger, @@ -58,6 +59,14 @@ export interface SignInRenderProps { */ error: Error | null; + /** + * Server-side field-level validation errors from the most recent flow response, + * collapsed to one message per field (first error wins). Empty when no validation + * failures are active. Render-prop consumers should display these alongside their + * own client-side validation errors. + */ + fieldErrors: Record; + /** * Function to manually initialize the flow */ @@ -223,6 +232,9 @@ const SignIn: FC = ({ // State management for the flow const [components, setComponents] = useState([]); const [additionalData, setAdditionalData] = useState>({}); + // Server-side validation errors from the most recent flow response. Updated on every + // submission; cleared when the next submission begins so stale errors don't linger. + const [serverFieldErrors, setServerFieldErrors] = useState(null); const [currentExecutionId, setCurrentExecutionId] = useState(null); const challengeTokenRef: any = useRef(null); const [isStorageReady, setIsStorageReady] = useState(false); @@ -637,6 +649,8 @@ const SignIn: FC = ({ try { setIsSubmitting(true); setFlowError(null); + // Clear any field errors from the previous response before the new round-trip. + setServerFieldErrors(null); const response: EmbeddedSignInFlowResponseV2 = (await signIn({ executionId: effectiveExecutionId, @@ -746,6 +760,13 @@ const SignIn: FC = ({ // Clean up executionId from URL after setting it in state cleanupFlowUrlParams(); + // Surface server-side validation failures so BaseSignIn can inject them into + // the form-level fieldErrors state used by the render-prop / default UI. + const responseFieldErrors: FieldError[] | undefined = (response.data as any)?.fieldErrors; + if (responseFieldErrors && responseFieldErrors.length > 0) { + setServerFieldErrors(responseFieldErrors); + } + // Display failure reason from INCOMPLETE response if ((response as any)?.failureReason) { setFlowError(new Error((response as any).failureReason)); @@ -856,10 +877,25 @@ const SignIn: FC = ({ }, [passkeyState.isActive, passkeyState.challenge, passkeyState.creationOptions, passkeyState.executionId]); if (children) { + // Collapse the server FieldError[] array to a single message per field map for + // render-prop consumers. First error per field wins. Multi-error cases per + // field are rare in practice (server typically returns one rule failure per + // field in current flows) and consumers needing the full array can still read + // it from the raw flow response. + const renderPropFieldErrors: Record = {}; + if (serverFieldErrors) { + for (const fe of serverFieldErrors) { + if (!(fe.identifier in renderPropFieldErrors)) { + renderPropFieldErrors[fe.identifier] = fe.message; + } + } + } + const renderProps: SignInRenderProps = { additionalData, components, error: flowError, + fieldErrors: renderPropFieldErrors, initialize: initializeFlow, isInitialized: isFlowInitialized, isLoading: isLoading || isSubmitting || !isInitialized, @@ -884,6 +920,7 @@ const SignIn: FC = ({ size={size} variant={variant} preferences={preferences} + serverFieldErrors={serverFieldErrors} /> ); }; diff --git a/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx index 1ad9de5b3..c22648354 100644 --- a/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx +++ b/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx @@ -23,7 +23,9 @@ import { EmbeddedFlowResponseType, withVendorCSSClassPrefix, EmbeddedFlowComponentTypeV2 as EmbeddedFlowComponentType, + FieldErrorV2 as FieldError, createPackageComponentLogger, + buildValidatorFromRules, Preferences, } from '@asgardeo/browser'; import {cx} from '@emotion/css'; @@ -409,11 +411,13 @@ const BaseSignUpContent: FC = ({ component.type === EmbeddedFlowComponentType.TextInput || component.type === EmbeddedFlowComponentType.PasswordInput || component.type === EmbeddedFlowComponentType.EmailInput || - component.type === EmbeddedFlowComponentType.Select + component.type === EmbeddedFlowComponentType.Select || + component.type === EmbeddedFlowComponentType.DateInput ) { // Use component.ref (mapped identifier) as the field name instead of component.id // This ensures form field names match what the input components use const fieldName: any = component.ref || component.id; + const ruleValidator = buildValidatorFromRules(component.validation); fields.push({ initialValue: '', @@ -431,6 +435,13 @@ const BaseSignUpContent: FC = ({ ) { return t('field.email.invalid'); } + // Evaluate declarative validation rules from meta.components[].validation. + if (ruleValidator && value) { + const ruleMessage = ruleValidator(value); + if (ruleMessage) { + return t(ruleMessage); + } + } return null; }, @@ -466,11 +477,34 @@ const BaseSignUpContent: FC = ({ isValid: isFormValid, setValue: setFormValue, setTouched: setFormTouched, + setErrors: setFormErrors, + clearErrors: clearFormErrors, validateForm, touchAllFields, 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. + */ + useEffect(() => { + clearFormErrors(); + const responseFieldErrors: FieldError[] | undefined = (currentFlow?.data as any)?.fieldErrors; + if (!responseFieldErrors || responseFieldErrors.length === 0) { + return; + } + const errors: Record = {}; + for (const fe of responseFieldErrors) { + if (!(fe.identifier in errors)) { + errors[fe.identifier] = fe.message; + } + } + setFormErrors(errors); + Object.keys(errors).forEach((field: string) => setFormTouched(field, true)); + }, [currentFlow, setFormErrors, setFormTouched, clearFormErrors]); + /** * Setup form fields based on the current flow. */