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
9 changes: 9 additions & 0 deletions .changeset/embed-password-policy-in-component.md
Original file line number Diff line number Diff line change
@@ -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.
103 changes: 90 additions & 13 deletions e2e/davinci-app/components/password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PasswordCollector>,
collector: PasswordCollector | ValidatedPasswordCollector,
updater: Updater<PasswordCollector | ValidatedPasswordCollector>,
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 passwordPolicy = collector.output.passwordPolicy;
const requirementsList = document.createElement('ul');
requirementsList.className = 'password-requirements';

if (passwordPolicy.length) {
const { min, max } = passwordPolicy.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 (passwordPolicy.minCharacters) {
for (const [charset, count] of Object.entries(passwordPolicy.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<HTMLUListElement>(`.${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);
}
});
}
10 changes: 7 additions & 3 deletions e2e/davinci-app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
143 changes: 128 additions & 15 deletions packages/davinci-client/api-report/davinci-client.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector;

// @public
export type CollectorValueType<T> = T extends {
type: 'PasswordCollector';
} ? string : T extends {
type: 'ValidatedPasswordCollector';
} ? string : T extends {
type: 'TextCollector';
category: 'SingleValueCollector';
Expand Down Expand Up @@ -267,13 +269,11 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
resume: (input: {
continueToken: string;
}) => Promise<InternalErrorResponse | NodeStates>;
start: <QueryParams extends OutgoingQueryParams = OutgoingQueryParams>(options?: StartOptions<QueryParams> | undefined) => Promise<ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode>;
start: <QueryParams extends OutgoingQueryParams = OutgoingQueryParams>(options?: StartOptions<QueryParams> | undefined) => Promise<ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode>;
update: <T extends SingleValueCollectors | MultiSelectCollector | ObjectValueCollectors | AutoCollectors>(collector: T) => Updater<T>;
validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator;
poll: (collector: PollingCollector) => Poller;
getClient: () => {
status: "start";
} | {
action: string;
collectors: Collectors[];
description?: string;
Expand All @@ -287,6 +287,8 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
status: "error";
} | {
status: "failure";
} | {
status: "start";
} | {
authorization?: {
code?: string;
Expand All @@ -297,7 +299,7 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(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;
Expand All @@ -306,8 +308,6 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
href?: string;
eventName?: string;
status: "continue";
} | {
status: "start";
} | {
_links?: Links;
eventName?: string;
Expand All @@ -323,6 +323,8 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
interactionId?: string;
interactionToken?: string;
status: "failure";
} | {
status: "start";
} | {
_links?: Links;
eventName?: string;
Expand Down Expand Up @@ -1032,7 +1034,7 @@ export type InferMultiValueCollectorType<T extends MultiValueCollectorTypes> = T
export type InferNoValueCollectorType<T extends NoValueCollectorTypes> = T extends 'ReadOnlyCollector' ? NoValueCollectorBase<'ReadOnlyCollector'> : T extends 'QrCodeCollector' ? QrCodeCollectorBase : T extends 'AgreementCollector' ? AgreementCollector : NoValueCollectorBase<'NoValueCollector'>;

// @public
export type InferSingleValueCollectorType<T extends SingleValueCollectorTypes> = 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 SingleValueCollectorTypes> = 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 ObjectValueCollectorTypes> = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>;
Expand Down Expand Up @@ -1170,8 +1172,8 @@ value: Record<string, unknown>;
}, 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 | 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 | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[];
};

// @public (undocumented)
Expand Down Expand Up @@ -1323,7 +1325,90 @@ 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<string, number>;
// (undocumented)
minUniqueCharacters?: number;
// (undocumented)
name?: string;
// (undocumented)
notSimilarToCurrent?: boolean;
// (undocumented)
populationCount?: number;
// (undocumented)
updatedAt?: string;
}

// @public (undocumented)
export type PhoneNumberCollector = ObjectValueCollectorWithObjectValue<'PhoneNumberCollector', PhoneNumberInputValue, PhoneNumberOutputValue>;
Expand Down Expand Up @@ -1588,10 +1673,10 @@ export interface SingleValueCollectorNoValue<T extends SingleValueCollectorTypes
}

// @public (undocumented)
export type SingleValueCollectors = SingleValueCollectorNoValue<'PasswordCollector'> | 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<T extends SingleValueCollectorTypes> {
Expand Down Expand Up @@ -1621,11 +1706,11 @@ export interface SingleValueCollectorWithValue<T extends SingleValueCollectorTyp
}

// @public (undocumented)
export type SingleValueFields = StandardField | ValidatedField | SingleSelectField | ProtectField;
export type SingleValueFields = StandardField | PasswordField | ValidatedField | SingleSelectField | ProtectField;

// @public (undocumented)
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;
required?: boolean;
Expand Down Expand Up @@ -1743,6 +1828,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;
};
// (undocumented)
name: string;
// (undocumented)
output: {
key: string;
label: string;
type: string;
verify: boolean;
passwordPolicy: PasswordPolicy;
};
// (undocumented)
type: 'ValidatedPasswordCollector';
}

// @public (undocumented)
export interface ValidatedSingleValueCollectorWithValue<T extends SingleValueCollectorTypes> {
// (undocumented)
Expand Down
Loading
Loading