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
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 @@ -94,6 +94,14 @@ 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) {
return davinciClient.validate(collector);
}

/**
* @function setNext - Get the next node in the DaVinci flow
* @returns {Promise<void>}
Expand Down Expand Up @@ -148,6 +156,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(errors);
}

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
10 changes: 6 additions & 4 deletions javascript/reactjs-todo-davinci/e2e/davinci-protect.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ test.describe('React - DaVinci Protect', () => {
const method = request.method();
const requestUrl = request.url();
const payload = request.postDataJSON();
const data = payload.parameters.data.formData.riskSDK;

requests.push(requestUrl);

if (method === 'POST' && requestUrl.includes('customHTMLTemplate')) {
// Only process POST requests with JSON payloads
if (method === 'POST' && payload && requestUrl.includes('customHTMLTemplate')) {
const data = payload.parameters?.data?.formData?.riskSDK;
expect(data).toBeDefined();
expect(data).toMatch(/^R\/o\//);
}
Expand Down Expand Up @@ -73,11 +74,12 @@ test.describe('React - DaVinci Protect', () => {
const method = request.method();
const requestUrl = request.url();
const payload = request.postDataJSON();
const data = payload.parameters.data.formData.riskSDK;

requests.push(requestUrl);

if (method === 'POST' && requestUrl.includes('customHTMLTemplate')) {
// Only process POST requests with JSON payloads
if (method === 'POST' && payload && requestUrl.includes('customHTMLTemplate')) {
const data = payload.parameters?.data?.formData?.riskSDK;
expect(data).toBeDefined();
expect(data).toMatch(/^R\/o\//);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
*
* 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 ACR_VALUE = '769eecb92f8e66f88005a85e8b939a01';

async function navigateToRegistrationForm(page) {
await page.goto(`${BASE_URL}/login?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);
});
});
4 changes: 2 additions & 2 deletions javascript/reactjs-todo-davinci/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@
"webpack-dev-server": "^5.1.0"
},
"dependencies": {
"@forgerock/davinci-client": "latest",
"@forgerock/oidc-client": "latest",
"@forgerock/davinci-client": "0.0.0-beta-20260611180129",
"@forgerock/oidc-client": "0.0.0-beta-20260611180129",
"@forgerock/protect": "latest",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
Expand Down
5 changes: 2 additions & 3 deletions javascript/reactjs-todo-journey/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"name": "reactjs-todo-journey",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"description": "A Ping authenticated sample web app written in React",
"devDependencies": {
"@eslint/js": "^9.13.0",
Expand All @@ -20,8 +19,8 @@
"vite": "^5.4.9"
},
"dependencies": {
"@forgerock/journey-client": "latest",
"@forgerock/oidc-client": "latest",
"@forgerock/journey-client": "0.0.0-beta-20260611180129",
"@forgerock/oidc-client": "0.0.0-beta-20260611180129",
"@forgerock/protect": "latest",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand Down
6 changes: 3 additions & 3 deletions javascript/reactjs-todo-journey/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@
* of the MIT license. See the LICENSE file for details.
*/

import { defineConfig, devices } from '@playwright/test';
import * as dotenv from 'dotenv';
const { defineConfig, devices } = require('@playwright/test');
const dotenv = require('dotenv');

// Load environment variables from .env file
dotenv.config({ path: '.env' });

const url = process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:8443';

export default defineConfig({
module.exports = defineConfig({
testDir: 'e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
Expand Down
2 changes: 1 addition & 1 deletion javascript/reactjs-todo-oidc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"webpack-dev-server": "^5.1.0"
},
"dependencies": {
"@forgerock/oidc-client": "latest",
"@forgerock/oidc-client": "0.0.0-beta-20260611180129",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"dotenv": "^10.0.0",
Expand Down
Loading
Loading