Skip to content

Commit bc66418

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 44f9be3 commit bc66418

22 files changed

Lines changed: 1331 additions & 223 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 validation = collector.input.validation;
40+
const requirementsList = document.createElement('ul');
41+
requirementsList.className = 'password-requirements';
42+
43+
if (validation.length) {
44+
const { min, max } = validation.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 (validation.minCharacters) {
61+
for (const [charset, count] of Object.entries(validation.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(

0 commit comments

Comments
 (0)