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 new collector is emitted whenever a password field carries a `passwordPolicy` — the presence of the policy is the sole discriminator between the two types, regardless of the server-side field tag (`PASSWORD` vs `PASSWORD_VERIFY`). `ValidatedPasswordCollector.output.passwordPolicy` is required; 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.
1 change: 1 addition & 0 deletions .nxignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.opensource/
1 change: 1 addition & 0 deletions .opensource/forgerock-javascript-sdk
Submodule forgerock-javascript-sdk added at 1e3f0d
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 @@ -234,13 +234,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
131 changes: 122 additions & 9 deletions packages/davinci-client/api-report/davinci-client.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,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 | 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 | UnknownCollector;

// @public
export type CollectorValueType<T> = T extends {
type: 'PasswordCollector';
} ? string : T extends {
type: 'ValidatedPasswordCollector';
} ? string : T extends {
Comment on lines +150 to 157
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Public API exposes the wrong password verify contract.

The report exports ValidatedPasswordCollector and requires output.passwordPolicy, but the PR objective describes a PasswordVerifyCollector whose passwordPolicy is optional and omitted when absent. This is a public API mismatch for consumers switching on collector type or handling PASSWORD_VERIFY without embedded policy.

Please update the source types to expose the intended PasswordVerifyCollector/PasswordVerifyField contract, then regenerate this API report.

Also applies to: 1006-1006, 1297-1331, 1644-1648, 1800-1826

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/davinci-client/api-report/davinci-client.api.md` around lines 150 -
157, The public API currently exposes ValidatedPasswordCollector and requires
output.passwordPolicy, but the intended contract is
PasswordVerifyCollector/PasswordVerifyField where passwordPolicy is optional
(omitted when absent); update the source type declarations so the exported union
uses PasswordVerifyCollector instead of ValidatedPasswordCollector, change the
field/type name to PasswordVerifyField if necessary, make passwordPolicy
optional on that collector/type, ensure any discriminated union uses type:
'PasswordVerifyCollector' (or 'PASSWORD_VERIFY') for switching, then regenerate
the API report so davinci-client.api.md reflects the corrected exported types
and CollectorValueType behavior.

type: 'TextCollector';
category: 'SingleValueCollector';
Expand Down Expand Up @@ -1001,7 +1003,7 @@ export type InferMultiValueCollectorType<T extends MultiValueCollectorTypes> = T
export type InferNoValueCollectorType<T extends NoValueCollectorTypes> = T extends 'ReadOnlyCollector' ? NoValueCollectorBase<'ReadOnlyCollector'> : T extends 'QrCodeCollector' ? QrCodeCollectorBase : 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 @@ -1139,8 +1141,8 @@ value: Record<string, unknown>;
}, string>;

// @public
export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & {
getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | 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 | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & {
getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | ValidatedPasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[];
};

// @public (undocumented)
Expand Down Expand Up @@ -1292,7 +1294,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 @@ -1557,10 +1642,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 @@ -1590,11 +1675,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 @@ -1712,6 +1797,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