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 @@ -62,7 +62,7 @@ export default function Form() {
const [user, setCode] = useOAuth();
const [
{ formName, formAction, node, collectors },
{ getError, setNext, startNewFlow, updater, pollStatus, externalIdp },
{ getError, setNext, startNewFlow, updater, validator, pollStatus, externalIdp },
] = useDavinci();

/**
Expand Down Expand Up @@ -166,6 +166,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 pollStatus - Gets the DaVinci client pollStatus function for a collector
* @returns {function} - A function to start challenge or continue polling
Expand Down Expand Up @@ -157,6 +165,7 @@ export default function useDavinci() {
startNewFlow,
updater,
pollStatus,
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?

}
Comment on lines +77 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear the confirm error when both fields are emptied.

If a user types in both fields (triggering a mismatch error), clears the Confirm field, and then clears the Primary field, the "Passwords do not match" error remains visible even when both fields are empty. This happens because when confirmValue is empty, both conditions in the if/else if block evaluate to false, leaving the confirmError state unchanged.

Change else if (confirmValue) to else to ensure the error clears when both fields become empty, mirroring the logic in handleConfirmChange.

🐛 Proposed fix
     // Keep confirm error in sync as the primary value changes
     if (confirmValue && value !== confirmValue) {
       setConfirmError('Passwords do not match');
-    } else if (confirmValue) {
+    } else {
       setConfirmError('');
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Keep confirm error in sync as the primary value changes
if (confirmValue && value !== confirmValue) {
setConfirmError('Passwords do not match');
} else if (confirmValue) {
setConfirmError('');
}
}
// Keep confirm error in sync as the primary value changes
if (confirmValue && value !== confirmValue) {
setConfirmError('Passwords do not match');
} else {
setConfirmError('');
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@javascript/reactjs-todo-davinci/client/components/davinci-client/password.js`
around lines 77 - 83, Update the primary password change handler’s confirm-error
synchronization logic by changing the `else if (confirmValue)` branch to an
unconditional `else`, so `setConfirmError('')` runs when the confirm field is
empty as well as when the values match. Keep the existing mismatch condition
unchanged and mirror `handleConfirmChange` behavior.


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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 = '533a7ff229ca6395afd9dd6deb699944';

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' })).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);
});
});
8 changes: 4 additions & 4 deletions javascript/reactjs-todo-journey/e2e/protect.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

import { test, expect } from '@playwright/test';
import { displayName, password, username } from './utils/demo-user';
import { password, username } from './utils/demo-user';

test.describe('React Journey - Login with Protect', () => {
test('should succeed when initialized by callback', async ({ page }) => {
Expand All @@ -26,7 +26,7 @@ test.describe('React Journey - Login with Protect', () => {
await page.getByLabel('Password').fill(password);
await page.getByLabel('Password').press('Enter');

await expect(page.getByText(`Welcome back, ${displayName}!`)).toBeVisible();
await page.waitForRequest('https://openam-sdks.forgeblocks.com/am/json/alpha/authenticate');

await expect(logs.includes('Protect initialized by callback for data collection')).toBeTruthy();
await expect(logs.includes('Data set on Protect evaluation callback')).toBeTruthy();
Expand All @@ -48,8 +48,8 @@ test.describe('React Journey - Login with Protect', () => {
await page.getByLabel('Password').fill(password);
await page.getByLabel('Password').press('Enter');

await expect(page.getByText(`Welcome back, ${displayName}!`)).toBeVisible();

await page.waitForRequest('https://openam-sdks.forgeblocks.com/am/json/alpha/authenticate');
await expect(
logs.includes('Protect initialized at bootstrap for data collection'),
).toBeTruthy();
Expand Down
12 changes: 6 additions & 6 deletions javascript/reactjs-todo-journey/eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import js from '@eslint/js';
import globals from 'globals';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
const js = require('@eslint/js');
const globals = require('globals');
const react = require('eslint-plugin-react');
const reactHooks = require('eslint-plugin-react-hooks');
const reactRefresh = require('eslint-plugin-react-refresh');

export default [
module.exports = [
{
ignores: [
'**/node_modules/**',
Expand Down
2 changes: 0 additions & 2 deletions javascript/reactjs-todo-journey/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@
"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",
"@vitejs/plugin-react": "^4.3.3",
"bootstrap": "^5.0.1",
"dotenv": "^10.0.0",
"eslint": "^9.13.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
Expand Down
10 changes: 3 additions & 7 deletions javascript/reactjs-todo-journey/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,16 @@
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/

import { defineConfig, devices } from '@playwright/test';
import * as dotenv from 'dotenv';
import { createRequire } from 'module';

const require = createRequire(import.meta.url);
const { defineConfig, devices } = require('@playwright/test');
const dotenv = require('dotenv');
const testConfig = require('./config.test.json');

// 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
Loading
Loading