Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/input-validation-flow-inputs.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions packages/i18n/src/models/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 | */
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/translations/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 | */
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/translations/fr-FR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 | */
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/translations/hi-IN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ const translations: I18nTranslations = {

/* Validation */
'validations.required.field.error': 'यह फील्ड आवश्यक है',
'validation.pattern.invalid': 'यह मान आवश्यक प्रारूप से मेल नहीं खाता।',
'validation.minLength.invalid': 'यह मान बहुत छोटा है।',
'validation.maxLength.invalid': 'यह मान बहुत लंबा है।',

/* |---------------------------------------------------------------| */
/* | Widgets | */
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ const translations: I18nTranslations = {

/* Validation */
'validations.required.field.error': 'この項目は必須です',
'validation.pattern.invalid': 'この値は必要な形式と一致しません。',
'validation.minLength.invalid': 'この値は短すぎます。',
'validation.maxLength.invalid': 'この値は長すぎます。',

/* |---------------------------------------------------------------| */
/* | Widgets | */
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/translations/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 | */
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/translations/pt-PT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 | */
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/translations/si-LK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ const translations: I18nTranslations = {

/* Validation */
'validations.required.field.error': 'මෙම ක්ෂේත්‍රය අවශ්‍යයි',
'validation.pattern.invalid': 'මෙම අගය අවශ්‍ය ආකෘතියට නොගැලපේ.',
'validation.minLength.invalid': 'මෙම අගය ඉතා කෙටියි.',
'validation.maxLength.invalid': 'මෙම අගය ඉතා දිගයි.',

/* |---------------------------------------------------------------| */
/* | Widgets | */
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/translations/ta-IN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ const translations: I18nTranslations = {

/* Validation */
'validations.required.field.error': 'இந்த புலம் தேவை',
'validation.pattern.invalid': 'இந்த மதிப்பு தேவையான வடிவத்துடன் பொருந்தவில்லை.',
'validation.minLength.invalid': 'இந்த மதிப்பு மிகவும் குறுகியது.',
'validation.maxLength.invalid': 'இந்த மதிப்பு மிகவும் நீளமானது.',

/* |---------------------------------------------------------------| */
/* | Widgets | */
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/translations/te-IN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ const translations: I18nTranslations = {

/* Validation */
'validations.required.field.error': 'ఈ ఫీల్డ్ అవసరం',
'validation.pattern.invalid': 'ఈ విలువ అవసరమైన ఆకృతికి సరిపోలడం లేదు.',
'validation.minLength.invalid': 'ఈ విలువ చాలా చిన్నది.',
'validation.maxLength.invalid': 'ఈ విలువ చాలా పెద్దది.',

/* |---------------------------------------------------------------| */
/* | Widgets | */
Expand Down
8 changes: 8 additions & 0 deletions packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down
78 changes: 78 additions & 0 deletions packages/javascript/src/models/v2/embedded-flow-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down Expand Up @@ -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').
*/
Expand Down Expand Up @@ -350,13 +360,69 @@ 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).
*/
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.
*
Expand Down Expand Up @@ -434,6 +500,16 @@ export interface EmbeddedFlowResponseData {
*/
additionalData?: Record<string, any>;

/**
* 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
Expand All @@ -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[];
}[];

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading