-
Notifications
You must be signed in to change notification settings - Fork 16
feat(reactjs-todo-davinci): add ValidatedPasswordCollector support #116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
cf50b48
aaf99e7
ddb0906
ee12bf3
550a5b7
17d4a80
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 () => []; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
|
|
||
| /** | ||
| * @function setNext - Get the next node in the DaVinci flow | ||
| * @returns {Promise<void>} | ||
|
|
@@ -148,6 +161,7 @@ export default function useDavinci() { | |
| setNext, | ||
| startNewFlow, | ||
| updater, | ||
| validator, | ||
| externalIdp: davinciClient && davinciClient.externalIdp(), | ||
| getError: davinciClient && davinciClient.getError, | ||
| }, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 : []); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to do the |
||
| } | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
| ); | ||
| }; | ||
|
|
||
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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.