Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions javascript/reactjs-todo-davinci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This sample code is provided "as is" and is not a supported product of Ping Iden

- TextCollector
- PasswordCollector
- ValidatedPasswordCollector
- SingleSelectCollector
- ReadOnlyCollector
- PhoneNumberCollector
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export default function Form() {
const [user, setCode] = useOAuth();
const [
{ formName, formAction, node, collectors },
{ getError, setNext, startNewFlow, updater, externalIdp },
{ getError, setNext, startNewFlow, updater, validator, externalIdp },
] = useDavinci();

/**
Expand Down Expand Up @@ -162,6 +162,17 @@ export default function Form() {
key={collectorName}
/>
);
case 'ValidatedPasswordCollector':
return (
<Password
collector={collector}
inputName={collectorName}
updater={updater(collector)}
validator={validator(collector)}
verify={collector.output.verify}
key={collectorName}
/>
);
case 'SingleSelectCollector':
return (
<SingleSelect collector={collector} updater={updater(collector)} key={collectorName} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,31 @@
import { davinci } from '@forgerock/davinci-client';
import { CONFIG } from '../../../constants.js';

/**
* Override configs keyed by clientId URL param — allows tests to target a
* different PingOne tenant without recompiling the app.
*/
const CLIENT_CONFIGS = {
'fb456db5-2e08-46d3-adf0-05bf8d26ad60': {
clientId: 'fb456db5-2e08-46d3-adf0-05bf8d26ad60',
redirectUri: `${window.location.origin}/callback.html`,
scope: 'openid profile email phone name revoke',
serverConfig: {
wellknown:
'https://auth.pingone.ca/356a254c-cba3-4ade-be1a-860136e8df01/as/.well-known/openid-configuration',
},
},
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why are we hardcoding a tenant here? I feel like this should be moved into a test and not something exposed to the customer.

/**
* @function createClient - Utility function for creating a DaVinci client
* @returns {Object} - Either a DaVinci client if it has been initialized or null
*/
export default async function createClient() {
try {
const davinciClient = await davinci({ config: CONFIG });
const clientId = new URLSearchParams(window.location.search).get('clientId');
const config = (clientId && CLIENT_CONFIGS[clientId]) || CONFIG;
const davinciClient = await davinci({ config });
return davinciClient;
} catch (error) {
console.error('Error creating DaVinci client');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,19 @@ export default function useDavinci() {
return davinciClient.update(collector);
}

/**
* @function validator - Gets the DaVinci client validator function for a collector
* @returns {function} - A function to call to validate the collector's input
*/
function validator(collector) {
try {
return davinciClient.validate(collector);
} catch (error) {
console.error('Error creating validator for collector:', error);
return () => [];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't believe validate can throw. Can we remove this? If we want to handle errors then we should look for them like if (error) in validateResult {}.

}

/**
* @function setNext - Get the next node in the DaVinci flow
* @returns {Promise<void>}
Expand Down Expand Up @@ -148,6 +161,7 @@ export default function useDavinci() {
setNext,
startNewFlow,
updater,
validator,
externalIdp: davinciClient && davinciClient.externalIdp(),
getError: davinciClient && davinciClient.getError,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,149 @@ import React, { useState, useContext } from 'react';
import { ThemeContext } from '../../context/theme.context.js';
import EyeIcon from '../icons/eye-icon';

const Password = ({ collector, inputName, updater }) => {
const UPPERCASE_RE = /^[A-Z]+$/;
const LOWERCASE_RE = /^[a-z]+$/;
const DIGIT_RE = /^[0-9]+$/;

function buildRequirements(validation) {
const items = [];

if (validation.length) {
const { min, max } = validation.length;
if (min != null && max != null) {
items.push(`${min}–${max} characters`);
} else if (min != null) {
items.push(`At least ${min} characters`);
} else if (max != null) {
items.push(`At most ${max} characters`);
}
}

if (validation.minCharacters) {
for (const [charset, count] of Object.entries(validation.minCharacters)) {
if (UPPERCASE_RE.test(charset)) {
items.push(`At least ${count} uppercase letter(s)`);
} else if (LOWERCASE_RE.test(charset)) {
items.push(`At least ${count} lowercase letter(s)`);
} else if (DIGIT_RE.test(charset)) {
items.push(`At least ${count} number(s)`);
} else {
items.push(`At least ${count} special character(s)`);
}
}
}

return items;
}

const Password = ({ collector, inputName, updater, validator, verify }) => {
const theme = useContext(ThemeContext);
const [isVisible, setVisibility] = useState(false);
const [isPrimaryVisible, setPrimaryVisible] = useState(false);
const [isConfirmVisible, setConfirmVisible] = useState(false);
const [validationErrors, setValidationErrors] = useState([]);
const [confirmValue, setConfirmValue] = useState('');
const [confirmError, setConfirmError] = useState('');
const [primaryValue, setPrimaryValue] = useState('');

const passwordLabel = collector.output.label;
const isValidated = collector.type === 'ValidatedPasswordCollector';
const requirements =
isValidated && collector.input.validation ? buildRequirements(collector.input.validation) : [];

/**
* @function toggleVisibility - toggles the password from masked to plaintext
*/
function toggleVisibility() {
setVisibility(!isVisible);
function handlePrimaryChange(e) {
const value = e.target.value;
setPrimaryValue(value);

if (validator) {
const errors = validator(value);
setValidationErrors(Array.isArray(errors) ? errors : []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need to do the Array.isArray check? Validators should always return an array even if it's empty.

}

const result = updater(value);
if (result && result.error) {
console.error('Error updating password collector:', result.error.message);
}

// Keep confirm error in sync as the primary value changes
if (confirmValue && value !== confirmValue) {
setConfirmError('Passwords do not match');
} else if (confirmValue) {
setConfirmError('');
}
Comment on lines +72 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there anything blocking the user from submitting the form if there is an error? Or are these just UI hints?

}

function handleConfirmChange(e) {
const value = e.target.value;
setConfirmValue(value);
if (primaryValue && value !== primaryValue) {
setConfirmError('Passwords do not match');
} else {
setConfirmError('');
}
}

return (
<div className="cstm_form-floating input-group form-floating mb-3">
<input
className={`cstm_input-password form-control border-end-0 bg-transparent ${theme.textClass} ${theme.borderClass}`}
id={inputName}
name={inputName}
type={isVisible ? 'text' : 'password'}
onChange={(e) => updater(e.target.value)}
/>
<label htmlFor={inputName}>{passwordLabel}</label>
<button
className={`cstm_input-icon border-start-0 input-group-text bg-transparent ${theme.textClass} ${theme.borderClass}`}
onClick={toggleVisibility}
type="button"
>
<EyeIcon visible={isVisible} />
</button>
<div>
{requirements.length > 0 && (
<ul className="password-requirements">
{requirements.map((req, i) => (
<li key={i}>{req}</li>
))}
</ul>
)}

<div className="cstm_form-floating input-group form-floating mb-3">
<input
className={`cstm_input-password form-control border-end-0 bg-transparent ${theme.textClass} ${theme.borderClass}`}
id={inputName}
name={inputName}
type={isPrimaryVisible ? 'text' : 'password'}
onChange={handlePrimaryChange}
/>
<label htmlFor={inputName}>{passwordLabel}</label>
<button
className={`cstm_input-icon border-start-0 input-group-text bg-transparent ${theme.textClass} ${theme.borderClass}`}
onClick={() => setPrimaryVisible(!isPrimaryVisible)}
type="button"
>
<EyeIcon visible={isPrimaryVisible} />
</button>
</div>

{validationErrors.length > 0 && (
<ul className={`${inputName}-error`}>
{validationErrors.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
)}

{verify && (
<div>
<div className="cstm_form-floating input-group form-floating mb-3">
<input
className={`cstm_input-password form-control border-end-0 bg-transparent ${theme.textClass} ${theme.borderClass}`}
id={`${inputName}-confirm`}
name={`${inputName}-confirm`}
type={isConfirmVisible ? 'text' : 'password'}
onChange={handleConfirmChange}
/>
<label htmlFor={`${inputName}-confirm`}>Confirm Password</label>
<button
className={`cstm_input-icon border-start-0 input-group-text bg-transparent ${theme.textClass} ${theme.borderClass}`}
onClick={() => setConfirmVisible(!isConfirmVisible)}
type="button"
>
<EyeIcon visible={isConfirmVisible} />
</button>
</div>
{confirmError && (
<p className={`${inputName}-confirm-error`} style={{ color: 'red' }}>
{confirmError}
</p>
)}
</div>
)}
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
*
* Copyright (c) 2025 - 2026 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 { test, expect } from '@playwright/test';

const BASE_URL = 'http://localhost:8443';
const CLIENT_ID = 'fb456db5-2e08-46d3-adf0-05bf8d26ad60';
const ACR_VALUE = '769eecb92f8e66f88005a85e8b939a01';

async function navigateToRegistrationForm(page) {
await page.goto(`${BASE_URL}/login?clientId=${CLIENT_ID}&acrValue=${ACR_VALUE}`);
await expect(page.getByRole('heading', { name: 'Select Test Form' })).toBeVisible();
await page.getByRole('link', { name: 'USER_REGISTRATION' }).click();
await expect(page.getByRole('heading', { name: 'Example - Registration 1' })).toBeVisible({
timeout: 10000,
});
}

test.describe('React - DaVinci ValidatedPasswordCollector', () => {

test('shows password requirements list', async ({ page }) => {
await navigateToRegistrationForm(page);
await expect(page.locator('ul.password-requirements')).toBeVisible();
await expect(page.locator('ul.password-requirements li').first()).toBeVisible();
});

test('shows inline validation errors for a password that violates policy, then clears them', async ({
page,
}) => {
await navigateToRegistrationForm(page);
const passwordInput = page.getByLabel('Password');
await passwordInput.fill('a');
await expect(page.locator('[class$="-error"] li').first()).toBeVisible();

await passwordInput.fill('Demo_12345!');
await expect(page.locator('[class$="-error"] li')).toHaveCount(0);
});
});
Loading
Loading