Skip to content

Commit df0ad30

Browse files
committed
feat(davinci-client): add ValidatedPasswordCollector with embedded password policy
DV-16053: PingOne moves password policy from the response root onto the PASSWORD_VERIFY field. Surfaces a typed ValidatedPasswordCollector that exposes the embedded policy and validates input against it, while leaving plain PASSWORD fields on the simpler PasswordCollector path. - Splits PASSWORD vs PASSWORD_VERIFY into PasswordCollector and ValidatedPasswordCollector; reducer discriminates by field.type. - New password-policy.rules.ts with pure rule functions (length, minUniqueCharacters, maxRepeatedCharacters, minCharacters) and returnPasswordPolicyValidator() for use by client.validate(). - client.validate() routes ValidatedPasswordCollector through the policy validator and rejects plain PasswordCollector with a typed error. - Updates collector type plumbing, mock data, sample app rendering, and regenerates API reports.
1 parent c82e795 commit df0ad30

21 files changed

Lines changed: 1193 additions & 141 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@forgerock/davinci-client': minor
3+
---
4+
5+
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.
6+
7+
Both collectors now expose a `verify: boolean` on `output` (defaults to `false`), propagated from the field when the server sends `verify: true`.
8+
9+
`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.

e2e/davinci-app/components/password.ts

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,109 @@
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
66
*/
7-
import type { PasswordCollector, Updater } from '@forgerock/davinci-client/types';
7+
import type {
8+
PasswordCollector,
9+
ValidatedPasswordCollector,
10+
Updater,
11+
Validator,
12+
} from '@forgerock/davinci-client/types';
813
import { dotToCamelCase } from '../helper.js';
914

15+
const UPPERCASE_RE = /^[A-Z]+$/;
16+
const LOWERCASE_RE = /^[a-z]+$/;
17+
const DIGIT_RE = /^[0-9]+$/;
18+
1019
export default function passwordComponent(
1120
formEl: HTMLFormElement,
12-
collector: PasswordCollector,
13-
updater: Updater<PasswordCollector>,
21+
collector: PasswordCollector | ValidatedPasswordCollector,
22+
updater: Updater<PasswordCollector | ValidatedPasswordCollector>,
23+
validator?: Validator,
1424
) {
25+
const collectorKey = dotToCamelCase(collector.output.key);
1526
const label = document.createElement('label');
1627
const input = document.createElement('input');
1728

18-
label.htmlFor = dotToCamelCase(collector.output.key);
29+
label.htmlFor = collectorKey;
1930
label.innerText = collector.output.label;
2031
input.type = 'password';
21-
input.id = dotToCamelCase(collector.output.key);
22-
input.name = dotToCamelCase(collector.output.key);
32+
input.id = collectorKey;
33+
input.name = collectorKey;
2334

2435
formEl?.appendChild(label);
2536
formEl?.appendChild(input);
2637

27-
formEl
28-
?.querySelector(`#${dotToCamelCase(collector.output.key)}`)
29-
?.addEventListener('blur', (event: Event) => {
30-
const error = updater((event.target as HTMLInputElement).value);
31-
if (error && 'error' in error) {
32-
console.error(error.error.message);
38+
if (collector.type === 'ValidatedPasswordCollector') {
39+
const passwordPolicy = collector.output.passwordPolicy;
40+
const requirementsList = document.createElement('ul');
41+
requirementsList.className = 'password-requirements';
42+
43+
if (passwordPolicy.length) {
44+
const { min, max } = passwordPolicy.length;
45+
let lengthMessage: string | null = null;
46+
if (min != null && max != null) {
47+
lengthMessage = `${min}${max} characters`;
48+
} else if (min != null) {
49+
lengthMessage = `At least ${min} characters`;
50+
} else if (max != null) {
51+
lengthMessage = `At most ${max} characters`;
52+
}
53+
if (lengthMessage) {
54+
const li = document.createElement('li');
55+
li.textContent = lengthMessage;
56+
requirementsList.appendChild(li);
57+
}
58+
}
59+
60+
if (passwordPolicy.minCharacters) {
61+
for (const [charset, count] of Object.entries(passwordPolicy.minCharacters)) {
62+
const li = document.createElement('li');
63+
if (UPPERCASE_RE.test(charset)) {
64+
li.textContent = `At least ${count} uppercase letter(s)`;
65+
} else if (LOWERCASE_RE.test(charset)) {
66+
li.textContent = `At least ${count} lowercase letter(s)`;
67+
} else if (DIGIT_RE.test(charset)) {
68+
li.textContent = `At least ${count} number(s)`;
69+
} else {
70+
li.textContent = `At least ${count} special character(s)`;
71+
}
72+
requirementsList.appendChild(li);
3373
}
34-
});
74+
}
75+
76+
if (requirementsList.children.length > 0) {
77+
formEl?.appendChild(requirementsList);
78+
}
79+
}
80+
81+
const inputEl = formEl?.querySelector(`#${collectorKey}`);
82+
const shouldValidate = collector.type === 'ValidatedPasswordCollector' && !!validator;
83+
84+
inputEl?.addEventListener('input', (event: Event) => {
85+
const value = (event.target as HTMLInputElement).value;
86+
87+
if (shouldValidate) {
88+
const result = validator(value);
89+
if (Array.isArray(result) && result.length) {
90+
let errorEl = formEl?.querySelector<HTMLUListElement>(`.${collectorKey}-error`);
91+
if (!errorEl) {
92+
errorEl = document.createElement('ul');
93+
errorEl.className = `${collectorKey}-error`;
94+
inputEl.after(errorEl);
95+
}
96+
const items = result.map((msg) => {
97+
const li = document.createElement('li');
98+
li.textContent = msg;
99+
return li;
100+
});
101+
errorEl.replaceChildren(...items);
102+
return;
103+
}
104+
formEl?.querySelector(`.${collectorKey}-error`)?.remove();
105+
}
106+
107+
const error = updater(value);
108+
if (error && 'error' in error) {
109+
console.error(error.error.message);
110+
}
111+
});
35112
}

