diff --git a/.changeset/embed-password-policy-in-component.md b/.changeset/embed-password-policy-in-component.md new file mode 100644 index 0000000000..72a4428d64 --- /dev/null +++ b/.changeset/embed-password-policy-in-component.md @@ -0,0 +1,9 @@ +--- +'@forgerock/davinci-client': minor +--- + +Add `ValidatedPasswordCollector` alongside `PasswordCollector`. The reducer routes by `field.type`: `PASSWORD` always produces a `PasswordCollector`, `PASSWORD_VERIFY` always produces a `ValidatedPasswordCollector`. `ValidatedPasswordCollector.output.passwordPolicy` carries the embedded policy from the field; when the field has no policy, an empty policy object is emitted and the validator treats it as no rules. Consumers can render password requirements directly from the collector. + +Both collectors now expose a `verify: boolean` on `output` (defaults to `false`), propagated from the field when the server sends `verify: true`. + +`store.validate(collector)` accepts a `ValidatedPasswordCollector` and returns a validator that enforces the policy's length, unique-character, repeated-character, and per-charset minimum rules. Passing a `PasswordCollector` returns the standard "cannot be validated" error. diff --git a/e2e/davinci-app/components/password.ts b/e2e/davinci-app/components/password.ts index 5b835478f8..869e4d6b1b 100644 --- a/e2e/davinci-app/components/password.ts +++ b/e2e/davinci-app/components/password.ts @@ -4,32 +4,109 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import type { PasswordCollector, Updater } from '@forgerock/davinci-client/types'; +import type { + PasswordCollector, + ValidatedPasswordCollector, + Updater, + Validator, +} from '@forgerock/davinci-client/types'; import { dotToCamelCase } from '../helper.js'; +const UPPERCASE_RE = /^[A-Z]+$/; +const LOWERCASE_RE = /^[a-z]+$/; +const DIGIT_RE = /^[0-9]+$/; + export default function passwordComponent( formEl: HTMLFormElement, - collector: PasswordCollector, - updater: Updater, + collector: PasswordCollector | ValidatedPasswordCollector, + updater: Updater, + validator?: Validator, ) { + const collectorKey = dotToCamelCase(collector.output.key); const label = document.createElement('label'); const input = document.createElement('input'); - label.htmlFor = dotToCamelCase(collector.output.key); + label.htmlFor = collectorKey; label.innerText = collector.output.label; input.type = 'password'; - input.id = dotToCamelCase(collector.output.key); - input.name = dotToCamelCase(collector.output.key); + input.id = collectorKey; + input.name = collectorKey; formEl?.appendChild(label); formEl?.appendChild(input); - formEl - ?.querySelector(`#${dotToCamelCase(collector.output.key)}`) - ?.addEventListener('blur', (event: Event) => { - const error = updater((event.target as HTMLInputElement).value); - if (error && 'error' in error) { - console.error(error.error.message); + if (collector.type === 'ValidatedPasswordCollector') { + const validation = collector.input.validation; + const requirementsList = document.createElement('ul'); + requirementsList.className = 'password-requirements'; + + if (validation.length) { + const { min, max } = validation.length; + let lengthMessage: string | null = null; + if (min != null && max != null) { + lengthMessage = `${min}–${max} characters`; + } else if (min != null) { + lengthMessage = `At least ${min} characters`; + } else if (max != null) { + lengthMessage = `At most ${max} characters`; + } + if (lengthMessage) { + const li = document.createElement('li'); + li.textContent = lengthMessage; + requirementsList.appendChild(li); + } + } + + if (validation.minCharacters) { + for (const [charset, count] of Object.entries(validation.minCharacters)) { + const li = document.createElement('li'); + if (UPPERCASE_RE.test(charset)) { + li.textContent = `At least ${count} uppercase letter(s)`; + } else if (LOWERCASE_RE.test(charset)) { + li.textContent = `At least ${count} lowercase letter(s)`; + } else if (DIGIT_RE.test(charset)) { + li.textContent = `At least ${count} number(s)`; + } else { + li.textContent = `At least ${count} special character(s)`; + } + requirementsList.appendChild(li); } - }); + } + + if (requirementsList.children.length > 0) { + formEl?.appendChild(requirementsList); + } + } + + const inputEl = formEl?.querySelector(`#${collectorKey}`); + const shouldValidate = collector.type === 'ValidatedPasswordCollector' && !!validator; + + inputEl?.addEventListener('input', (event: Event) => { + const value = (event.target as HTMLInputElement).value; + + if (shouldValidate) { + const result = validator(value); + if (Array.isArray(result) && result.length) { + let errorEl = formEl?.querySelector(`.${collectorKey}-error`); + if (!errorEl) { + errorEl = document.createElement('ul'); + errorEl.className = `${collectorKey}-error`; + inputEl.after(errorEl); + } + const items = result.map((msg) => { + const li = document.createElement('li'); + li.textContent = msg; + return li; + }); + errorEl.replaceChildren(...items); + return; + } + formEl?.querySelector(`.${collectorKey}-error`)?.remove(); + } + + const error = updater(value); + if (error && 'error' in error) { + console.error(error.error.message); + } + }); } diff --git a/e2e/davinci-app/main.ts b/e2e/davinci-app/main.ts index 119a4dec6e..724a3150b6 100644 --- a/e2e/davinci-app/main.ts +++ b/e2e/davinci-app/main.ts @@ -237,13 +237,17 @@ const urlParams = new URLSearchParams(window.location.search); davinciClient.update(collector), // Returns an update function for this collector davinciClient.validate(collector), // Returns a validate function for this collector ); - } else if (collector.type === 'PasswordCollector') { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - collector; + } else if ( + collector.type === 'PasswordCollector' || + collector.type === 'ValidatedPasswordCollector' + ) { passwordComponent( formEl, // You can ignore this; it's just for rendering collector, // This is the plain object of the collector davinciClient.update(collector), // Returns an update function for this collector + collector.type === 'ValidatedPasswordCollector' + ? davinciClient.validate(collector) + : undefined, ); } else if (collector.type === 'SubmitCollector') { submitButtonComponent( diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index b2528bf664..339d195251 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -178,11 +178,13 @@ export interface CollectorErrors { } // @public (undocumented) -export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; +export type Collectors = FlowCollector | PasswordCollector | ValidatedPasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; // @public export type CollectorValueType = T extends { type: 'PasswordCollector'; +} ? string : T extends { + type: 'ValidatedPasswordCollector'; } ? string : T extends { type: 'TextCollector'; category: 'SingleValueCollector'; @@ -212,7 +214,7 @@ export type CollectorValueType = T extends { } ? string[] : string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; // @public (undocumented) -export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | FidoRegistrationField | FidoAuthenticationField | PollingField; +export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField; // @public (undocumented) export interface ContinueNode { @@ -267,13 +269,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; - poll: (collector: PollingCollector) => Poller; + pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -287,6 +287,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -297,7 +299,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -306,8 +308,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -323,6 +323,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -1032,10 +1034,10 @@ export type InferMultiValueCollectorType = T export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' ? NoValueCollectorBase<'ReadOnlyCollector'> : T extends 'QrCodeCollector' ? QrCodeCollectorBase : T extends 'AgreementCollector' ? AgreementCollector : NoValueCollectorBase<'NoValueCollector'>; // @public -export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; +export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : T extends 'ValidatedPasswordCollector' ? ValidatedPasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; // @public (undocumented) -export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; +export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : T extends 'PhoneNumberExtensionCollector' ? PhoneNumberExtensionCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; // @public (undocumented) export type InitFlow = () => Promise; @@ -1170,8 +1172,8 @@ value: Record; }, string>; // @public -export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { - getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; +export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | ValidatedPasswordCollector | MultiSelectCollector | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { + getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | ValidatedPasswordCollector | MultiSelectCollector | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; }; // @public (undocumented) @@ -1283,10 +1285,10 @@ export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRe export type ObjectValueCollector = ObjectOptionsCollectorWithObjectValue | ObjectOptionsCollectorWithStringValue | ObjectValueCollectorWithObjectValue; // @public (undocumented) -export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; +export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; // @public -export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; +export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'PhoneNumberExtensionCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; // @public (undocumented) export interface ObjectValueCollectorWithObjectValue, OV = Record> { @@ -1323,18 +1325,156 @@ export interface OutgoingQueryParams { } // @public (undocumented) -export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'>; +export interface PasswordCollector { + // (undocumented) + category: 'SingleValueCollector'; + // (undocumented) + error: string | null; + // (undocumented) + id: string; + // (undocumented) + input: { + key: string; + value: string | number | boolean; + type: string; + }; + // (undocumented) + name: string; + // (undocumented) + output: { + key: string; + label: string; + type: string; + verify: boolean; + }; + // (undocumented) + type: 'PasswordCollector'; +} + +// @public +export type PasswordField = { + type: 'PASSWORD' | 'PASSWORD_VERIFY'; + key: string; + label: string; + required?: boolean; + verify?: boolean; + passwordPolicy?: PasswordPolicy; +}; + +// @public (undocumented) +export interface PasswordPolicy { + // (undocumented) + createdAt?: string; + // (undocumented) + default?: boolean; + // (undocumented) + description?: string; + // (undocumented) + excludesCommonlyUsed?: boolean; + // (undocumented) + excludesProfileData?: boolean; + // (undocumented) + history?: { + count?: number; + retentionDays?: number; + }; + // (undocumented) + id?: string; + // (undocumented) + length?: { + min?: number; + max?: number; + }; + // (undocumented) + lockout?: { + failureCount?: number; + durationSeconds?: number; + }; + // (undocumented) + maxAgeDays?: number; + // (undocumented) + maxRepeatedCharacters?: number; + // (undocumented) + minAgeDays?: number; + // (undocumented) + minCharacters?: Record; + // (undocumented) + minUniqueCharacters?: number; + // (undocumented) + name?: string; + // (undocumented) + notSimilarToCurrent?: boolean; + // (undocumented) + populationCount?: number; + // (undocumented) + updatedAt?: string; +} // @public (undocumented) export type PhoneNumberCollector = ObjectValueCollectorWithObjectValue<'PhoneNumberCollector', PhoneNumberInputValue, PhoneNumberOutputValue>; +// @public (undocumented) +export interface PhoneNumberExtensionCollector { + // (undocumented) + category: 'ObjectValueCollector'; + // (undocumented) + error: string | null; + // (undocumented) + id: string; + // (undocumented) + input: { + key: string; + value: PhoneNumberExtensionInputValue; + type: string; + validation: (ValidationRequired | ValidationPhoneNumber)[] | null; + }; + // (undocumented) + name: string; + // (undocumented) + output: { + key: string; + label: string; + type: string; + extensionLabel: string; + value: PhoneNumberExtensionOutputValue; + }; + // (undocumented) + type: 'PhoneNumberExtensionCollector'; +} + +// @public (undocumented) +export type PhoneNumberExtensionField = PhoneNumberField & { + showExtension: boolean; + extensionLabel: string; +}; + +// @public (undocumented) +export interface PhoneNumberExtensionInputValue { + // (undocumented) + countryCode: string; + // (undocumented) + extension: string; + // (undocumented) + phoneNumber: string; +} + +// @public (undocumented) +export interface PhoneNumberExtensionOutputValue { + // (undocumented) + countryCode?: string; + // (undocumented) + extension?: string; + // (undocumented) + phoneNumber?: string; +} + // @public (undocumented) export type PhoneNumberField = { type: 'PHONE_NUMBER'; key: string; label: string; - defaultCountryCode: string | null; required: boolean; + defaultCountryCode: string | null; validatePhoneNumber: boolean; }; @@ -1588,10 +1728,10 @@ export interface SingleValueCollectorNoValue | SingleSelectCollectorWithValue<'SingleSelectCollector'> | SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorWithValue<'TextCollector'> | ValidatedSingleValueCollectorWithValue<'TextCollector'>; +export type SingleValueCollectors = PasswordCollector | ValidatedPasswordCollector | SingleSelectCollectorWithValue<'SingleSelectCollector'> | SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorWithValue<'TextCollector'> | ValidatedSingleValueCollectorWithValue<'TextCollector'>; // @public -export type SingleValueCollectorTypes = 'PasswordCollector' | 'SingleValueCollector' | 'SingleSelectCollector' | 'SingleSelectObjectCollector' | 'TextCollector' | 'ValidatedTextCollector'; +export type SingleValueCollectorTypes = 'PasswordCollector' | 'ValidatedPasswordCollector' | 'SingleValueCollector' | 'SingleSelectCollector' | 'SingleSelectObjectCollector' | 'TextCollector' | 'ValidatedTextCollector'; // @public (undocumented) export interface SingleValueCollectorWithValue { @@ -1621,11 +1761,11 @@ export interface SingleValueCollectorWithValue; // @public (undocumented) export const updateCollectorValues: ActionCreatorWithPayload< { id: string; -value: string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; +value: string | string[] | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; index?: number; }, string>; @@ -1743,6 +1883,34 @@ export type ValidatedField = { }; }; +// @public (undocumented) +export interface ValidatedPasswordCollector { + // (undocumented) + category: 'SingleValueCollector'; + // (undocumented) + error: string | null; + // (undocumented) + id: string; + // (undocumented) + input: { + key: string; + value: string | number | boolean; + type: string; + validation: PasswordPolicy; + }; + // (undocumented) + name: string; + // (undocumented) + output: { + key: string; + label: string; + type: string; + verify: boolean; + }; + // (undocumented) + type: 'ValidatedPasswordCollector'; +} + // @public (undocumented) export interface ValidatedSingleValueCollectorWithValue { // (undocumented) diff --git a/packages/davinci-client/api-report/davinci-client.types.api.md b/packages/davinci-client/api-report/davinci-client.types.api.md index 2321431a0a..df4c16e49a 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -178,11 +178,13 @@ export interface CollectorErrors { } // @public (undocumented) -export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; +export type Collectors = FlowCollector | PasswordCollector | ValidatedPasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; // @public export type CollectorValueType = T extends { type: 'PasswordCollector'; +} ? string : T extends { + type: 'ValidatedPasswordCollector'; } ? string : T extends { type: 'TextCollector'; category: 'SingleValueCollector'; @@ -212,7 +214,7 @@ export type CollectorValueType = T extends { } ? string[] : string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; // @public (undocumented) -export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | FidoRegistrationField | FidoAuthenticationField | PollingField; +export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField; // @public (undocumented) export interface ContinueNode { @@ -267,13 +269,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; - poll: (collector: PollingCollector) => Poller; + pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -287,6 +287,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -297,7 +299,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -306,8 +308,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -323,6 +323,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -1029,10 +1031,10 @@ export type InferMultiValueCollectorType = T export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' ? NoValueCollectorBase<'ReadOnlyCollector'> : T extends 'QrCodeCollector' ? QrCodeCollectorBase : T extends 'AgreementCollector' ? AgreementCollector : NoValueCollectorBase<'NoValueCollector'>; // @public -export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; +export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : T extends 'ValidatedPasswordCollector' ? ValidatedPasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; // @public (undocumented) -export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; +export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : T extends 'PhoneNumberExtensionCollector' ? PhoneNumberExtensionCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; // @public (undocumented) export type InitFlow = () => Promise; @@ -1167,8 +1169,8 @@ value: Record; }, string>; // @public -export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { - getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; +export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | ValidatedPasswordCollector | MultiSelectCollector | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { + getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | ValidatedPasswordCollector | MultiSelectCollector | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; }; // @public (undocumented) @@ -1280,10 +1282,10 @@ export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRe export type ObjectValueCollector = ObjectOptionsCollectorWithObjectValue | ObjectOptionsCollectorWithStringValue | ObjectValueCollectorWithObjectValue; // @public (undocumented) -export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; +export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; // @public -export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; +export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'PhoneNumberExtensionCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; // @public (undocumented) export interface ObjectValueCollectorWithObjectValue, OV = Record> { @@ -1320,18 +1322,156 @@ export interface OutgoingQueryParams { } // @public (undocumented) -export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'>; +export interface PasswordCollector { + // (undocumented) + category: 'SingleValueCollector'; + // (undocumented) + error: string | null; + // (undocumented) + id: string; + // (undocumented) + input: { + key: string; + value: string | number | boolean; + type: string; + }; + // (undocumented) + name: string; + // (undocumented) + output: { + key: string; + label: string; + type: string; + verify: boolean; + }; + // (undocumented) + type: 'PasswordCollector'; +} + +// @public +export type PasswordField = { + type: 'PASSWORD' | 'PASSWORD_VERIFY'; + key: string; + label: string; + required?: boolean; + verify?: boolean; + passwordPolicy?: PasswordPolicy; +}; + +// @public (undocumented) +export interface PasswordPolicy { + // (undocumented) + createdAt?: string; + // (undocumented) + default?: boolean; + // (undocumented) + description?: string; + // (undocumented) + excludesCommonlyUsed?: boolean; + // (undocumented) + excludesProfileData?: boolean; + // (undocumented) + history?: { + count?: number; + retentionDays?: number; + }; + // (undocumented) + id?: string; + // (undocumented) + length?: { + min?: number; + max?: number; + }; + // (undocumented) + lockout?: { + failureCount?: number; + durationSeconds?: number; + }; + // (undocumented) + maxAgeDays?: number; + // (undocumented) + maxRepeatedCharacters?: number; + // (undocumented) + minAgeDays?: number; + // (undocumented) + minCharacters?: Record; + // (undocumented) + minUniqueCharacters?: number; + // (undocumented) + name?: string; + // (undocumented) + notSimilarToCurrent?: boolean; + // (undocumented) + populationCount?: number; + // (undocumented) + updatedAt?: string; +} // @public (undocumented) export type PhoneNumberCollector = ObjectValueCollectorWithObjectValue<'PhoneNumberCollector', PhoneNumberInputValue, PhoneNumberOutputValue>; +// @public (undocumented) +export interface PhoneNumberExtensionCollector { + // (undocumented) + category: 'ObjectValueCollector'; + // (undocumented) + error: string | null; + // (undocumented) + id: string; + // (undocumented) + input: { + key: string; + value: PhoneNumberExtensionInputValue; + type: string; + validation: (ValidationRequired | ValidationPhoneNumber)[] | null; + }; + // (undocumented) + name: string; + // (undocumented) + output: { + key: string; + label: string; + type: string; + extensionLabel: string; + value: PhoneNumberExtensionOutputValue; + }; + // (undocumented) + type: 'PhoneNumberExtensionCollector'; +} + +// @public (undocumented) +export type PhoneNumberExtensionField = PhoneNumberField & { + showExtension: boolean; + extensionLabel: string; +}; + +// @public (undocumented) +export interface PhoneNumberExtensionInputValue { + // (undocumented) + countryCode: string; + // (undocumented) + extension: string; + // (undocumented) + phoneNumber: string; +} + +// @public (undocumented) +export interface PhoneNumberExtensionOutputValue { + // (undocumented) + countryCode?: string; + // (undocumented) + extension?: string; + // (undocumented) + phoneNumber?: string; +} + // @public (undocumented) export type PhoneNumberField = { type: 'PHONE_NUMBER'; key: string; label: string; - defaultCountryCode: string | null; required: boolean; + defaultCountryCode: string | null; validatePhoneNumber: boolean; }; @@ -1585,10 +1725,10 @@ export interface SingleValueCollectorNoValue | SingleSelectCollectorWithValue<'SingleSelectCollector'> | SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorWithValue<'TextCollector'> | ValidatedSingleValueCollectorWithValue<'TextCollector'>; +export type SingleValueCollectors = PasswordCollector | ValidatedPasswordCollector | SingleSelectCollectorWithValue<'SingleSelectCollector'> | SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorWithValue<'TextCollector'> | ValidatedSingleValueCollectorWithValue<'TextCollector'>; // @public -export type SingleValueCollectorTypes = 'PasswordCollector' | 'SingleValueCollector' | 'SingleSelectCollector' | 'SingleSelectObjectCollector' | 'TextCollector' | 'ValidatedTextCollector'; +export type SingleValueCollectorTypes = 'PasswordCollector' | 'ValidatedPasswordCollector' | 'SingleValueCollector' | 'SingleSelectCollector' | 'SingleSelectObjectCollector' | 'TextCollector' | 'ValidatedTextCollector'; // @public (undocumented) export interface SingleValueCollectorWithValue { @@ -1618,11 +1758,11 @@ export interface SingleValueCollectorWithValue; // @public (undocumented) export const updateCollectorValues: ActionCreatorWithPayload< { id: string; -value: string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; +value: string | string[] | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; index?: number; }, string>; @@ -1740,6 +1880,34 @@ export type ValidatedField = { }; }; +// @public (undocumented) +export interface ValidatedPasswordCollector { + // (undocumented) + category: 'SingleValueCollector'; + // (undocumented) + error: string | null; + // (undocumented) + id: string; + // (undocumented) + input: { + key: string; + value: string | number | boolean; + type: string; + validation: PasswordPolicy; + }; + // (undocumented) + name: string; + // (undocumented) + output: { + key: string; + label: string; + type: string; + verify: boolean; + }; + // (undocumented) + type: 'ValidatedPasswordCollector'; +} + // @public (undocumented) export interface ValidatedSingleValueCollectorWithValue { // (undocumented) diff --git a/packages/davinci-client/src/lib/client.store.ts b/packages/davinci-client/src/lib/client.store.ts index e99dc64018..a0113a87fd 100644 --- a/packages/davinci-client/src/lib/client.store.ts +++ b/packages/davinci-client/src/lib/client.store.ts @@ -57,6 +57,7 @@ import type { Poller, } from './client.types.js'; import { returnValidator } from './collector.utils.js'; +import { returnPasswordPolicyValidator } from './password-policy.rules.js'; import type { ContinueNode, StartNode } from './node.types.js'; /** @@ -392,7 +393,16 @@ export async function davinci({ return handleUpdateValidateError('Collector not found', 'state_error', log.error); } + if (collectorToUpdate.type === 'PasswordCollector') { + return handleUpdateValidateError( + 'PasswordCollector cannot be validated; pass a ValidatedPasswordCollector', + 'state_error', + log.error, + ); + } + if ( + collectorToUpdate.type !== 'ValidatedPasswordCollector' && collectorToUpdate.category !== 'ValidatedSingleValueCollector' && collectorToUpdate.category !== 'ObjectValueCollector' && collectorToUpdate.category !== 'MultiValueCollector' && @@ -405,7 +415,10 @@ export async function davinci({ ); } - if (!('validation' in collectorToUpdate.input)) { + if ( + collectorToUpdate.type !== 'ValidatedPasswordCollector' && + !('validation' in collectorToUpdate.input) + ) { return handleUpdateValidateError( 'Collector has no validation rules', 'state_error', @@ -413,6 +426,10 @@ export async function davinci({ ); } + if (collectorToUpdate.type === 'ValidatedPasswordCollector') { + return returnPasswordPolicyValidator(collectorToUpdate); + } + return returnValidator(collectorToUpdate); }, diff --git a/packages/davinci-client/src/lib/client.types.ts b/packages/davinci-client/src/lib/client.types.ts index 1830f6fd5c..c23cf6709f 100644 --- a/packages/davinci-client/src/lib/client.types.ts +++ b/packages/davinci-client/src/lib/client.types.ts @@ -36,36 +36,38 @@ export type InitFlow = () => Promise; */ export type CollectorValueType = T extends { type: 'PasswordCollector' } ? string - : T extends { type: 'TextCollector'; category: 'SingleValueCollector' } + : T extends { type: 'ValidatedPasswordCollector' } ? string - : T extends { type: 'TextCollector'; category: 'ValidatedSingleValueCollector' } + : T extends { type: 'TextCollector'; category: 'SingleValueCollector' } ? string - : T extends { type: 'SingleSelectCollector' } + : T extends { type: 'TextCollector'; category: 'ValidatedSingleValueCollector' } ? string - : T extends { type: 'MultiSelectCollector' } - ? string[] - : T extends { type: 'DeviceRegistrationCollector' } - ? string - : T extends { type: 'DeviceAuthenticationCollector' } + : T extends { type: 'SingleSelectCollector' } + ? string + : T extends { type: 'MultiSelectCollector' } + ? string[] + : T extends { type: 'DeviceRegistrationCollector' } ? string - : T extends { type: 'PhoneNumberCollector' } - ? PhoneNumberInputValue - : T extends { type: 'FidoRegistrationCollector' } - ? FidoRegistrationInputValue - : T extends { type: 'FidoAuthenticationCollector' } - ? FidoAuthenticationInputValue - : T extends { category: 'SingleValueCollector' } - ? string - : T extends { category: 'ValidatedSingleValueCollector' } + : T extends { type: 'DeviceAuthenticationCollector' } + ? string + : T extends { type: 'PhoneNumberCollector' } + ? PhoneNumberInputValue + : T extends { type: 'FidoRegistrationCollector' } + ? FidoRegistrationInputValue + : T extends { type: 'FidoAuthenticationCollector' } + ? FidoAuthenticationInputValue + : T extends { category: 'SingleValueCollector' } ? string - : T extends { category: 'MultiValueCollector' } - ? string[] - : - | string - | string[] - | PhoneNumberInputValue - | FidoRegistrationInputValue - | FidoAuthenticationInputValue; + : T extends { category: 'ValidatedSingleValueCollector' } + ? string + : T extends { category: 'MultiValueCollector' } + ? string[] + : + | string + | string[] + | PhoneNumberInputValue + | FidoRegistrationInputValue + | FidoAuthenticationInputValue; /** * Generic updater function that accepts values appropriate for the collector type. diff --git a/packages/davinci-client/src/lib/collector.types.test-d.ts b/packages/davinci-client/src/lib/collector.types.test-d.ts index ecaabcbc33..64c8228916 100644 --- a/packages/davinci-client/src/lib/collector.types.test-d.ts +++ b/packages/davinci-client/src/lib/collector.types.test-d.ts @@ -14,6 +14,7 @@ import type { ActionCollectorNoUrl, TextCollector, PasswordCollector, + ValidatedPasswordCollector, FlowCollector, IdpCollector, SubmitCollector, @@ -36,6 +37,7 @@ import type { PhoneNumberExtensionInputValue, PhoneNumberExtensionOutputValue, } from './collector.types.js'; +import type { PasswordPolicy } from './davinci.types.js'; describe('Collector Types', () => { describe('SingleValueCollector Types', () => { @@ -50,20 +52,37 @@ describe('Collector Types', () => { }); it('should validate PasswordCollector structure', () => { - expectTypeOf().toMatchTypeOf< - SingleValueCollectorNoValue<'PasswordCollector'> - >(); expectTypeOf() .toHaveProperty('category') .toEqualTypeOf<'SingleValueCollector'>(); - expectTypeOf().toHaveProperty('type'); + expectTypeOf().toHaveProperty('type').toEqualTypeOf<'PasswordCollector'>(); expectTypeOf().toEqualTypeOf<{ key: string; label: string; type: string; + verify: boolean; }>(); }); + it('should validate ValidatedPasswordCollector structure', () => { + expectTypeOf() + .toHaveProperty('category') + .toEqualTypeOf<'SingleValueCollector'>(); + expectTypeOf() + .toHaveProperty('type') + .toEqualTypeOf<'ValidatedPasswordCollector'>(); + expectTypeOf() + .toHaveProperty('verify') + .toEqualTypeOf(); + expectTypeOf() + .toHaveProperty('validation') + .toEqualTypeOf(); + }); + + it('should validate PasswordCollector input does NOT have validation', () => { + expectTypeOf().not.toHaveProperty('validation'); + }); + it('should validate SingleCollector structure', () => { expectTypeOf().toMatchTypeOf< SingleValueCollectorWithValue<'SingleSelectCollector'> @@ -274,11 +293,35 @@ describe('Collector Types', () => { key: '', label: '', type: '', + verify: false, }, }; expectTypeOf(tCollector).toMatchTypeOf(); }); + it('should correctly infer ValidatedPasswordCollector Type', () => { + const tCollector: InferSingleValueCollectorType<'ValidatedPasswordCollector'> = { + category: 'SingleValueCollector', + error: null, + type: 'ValidatedPasswordCollector', + id: '', + name: '', + input: { + key: '', + value: '', + type: '', + validation: {}, + }, + output: { + key: '', + label: '', + type: '', + verify: false, + }, + }; + + expectTypeOf(tCollector).toMatchTypeOf(); + }); it('should correctly infer SingleValueCollector Type', () => { const tCollector: InferSingleValueCollectorType<'SingleValueCollector'> = { category: 'SingleValueCollector', diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index cb9f8f1787..306dcf32e8 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -5,7 +5,11 @@ * of the MIT license. See the LICENSE file for details. */ -import type { FidoAuthenticationOptions, FidoRegistrationOptions } from './davinci.types.js'; +import type { + FidoAuthenticationOptions, + FidoRegistrationOptions, + PasswordPolicy, +} from './davinci.types.js'; /** ********************************************************************* * SINGLE-VALUE COLLECTORS @@ -16,6 +20,7 @@ import type { FidoAuthenticationOptions, FidoRegistrationOptions } from './davin */ export type SingleValueCollectorTypes = | 'PasswordCollector' + | 'ValidatedPasswordCollector' | 'SingleValueCollector' | 'SingleSelectCollector' | 'SingleSelectObjectCollector' @@ -157,14 +162,16 @@ export type InferSingleValueCollectorType = ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector - : /** - * At this point, we have not passed in a collector type - * or we have explicitly passed in 'SingleValueCollector' - * So we can return either a SingleValueCollector with value - * or without a value. - **/ - | SingleValueCollectorWithValue<'SingleValueCollector'> - | SingleValueCollectorNoValue<'SingleValueCollector'>; + : T extends 'ValidatedPasswordCollector' + ? ValidatedPasswordCollector + : /** + * At this point, we have not passed in a collector type + * or we have explicitly passed in 'SingleValueCollector' + * So we can return either a SingleValueCollector with value + * or without a value. + **/ + | SingleValueCollectorWithValue<'SingleValueCollector'> + | SingleValueCollectorNoValue<'SingleValueCollector'>; /** * SINGLE-VALUE COLLECTOR TYPES @@ -174,13 +181,51 @@ export type SingleValueCollector = | SingleValueCollectorNoValue; export type SingleValueCollectors = - | SingleValueCollectorNoValue<'PasswordCollector'> + | PasswordCollector + | ValidatedPasswordCollector | SingleSelectCollectorWithValue<'SingleSelectCollector'> | SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorWithValue<'TextCollector'> | ValidatedSingleValueCollectorWithValue<'TextCollector'>; -export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'>; +export interface PasswordCollector { + category: 'SingleValueCollector'; + error: string | null; + type: 'PasswordCollector'; + id: string; + name: string; + input: { + key: string; + value: string | number | boolean; + type: string; + }; + output: { + key: string; + label: string; + type: string; + verify: boolean; + }; +} + +export interface ValidatedPasswordCollector { + category: 'SingleValueCollector'; + error: string | null; + type: 'ValidatedPasswordCollector'; + id: string; + name: string; + input: { + key: string; + value: string | number | boolean; + type: string; + validation: PasswordPolicy; + }; + output: { + key: string; + label: string; + type: string; + verify: boolean; + }; +} export type TextCollector = SingleValueCollectorWithValue<'TextCollector'>; export type SingleSelectCollector = SingleSelectCollectorWithValue<'SingleSelectCollector'>; export type ValidatedTextCollector = ValidatedSingleValueCollectorWithValue<'TextCollector'>; diff --git a/packages/davinci-client/src/lib/collector.utils.test.ts b/packages/davinci-client/src/lib/collector.utils.test.ts index 7b47a6f22f..8db925e882 100644 --- a/packages/davinci-client/src/lib/collector.utils.test.ts +++ b/packages/davinci-client/src/lib/collector.utils.test.ts @@ -12,6 +12,7 @@ import { returnSubmitCollector, returnSingleValueCollector, returnPasswordCollector, + returnValidatedPasswordCollector, returnTextCollector, returnSingleSelectCollector, returnMultiSelectCollector, @@ -25,10 +26,12 @@ import { returnQrCodeCollector, returnAgreementCollector, } from './collector.utils.js'; +import { returnPasswordPolicyValidator } from './password-policy.rules.js'; import type { DaVinciField, DeviceAuthenticationField, DeviceRegistrationField, + PasswordField, FidoAuthenticationField, FidoRegistrationField, PhoneNumberExtensionField, @@ -321,6 +324,7 @@ describe('Single Value Collectors', () => { key: mockField.key, label: mockField.label, type: mockField.type, + verify: false, }, }); expect(result.output).not.toHaveProperty('value'); @@ -342,9 +346,26 @@ describe('Single Value Collectors', () => { describe('Specialized Single Value Collectors', () => { it('creates a password collector', () => { - const result = returnPasswordCollector(mockField, 1); + const passwordField: PasswordField = { + type: 'PASSWORD', + key: 'password', + label: 'Password', + }; + const result = returnPasswordCollector(passwordField, 1); expect(result.type).toBe('PasswordCollector'); expect(result.output).not.toHaveProperty('value'); + expect(result.output.verify).toBe(false); + }); + + it('propagates verify: true from a PASSWORD field onto the PasswordCollector', () => { + const passwordField: PasswordField = { + type: 'PASSWORD', + key: 'password', + label: 'Password', + verify: true, + }; + const result = returnPasswordCollector(passwordField, 1); + expect(result.output.verify).toBe(true); }); it('creates a text collector', () => { @@ -1290,3 +1311,223 @@ describe('Return collector validator', () => { ); }); }); + +describe('returnValidatedPasswordCollector', () => { + const mockPasswordPolicy = { + id: '39cad7af-3c2f-4672-9c3f-c47e5169e582', + name: 'Standard', + length: { min: 8, max: 255 }, + minCharacters: { + '~!@#$%^&*()-_=+[]{}|;:,.<>/?': 1, + '0123456789': 1, + ABCDEFGHIJKLMNOPQRSTUVWXYZ: 1, + abcdefghijklmnopqrstuvwxyz: 1, + }, + }; + + it('should create a ValidatedPasswordCollector with embedded passwordPolicy', () => { + const field: PasswordField = { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + required: true, + passwordPolicy: mockPasswordPolicy, + }; + + const result = returnValidatedPasswordCollector(field, 0); + + expect(result).toEqual({ + category: 'SingleValueCollector', + error: null, + type: 'ValidatedPasswordCollector', + id: 'user.password-0', + name: 'user.password', + input: { + key: 'user.password', + value: '', + type: 'PASSWORD_VERIFY', + validation: mockPasswordPolicy, + }, + output: { + key: 'user.password', + label: 'Password', + type: 'PASSWORD_VERIFY', + verify: false, + }, + }); + }); + + it('should propagate verify: true from the field onto the collector', () => { + const field: PasswordField = { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + verify: true, + passwordPolicy: mockPasswordPolicy, + }; + + const result = returnValidatedPasswordCollector(field, 0); + + expect(result.output.verify).toBe(true); + }); + + it('should fall back to an empty policy when called directly with a field that has no policy', () => { + // The reducer selects returnPasswordCollector when a field has no passwordPolicy (for both + // PASSWORD and PASSWORD_VERIFY types), so this field would normally never reach + // returnValidatedPasswordCollector. This test exercises the factory's defensive fallback + // directly, covering callers who bypass the reducer and invoke it without a policy attached. + const field: PasswordField = { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + }; + + const result = returnValidatedPasswordCollector(field, 1); + + expect(result.input.validation).toEqual({}); + expect(result.output.verify).toBe(false); + }); + + it('should record errors when field is missing properties', () => { + const invalidField = {} as PasswordField; + const result = returnValidatedPasswordCollector(invalidField, 0); + expect(result.error).toContain('Key is not found'); + expect(result.error).toContain('Label is not found'); + expect(result.error).toContain('Type is not found'); + }); +}); + +describe('returnPasswordPolicyValidator', () => { + const makeCollector = (passwordPolicy?: Record) => { + const field: PasswordField = { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + ...(passwordPolicy && { passwordPolicy }), + } as PasswordField; + return returnValidatedPasswordCollector(field, 0); + }; + + it('should return an empty array when the collector has no passwordPolicy', () => { + const validate = returnPasswordPolicyValidator(makeCollector()); + expect(validate('anything')).toEqual([]); + }); + + it('should return an empty array when the value satisfies all policy rules', () => { + const validate = returnPasswordPolicyValidator( + makeCollector({ + length: { min: 8, max: 20 }, + minUniqueCharacters: 5, + maxRepeatedCharacters: 2, + minCharacters: { '0123456789': 1, '!@#$%^&*()': 1 }, + }), + ); + expect(validate('Valid1@Password')).toEqual([]); + }); + + describe('length rule', () => { + it('should fail with a range message when value is shorter than length.min and max is set', () => { + const validate = returnPasswordPolicyValidator( + makeCollector({ length: { min: 8, max: 20 } }), + ); + const errors = validate('short'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('8'); + expect(errors[0]).toContain('20'); + }); + + it('should fail when value is longer than length.max', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ length: { min: 1, max: 4 } })); + expect(validate('toolong')).toHaveLength(1); + }); + + it('should check only the lower bound when length.max is undefined', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ length: { min: 8 } })); + expect(validate('short')).toHaveLength(1); + expect(validate('longenough')).toEqual([]); + }); + + it('should check only the upper bound when length.min is undefined', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ length: { max: 4 } })); + expect(validate('toolong')).toHaveLength(1); + expect(validate('ok')).toEqual([]); + }); + + it('should skip the length check entirely when both min and max are undefined', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ length: {} })); + expect(validate('')).toEqual([]); + expect(validate('anything-at-all')).toEqual([]); + }); + }); + + describe('minUniqueCharacters rule', () => { + it('should fail when the count of distinct characters is below the minimum', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ minUniqueCharacters: 5 })); + const errors = validate('aaa111@@@'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('5'); + }); + + it('should pass when the count of distinct characters meets the minimum', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ minUniqueCharacters: 3 })); + expect(validate('abc')).toEqual([]); + }); + }); + + describe('maxRepeatedCharacters rule', () => { + it('should fail based on total occurrences of any character, not only consecutive runs', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ maxRepeatedCharacters: 2 })); + const errors = validate('aXaXaX'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('2'); + }); + + it('should pass when no character appears more than the maximum', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ maxRepeatedCharacters: 2 })); + expect(validate('abcabc')).toEqual([]); + }); + }); + + describe('minCharacters rule', () => { + it('should fail when the value contains fewer characters from the required charset than required', () => { + const validate = returnPasswordPolicyValidator( + makeCollector({ minCharacters: { '0123456789': 2 } }), + ); + const errors = validate('Password@1'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('2'); + expect(errors[0]).toContain('0123456789'); + }); + + it('should pass when enough characters from the required charset are present', () => { + const validate = returnPasswordPolicyValidator( + makeCollector({ minCharacters: { '!@#$%^&*()': 2 } }), + ); + expect(validate('hello@world!')).toEqual([]); + }); + + it('should emit one error per failing charset when multiple are required', () => { + const validate = returnPasswordPolicyValidator( + makeCollector({ + minCharacters: { + '0123456789': 1, + ABCDEFGHIJKLMNOPQRSTUVWXYZ: 1, + }, + }), + ); + const errors = validate('lowercaseonly'); + expect(errors).toHaveLength(2); + }); + }); + + it('should accumulate errors from multiple failing rules', () => { + const validate = returnPasswordPolicyValidator( + makeCollector({ + length: { min: 12, max: 20 }, + minUniqueCharacters: 10, + minCharacters: { '0123456789': 1 }, + }), + ); + expect(validate('aaa').length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/packages/davinci-client/src/lib/collector.utils.ts b/packages/davinci-client/src/lib/collector.utils.ts index 4d639d9381..e51b877d89 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -32,6 +32,8 @@ import type { QrCodeCollectorBase, AgreementCollector, PhoneNumberExtensionOutputValue, + PasswordCollector, + ValidatedPasswordCollector, } from './collector.types.js'; import type { DeviceAuthenticationField, @@ -39,6 +41,7 @@ import type { FidoAuthenticationField, FidoRegistrationField, MultiSelectField, + PasswordField, PhoneNumberField, ProtectField, QrCodeField, @@ -151,7 +154,7 @@ export function returnSubmitCollector(field: StandardField, idx: number) { * @returns {SingleValueCollector} The constructed SingleValueCollector object. */ export function returnSingleValueCollector< - Field extends StandardField | SingleSelectField | ValidatedField, + Field extends StandardField | SingleSelectField | ValidatedField | PasswordField, CollectorType extends SingleValueCollectorTypes = 'SingleValueCollector', >(field: Field, idx: number, collectorType: CollectorType, data?: string) { let error = ''; @@ -166,6 +169,7 @@ export function returnSingleValueCollector< } if (collectorType === 'PasswordCollector') { + const verify = 'verify' in field ? field.verify === true : false; return { category: 'SingleValueCollector', error: error || null, @@ -181,7 +185,32 @@ export function returnSingleValueCollector< key: field.key, label: field.label, type: field.type, - // No default or existing value is passed + verify, + }, + } as InferSingleValueCollectorType; + } else if (collectorType === 'ValidatedPasswordCollector') { + // Reducer routes here only when passwordPolicy is present, but fallback to {} keeps the + // factory total and consistent with the type definition (passwordPolicy is optional). + const validation = + 'passwordPolicy' in field && field.passwordPolicy ? field.passwordPolicy : {}; + const verify = 'verify' in field ? field.verify === true : false; + return { + category: 'SingleValueCollector', + error: error || null, + type: collectorType, + id: `${field?.key}-${idx}`, + name: field.key, + input: { + key: field.key, + value: '', + type: field.type, + validation, + }, + output: { + key: field.key, + label: field.label, + type: field.type, + verify, }, } as InferSingleValueCollectorType; } else if (collectorType === 'SingleSelectCollector') { @@ -435,13 +464,33 @@ export function returnObjectValueAutoCollector< } /** - * @function returnPasswordCollector - Creates a PasswordCollector object based on the provided field and index. - * @param {DaVinciField} field - The field object containing key, label, type, and links. + * @function returnPasswordCollector - Creates a PasswordCollector (no password policy). + * @param {PasswordField} field - The PASSWORD / PASSWORD_VERIFY field; a `verify` flag is + * propagated to the collector output if set. * @param {number} idx - The index to be used in the id of the PasswordCollector. * @returns {PasswordCollector} The constructed PasswordCollector object. */ -export function returnPasswordCollector(field: StandardField, idx: number) { - return returnSingleValueCollector(field, idx, 'PasswordCollector'); +export function returnPasswordCollector(field: PasswordField, idx: number): PasswordCollector { + return returnSingleValueCollector(field, idx, 'PasswordCollector') as PasswordCollector; +} + +/** + * @function returnValidatedPasswordCollector - Creates a ValidatedPasswordCollector for + * fields that carry a `passwordPolicy`. The reducer routes both `PASSWORD` and + * `PASSWORD_VERIFY` here when a policy is present. + * @param {PasswordField} field - The PASSWORD or PASSWORD_VERIFY field carrying a passwordPolicy. + * @param {number} idx - The index of the field in the form. + * @returns {ValidatedPasswordCollector} The constructed ValidatedPasswordCollector. + */ +export function returnValidatedPasswordCollector( + field: PasswordField, + idx: number, +): ValidatedPasswordCollector { + return returnSingleValueCollector( + field, + idx, + 'ValidatedPasswordCollector', + ) as ValidatedPasswordCollector; } /** diff --git a/packages/davinci-client/src/lib/davinci.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index b73488fd3a..20edd9b60b 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -53,14 +53,7 @@ export interface Links { } export type StandardField = { - type: - | 'PASSWORD' - | 'PASSWORD_VERIFY' - | 'TEXT' - | 'SUBMIT_BUTTON' - | 'FLOW_BUTTON' - | 'FLOW_LINK' - | 'BUTTON'; + type: 'TEXT' | 'SUBMIT_BUTTON' | 'FLOW_BUTTON' | 'FLOW_LINK' | 'BUTTON'; key: string; label: string; @@ -68,6 +61,42 @@ export type StandardField = { required?: boolean; }; +export interface PasswordPolicy { + id?: string; + name?: string; + description?: string; + excludesProfileData?: boolean; + notSimilarToCurrent?: boolean; + excludesCommonlyUsed?: boolean; + maxAgeDays?: number; + minAgeDays?: number; + maxRepeatedCharacters?: number; + minUniqueCharacters?: number; + history?: { count?: number; retentionDays?: number }; + lockout?: { failureCount?: number; durationSeconds?: number }; + length?: { min?: number; max?: number }; + minCharacters?: Record; + populationCount?: number; + createdAt?: string; + updatedAt?: string; + default?: boolean; +} + +/** + * Raw server field shape for password inputs. The server tags the `type` as + * `PASSWORD_VERIFY` whenever `verify` is set, but our collector taxonomy ignores + * that — we pick `PasswordCollector` vs `ValidatedPasswordCollector` based on + * whether `passwordPolicy` is present. + */ +export type PasswordField = { + type: 'PASSWORD' | 'PASSWORD_VERIFY'; + key: string; + label: string; + required?: boolean; + verify?: boolean; + passwordPolicy?: PasswordPolicy; +}; + export type ReadOnlyField = { type: 'LABEL'; content: string; @@ -260,7 +289,12 @@ export type ComplexValueFields = export type MultiValueFields = MultiSelectField; export type ReadOnlyFields = ReadOnlyField | QrCodeField | AgreementField; export type RedirectFields = RedirectField; -export type SingleValueFields = StandardField | ValidatedField | SingleSelectField | ProtectField; +export type SingleValueFields = + | StandardField + | PasswordField + | ValidatedField + | SingleSelectField + | ProtectField; export type DaVinciField = | ComplexValueFields diff --git a/packages/davinci-client/src/lib/davinci.utils.test.ts b/packages/davinci-client/src/lib/davinci.utils.test.ts index 81a12bcf40..094d4d945c 100644 --- a/packages/davinci-client/src/lib/davinci.utils.test.ts +++ b/packages/davinci-client/src/lib/davinci.utils.test.ts @@ -38,7 +38,7 @@ describe('transformSubmitRequest', () => { category: 'SingleValueCollector', error: null, input: { key: 'password', value: 'secret', type: 'PASSWORD' }, - output: { key: 'password', label: 'Password', type: 'PASSWORD' }, + output: { key: 'password', label: 'Password', type: 'PASSWORD', verify: false }, type: 'PasswordCollector', id: 'xyz', name: 'password', diff --git a/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts b/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts index 4962470b45..eb372de870 100644 --- a/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts +++ b/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts @@ -42,6 +42,49 @@ export const obj = { label: 'Password', required: true, }, + { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + required: true, + passwordPolicy: { + id: '39cad7af-3c2f-4672-9c3f-c47e5169e582', + environment: { + id: '02fb4743-189a-4bc7-9d6c-a919edfe6447', + }, + name: 'Standard', + description: 'A standard policy that incorporates industry best practices', + excludesProfileData: true, + notSimilarToCurrent: true, + excludesCommonlyUsed: true, + maxAgeDays: 182, + minAgeDays: 1, + maxRepeatedCharacters: 2, + minUniqueCharacters: 5, + history: { + count: 6, + retentionDays: 365, + }, + lockout: { + failureCount: 5, + durationSeconds: 900, + }, + length: { + min: 8, + max: 255, + }, + minCharacters: { + '~!@#$%^&*()-_=+[]{}|;:,.<>/?': 1, + '0123456789': 1, + ABCDEFGHIJKLMNOPQRSTUVWXYZ: 1, + abcdefghijklmnopqrstuvwxyz: 1, + }, + populationCount: 1, + createdAt: '2024-01-03T19:50:39.586Z', + updatedAt: '2024-01-03T19:50:39.586Z', + default: true, + }, + }, { type: 'SUBMIT_BUTTON', label: 'Sign On', @@ -140,6 +183,7 @@ export const obj = { value: { 'user.username': '', password: '', + 'user.password': '', 'dropdown-field': '', 'combobox-field': '', 'radio-field': '', @@ -162,57 +206,13 @@ export const obj = { region: 'CA', themeId: 'activeTheme', formId: 'f0cf83ab-f8f4-4f4a-9260-8f7d27061fa7', - passwordPolicy: { - _links: { - environment: { - href: 'http://10.76.247.190:4140/directory-api/environments/02fb4743-189a-4bc7-9d6c-a919edfe6447', - }, - self: { - href: 'http://10.76.247.190:4140/directory-api/environments/02fb4743-189a-4bc7-9d6c-a919edfe6447/passwordPolicies/39cad7af-3c2f-4672-9c3f-c47e5169e582', - }, - }, - id: '39cad7af-3c2f-4672-9c3f-c47e5169e582', - environment: { - id: '02fb4743-189a-4bc7-9d6c-a919edfe6447', - }, - name: 'Standard', - description: 'A standard policy that incorporates industry best practices', - excludesProfileData: true, - notSimilarToCurrent: true, - excludesCommonlyUsed: true, - maxAgeDays: 182, - minAgeDays: 1, - maxRepeatedCharacters: 2, - minUniqueCharacters: 5, - history: { - count: 6, - retentionDays: 365, - }, - lockout: { - failureCount: 5, - durationSeconds: 900, - }, - length: { - min: 8, - max: 255, - }, - minCharacters: { - '~!@#$%^&*()-_=+[]{}|;:,.<>/?': 1, - '0123456789': 1, - ABCDEFGHIJKLMNOPQRSTUVWXYZ: 1, - abcdefghijklmnopqrstuvwxyz: 1, - }, - populationCount: 1, - createdAt: '2024-01-03T19:50:39.586Z', - updatedAt: '2024-01-03T19:50:39.586Z', - default: true, - }, isResponseCompatibleWithMobileAndWebSdks: true, fieldTypes: [ 'LABEL', 'ERROR_DISPLAY', 'TEXT', 'PASSWORD', + 'PASSWORD_VERIFY', 'RADIO', 'CHECKBOX', 'FLOW_LINK', diff --git a/packages/davinci-client/src/lib/mock-data/node.next.mock.ts b/packages/davinci-client/src/lib/mock-data/node.next.mock.ts index e32441326a..ee017c75b7 100644 --- a/packages/davinci-client/src/lib/mock-data/node.next.mock.ts +++ b/packages/davinci-client/src/lib/mock-data/node.next.mock.ts @@ -26,7 +26,7 @@ export const nodeNext0 = { id: 'password-1', name: 'password', input: { key: 'password', value: '', type: 'PASSWORD' }, - output: { key: 'password', label: 'Password', type: 'PASSWORD' }, + output: { key: 'password', label: 'Password', type: 'PASSWORD', verify: false }, }, { category: 'ActionCollector', diff --git a/packages/davinci-client/src/lib/node.reducer.test.ts b/packages/davinci-client/src/lib/node.reducer.test.ts index 2d2ba361ab..0e49fb876f 100644 --- a/packages/davinci-client/src/lib/node.reducer.test.ts +++ b/packages/davinci-client/src/lib/node.reducer.test.ts @@ -13,6 +13,8 @@ import type { FidoAuthenticationCollector, FidoRegistrationCollector, MultiSelectCollector, + PasswordCollector, + ValidatedPasswordCollector, PhoneNumberCollector, PhoneNumberExtensionCollector, PollingCollector, @@ -125,6 +127,7 @@ describe('The node collector reducer', () => { key: 'password', label: 'Password', type: 'PASSWORD', + verify: false, }, }, { @@ -203,6 +206,7 @@ describe('The node collector reducer', () => { key: 'password', label: 'Password', type: 'PASSWORD', + verify: false, }, }, { @@ -277,6 +281,7 @@ describe('The node collector reducer', () => { key: 'password', label: 'Password', type: 'PASSWORD', + verify: false, }, }, { @@ -1429,6 +1434,178 @@ describe('The node collector reducer with pollCollectorValues', () => { }); }); +describe('PASSWORD_VERIFY with password policy', () => { + const mockPasswordPolicy = { + id: '39cad7af-3c2f-4672-9c3f-c47e5169e582', + name: 'Standard', + description: 'A standard policy that incorporates industry best practices', + length: { min: 8, max: 255 }, + minCharacters: { + '~!@#$%^&*()-_=+[]{}|;:,.<>/?': 1, + '0123456789': 1, + ABCDEFGHIJKLMNOPQRSTUVWXYZ: 1, + abcdefghijklmnopqrstuvwxyz: 1, + }, + }; + + it('should produce ValidatedPasswordCollector with embedded passwordPolicy', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + required: true, + passwordPolicy: mockPasswordPolicy, + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result).toEqual([ + { + category: 'SingleValueCollector', + error: null, + type: 'ValidatedPasswordCollector', + id: 'user.password-0', + name: 'user.password', + input: { + key: 'user.password', + value: '', + type: 'PASSWORD_VERIFY', + validation: mockPasswordPolicy, + }, + output: { + key: 'user.password', + label: 'Password', + type: 'PASSWORD_VERIFY', + verify: false, + }, + } satisfies ValidatedPasswordCollector, + ]); + }); + + it('should produce PasswordCollector when a PASSWORD_VERIFY field has no policy', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result[0].type).toBe('PasswordCollector'); + }); + + it('should produce ValidatedPasswordCollector when a PASSWORD field has a policy', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'PASSWORD', + key: 'user.password', + label: 'Password', + passwordPolicy: mockPasswordPolicy, + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result[0].type).toBe('ValidatedPasswordCollector'); + expect((result[0] as ValidatedPasswordCollector).input.validation).toEqual(mockPasswordPolicy); + }); + + it('should propagate verify: true from the field onto PasswordCollector', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'PASSWORD', + key: 'password', + label: 'Password', + verify: true, + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result[0].type).toBe('PasswordCollector'); + expect((result[0] as PasswordCollector).output.verify).toBe(true); + }); + + it('should propagate verify: true from the field onto ValidatedPasswordCollector', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + verify: true, + passwordPolicy: mockPasswordPolicy, + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result[0].type).toBe('ValidatedPasswordCollector'); + expect((result[0] as ValidatedPasswordCollector).output.verify).toBe(true); + }); + + it('should default verify to false when the field omits it', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'PASSWORD', + key: 'password', + label: 'Password', + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect((result[0] as PasswordCollector).output.verify).toBe(false); + }); + + it('should still produce PasswordCollector for PASSWORD type (no regression)', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'PASSWORD', + key: 'password', + label: 'Password', + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result[0].type).toBe('PasswordCollector'); + expect((result[0] as PasswordCollector).output).not.toHaveProperty('passwordPolicy'); + expect((result[0] as PasswordCollector).input).not.toHaveProperty('validation'); + }); +}); + describe('The node collector reducer with FidoAuthenticationFieldValue', () => { it('should handle collector updates ', () => { // todo: declare inputValue type as FidoAuthenticationInputValue diff --git a/packages/davinci-client/src/lib/node.reducer.ts b/packages/davinci-client/src/lib/node.reducer.ts index 3c84780761..fe18315f34 100644 --- a/packages/davinci-client/src/lib/node.reducer.ts +++ b/packages/davinci-client/src/lib/node.reducer.ts @@ -16,6 +16,7 @@ import { returnActionCollector, returnFlowCollector, returnPasswordCollector, + returnValidatedPasswordCollector, returnIdpCollector, returnSubmitCollector, returnTextCollector, @@ -39,6 +40,7 @@ import type { SingleSelectCollector, FlowCollector, PasswordCollector, + ValidatedPasswordCollector, SingleValueCollector, IdpCollector, SubmitCollector, @@ -93,6 +95,7 @@ export const pollCollectorValues = createAction('node/poll'); const initialCollectorValues: ( | FlowCollector | PasswordCollector + | ValidatedPasswordCollector | TextCollector | IdpCollector | SubmitCollector @@ -178,10 +181,11 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build // Intentional fall-through return returnObjectSelectCollector(field, idx); } - case 'PASSWORD': - case 'PASSWORD_VERIFY': { - // No data to send - return returnPasswordCollector(field, idx); + case 'PASSWORD_VERIFY': + case 'PASSWORD': { + return field.passwordPolicy + ? returnValidatedPasswordCollector(field, idx) + : returnPasswordCollector(field, idx); } case 'PHONE_NUMBER': { const prefillData = data as diff --git a/packages/davinci-client/src/lib/node.types.test-d.ts b/packages/davinci-client/src/lib/node.types.test-d.ts index 86c3a4ecea..4ffef1c3d8 100644 --- a/packages/davinci-client/src/lib/node.types.test-d.ts +++ b/packages/davinci-client/src/lib/node.types.test-d.ts @@ -21,6 +21,7 @@ import { FlowCollector, MultiSelectCollector, PasswordCollector, + ValidatedPasswordCollector, ReadOnlyCollector, SingleSelectCollector, SingleValueCollector, @@ -225,6 +226,7 @@ describe('Node Types', () => { expectTypeOf().toMatchTypeOf< | TextCollector | PasswordCollector + | ValidatedPasswordCollector | FlowCollector | IdpCollector | SubmitCollector @@ -265,7 +267,7 @@ describe('Node Types', () => { id: 'test', name: 'Test', input: { key: 'test', value: '', type: 'string' }, - output: { key: 'test', label: 'Test', type: 'string' }, + output: { key: 'test', label: 'Test', type: 'string', verify: false }, }, ]; diff --git a/packages/davinci-client/src/lib/node.types.ts b/packages/davinci-client/src/lib/node.types.ts index 52759bf695..f9fdb6f4c5 100644 --- a/packages/davinci-client/src/lib/node.types.ts +++ b/packages/davinci-client/src/lib/node.types.ts @@ -9,6 +9,7 @@ import { GenericError } from '@forgerock/sdk-types'; import type { FlowCollector, PasswordCollector, + ValidatedPasswordCollector, TextCollector, IdpCollector, SubmitCollector, @@ -35,6 +36,7 @@ import type { Links } from './davinci.types.js'; export type Collectors = | FlowCollector | PasswordCollector + | ValidatedPasswordCollector | TextCollector | SingleSelectCollector | IdpCollector diff --git a/packages/davinci-client/src/lib/password-policy.rules.ts b/packages/davinci-client/src/lib/password-policy.rules.ts new file mode 100644 index 0000000000..399a80d9e7 --- /dev/null +++ b/packages/davinci-client/src/lib/password-policy.rules.ts @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import { Array as Arr, Option, pipe } from 'effect'; + +import type { ValidatedPasswordCollector } from './collector.types.js'; +import type { PasswordPolicy } from './davinci.types.js'; + +/** + * A single policy check: given the policy and a candidate value, produce zero or more + * human-readable error strings. Rules are pure and independent — new ones can be added + * by extending `passwordPolicyRules` below. + */ +type PasswordPolicyRule = (policy: PasswordPolicy, value: string) => readonly string[]; + +const countChars = (value: string): ReadonlyMap => { + const counts = new Map(); + for (const ch of value) counts.set(ch, (counts.get(ch) ?? 0) + 1); + return counts; +}; + +const formatLengthMessage = (min?: number, max?: number): string => { + if (min != null && max != null) return `Password must be between ${min} and ${max} characters`; + if (min != null) return `Password must be at least ${min} characters`; + return `Password must be at most ${max} characters`; +}; + +const lengthRule: PasswordPolicyRule = (policy, value) => { + const length = policy.length; + if (!length) return []; + const { min, max } = length; + if (min == null && max == null) return []; + const outOfRange = (min != null && value.length < min) || (max != null && value.length > max); + return outOfRange ? [formatLengthMessage(min, max)] : []; +}; + +const minUniqueCharactersRule: PasswordPolicyRule = (policy, value) => { + const min = policy.minUniqueCharacters; + if (min == null) return []; + return new Set(value).size < min + ? [`Password must contain at least ${min} unique characters`] + : []; +}; + +const maxRepeatedCharactersRule: PasswordPolicyRule = (policy, value) => { + const max = policy.maxRepeatedCharacters; + if (max == null) return []; + const maxCount = pipe( + countChars(value), + (counts) => Array.from(counts.values()), + Arr.reduce(0, (acc, n) => (n > acc ? n : acc)), + ); + return maxCount > max ? [`Password cannot repeat any character more than ${max} times`] : []; +}; + +const minCharactersRule: PasswordPolicyRule = (policy, value) => { + if (!policy.minCharacters) return []; + return pipe( + Object.entries(policy.minCharacters), + Arr.filterMap(([charset, min]) => { + const members = new Set(charset); + let hits = 0; + for (const ch of value) if (members.has(ch)) hits += 1; + return hits < min + ? Option.some(`Password must contain at least ${min} character(s) from "${charset}"`) + : Option.none(); + }), + ); +}; + +const passwordPolicyRules: readonly PasswordPolicyRule[] = [ + lengthRule, + minUniqueCharactersRule, + maxRepeatedCharactersRule, + minCharactersRule, +]; + +/** + * @function returnPasswordPolicyValidator - Creates a validator function that checks a candidate + * value against the `passwordPolicy` embedded on a `ValidatedPasswordCollector`. Rules mirror the + * native SDKs: length bounds, minimum unique characters, maximum repeated character occurrences, + * and per-charset minimums. Returns `[]` when no policy is present on the collector. + * @param {ValidatedPasswordCollector} collector - The collector whose output may carry a passwordPolicy. + * @returns {(value: string) => string[]} - A validator that returns human-readable error strings. + */ +export function returnPasswordPolicyValidator( + collector: ValidatedPasswordCollector, +): (value: string) => string[] { + const policy = collector.input.validation; + return (value: string) => + policy + ? pipe( + passwordPolicyRules, + Arr.flatMap((rule) => rule(policy, value)), + ) + : []; +} diff --git a/packages/davinci-client/src/lib/updater-narrowing.types.test-d.ts b/packages/davinci-client/src/lib/updater-narrowing.types.test-d.ts index 7ba8b20d75..ca7d78b180 100644 --- a/packages/davinci-client/src/lib/updater-narrowing.types.test-d.ts +++ b/packages/davinci-client/src/lib/updater-narrowing.types.test-d.ts @@ -10,6 +10,7 @@ import { describe, expectTypeOf, it } from 'vitest'; import type { Updater } from './client.types.js'; import type { PasswordCollector, + ValidatedPasswordCollector, TextCollector, ValidatedTextCollector, SingleSelectCollector, @@ -29,6 +30,7 @@ import type { Collectors } from './node.types.js'; type MockUpdate = < T extends | PasswordCollector + | ValidatedPasswordCollector | TextCollector | ValidatedTextCollector | SingleSelectCollector @@ -64,6 +66,22 @@ describe('Updater Type Narrowing with Real Usage Pattern', () => { } }); + it('ValidatedPasswordCollector should narrow collector to ValidatedPasswordCollector type', () => { + const collector = {} as Collectors; + + if (collector.type === 'ValidatedPasswordCollector') { + // 1. Collector itself should be narrowed to ValidatedPasswordCollector + expectTypeOf(collector).toEqualTypeOf(); + + // 2. update() should return Updater + const updater = mockUpdate(collector); + expectTypeOf(updater).toEqualTypeOf>(); + + // 3. The updater parameter should accept string + expectTypeOf(updater).parameter(0).toEqualTypeOf(); + } + }); + it('TextCollector should narrow collector to TextCollector | ValidatedTextCollector', () => { const collector = {} as Collectors; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index babd5d7de7..b0fdeec512 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -598,10 +598,10 @@ importers: version: 28.0.0 tsx: specifier: ^4.20.0 - version: 4.20.6 + version: 4.21.0 vitest: specifier: catalog:vitest - version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) + version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) devDependencies: '@forgerock/javascript-sdk': specifier: 4.9.0 @@ -11995,15 +11995,6 @@ snapshots: msw: 2.12.1(@types/node@24.9.2)(typescript@5.8.3) vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) - '@vitest/mocker@3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - msw: 2.12.1(@types/node@24.9.2)(typescript@5.9.3) - vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) - '@vitest/mocker@3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 @@ -17565,49 +17556,6 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1): - dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.2.2 - magic-string: 0.30.21 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.9.2 - '@vitest/ui': 3.2.4(vitest@3.2.4) - jsdom: 27.4.0(@noble/hashes@1.8.0) - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vitest@3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3