e2e/davinci-app/main.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,13 +237,17 @@ const urlParams = new URLSearchParams(window.location.search);
237237
davinciClient.update(collector), // Returns an update function for this collector
238238
davinciClient.validate(collector), // Returns a validate function for this collector
239239
);
240-
} else if (collector.type === 'PasswordCollector') {
241-
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
242-
collector;
240+
} else if (
241+
collector.type === 'PasswordCollector' ||
242+
collector.type === 'ValidatedPasswordCollector'
243+
) {
243244
passwordComponent(
244245
formEl, // You can ignore this; it's just for rendering
245246
collector, // This is the plain object of the collector
246247
davinciClient.update(collector), // Returns an update function for this collector
248+
collector.type === 'ValidatedPasswordCollector'
249+
? davinciClient.validate(collector)
250+
: undefined,
247251
);
248252
} else if (collector.type === 'SubmitCollector') {
249253
submitButtonComponent(

packages/davinci-client/api-report/davinci-client.api.md

Lines changed: 122 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,13 @@ export interface CollectorErrors {
178178
}
179179

180180
// @public (undocumented)
181-
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;
181+
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;
182182

183183
// @public
184184
export type CollectorValueType<T> = T extends {
185185
type: 'PasswordCollector';
186+
} ? string : T extends {
187+
type: 'ValidatedPasswordCollector';
186188
} ? string : T extends {
187189
type: 'TextCollector';
188190
category: 'SingleValueCollector';
@@ -1032,7 +1034,7 @@ export type InferMultiValueCollectorType<T extends MultiValueCollectorTypes> = T
10321034
export type InferNoValueCollectorType<T extends NoValueCollectorTypes> = T extends 'ReadOnlyCollector' ? NoValueCollectorBase<'ReadOnlyCollector'> : T extends 'QrCodeCollector' ? QrCodeCollectorBase : T extends 'AgreementCollector' ? AgreementCollector : NoValueCollectorBase<'NoValueCollector'>;
10331035

10341036
// @public
1035-
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'>;
1037+
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'>;
10361038

10371039
// @public (undocumented)
10381040
export type InferValueObjectCollectorType<T extends ObjectValueCollectorTypes> = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>;
@@ -1170,8 +1172,8 @@ value: Record<string, unknown>;
11701172
}, string>;
11711173

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

11771179
// @public (undocumented)
@@ -1323,7 +1325,90 @@ export interface OutgoingQueryParams {
13231325
}
13241326

13251327
// @public (undocumented)
1326-
export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'>;
1328+
export interface PasswordCollector {
1329+
// (undocumented)
1330+
category: 'SingleValueCollector';
1331+
// (undocumented)
1332+
error: string | null;
1333+
// (undocumented)
1334+
id: string;
1335+
// (undocumented)
1336+
input: {
1337+
key: string;
1338+
value: string | number | boolean;
1339+
type: string;
1340+
};
1341+
// (undocumented)
1342+
name: string;
1343+
// (undocumented)
1344+
output: {
1345+
key: string;
1346+
label: string;
1347+
type: string;
1348+
verify: boolean;
1349+
};
1350+
// (undocumented)
1351+
type: 'PasswordCollector';
1352+
}
1353+
1354+
// @public
1355+
export type PasswordField = {
1356+
type: 'PASSWORD' | 'PASSWORD_VERIFY';
1357+
key: string;
1358+
label: string;
1359+
required?: boolean;
1360+
verify?: boolean;
1361+
passwordPolicy?: PasswordPolicy;
1362+
};
1363+
1364+
// @public (undocumented)
1365+
export interface PasswordPolicy {
1366+
// (undocumented)
1367+
createdAt?: string;
1368+
// (undocumented)
1369+
default?: boolean;
1370+
// (undocumented)
1371+
description?: string;
1372+
// (undocumented)
1373+
excludesCommonlyUsed?: boolean;
1374+
// (undocumented)
1375+
excludesProfileData?: boolean;
1376+
// (undocumented)
1377+
history?: {
1378+
count?: number;
1379+
retentionDays?: number;
1380+
};
1381+
// (undocumented)
1382+
id?: string;
1383+
// (undocumented)
1384+
length?: {
1385+
min?: number;
1386+
max?: number;
1387+
};
1388+
// (undocumented)
1389+
lockout?: {
1390+
failureCount?: number;
1391+
durationSeconds?: number;
1392+
};
1393+
// (undocumented)
1394+
maxAgeDays?: number;
1395+
// (undocumented)
1396+
maxRepeatedCharacters?: number;
1397+
// (undocumented)
1398+
minAgeDays?: number;
1399+
// (undocumented)
1400+
minCharacters?: Record<string, number>;
1401+
// (undocumented)
1402+
minUniqueCharacters?: number;
1403+
// (undocumented)
1404+
name?: string;
1405+
// (undocumented)
1406+
notSimilarToCurrent?: boolean;
1407+
// (undocumented)
1408+
populationCount?: number;
1409+
// (undocumented)
1410+
updatedAt?: string;
1411+
}
13271412

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

15901675
// @public (undocumented)
1591-
export type SingleValueCollectors = SingleValueCollectorNoValue<'PasswordCollector'> | SingleSelectCollectorWithValue<'SingleSelectCollector'> | SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorWithValue<'TextCollector'> | ValidatedSingleValueCollectorWithValue<'TextCollector'>;
1676+
export type SingleValueCollectors = PasswordCollector | ValidatedPasswordCollector | SingleSelectCollectorWithValue<'SingleSelectCollector'> | SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorWithValue<'TextCollector'> | ValidatedSingleValueCollectorWithValue<'TextCollector'>;
15921677

15931678
// @public
1594-
export type SingleValueCollectorTypes = 'PasswordCollector' | 'SingleValueCollector' | 'SingleSelectCollector' | 'SingleSelectObjectCollector' | 'TextCollector' | 'ValidatedTextCollector';
1679+
export type SingleValueCollectorTypes = 'PasswordCollector' | 'ValidatedPasswordCollector' | 'SingleValueCollector' | 'SingleSelectCollector' | 'SingleSelectObjectCollector' | 'TextCollector' | 'ValidatedTextCollector';
15951680

15961681
// @public (undocumented)
15971682
export interface SingleValueCollectorWithValue<T extends SingleValueCollectorTypes> {
@@ -1621,11 +1706,11 @@ export interface SingleValueCollectorWithValue<T extends SingleValueCollectorTyp
16211706
}
16221707

16231708
// @public (undocumented)
1624-
export type SingleValueFields = StandardField | ValidatedField | SingleSelectField | ProtectField;
1709+
export type SingleValueFields = StandardField | PasswordField | ValidatedField | SingleSelectField | ProtectField;
16251710

16261711
// @public (undocumented)
16271712
export type StandardField = {
1628-
type: 'PASSWORD' | 'PASSWORD_VERIFY' | 'TEXT' | 'SUBMIT_BUTTON' | 'FLOW_BUTTON' | 'FLOW_LINK' | 'BUTTON';
1713+
type: 'TEXT' | 'SUBMIT_BUTTON' | 'FLOW_BUTTON' | 'FLOW_LINK' | 'BUTTON';
16291714
key: string;
16301715
label: string;
16311716
required?: boolean;
@@ -1743,6 +1828,34 @@ export type ValidatedField = {
17431828
};
17441829
};
17451830

1831+
// @public (undocumented)
1832+
export interface ValidatedPasswordCollector {
1833+
// (undocumented)
1834+
category: 'SingleValueCollector';
1835+
// (undocumented)
1836+
error: string | null;
1837+
// (undocumented)
1838+
id: string;
1839+
// (undocumented)
1840+
input: {
1841+
key: string;
1842+
value: string | number | boolean;
1843+
type: string;
1844+
};
1845+
// (undocumented)
1846+
name: string;
1847+
// (undocumented)
1848+
output: {
1849+
key: string;
1850+
label: string;
1851+
type: string;
1852+
verify: boolean;
1853+
passwordPolicy: PasswordPolicy;
1854+
};
1855+
// (undocumented)
1856+
type: 'ValidatedPasswordCollector';
1857+
}
1858+
17461859
// @public (undocumented)
17471860
export interface ValidatedSingleValueCollectorWithValue<T extends SingleValueCollectorTypes> {
17481861
// (undocumented)

0 commit comments

Comments
 (0)