Skip to content
Merged
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
2 changes: 2 additions & 0 deletions javascript/reactjs-todo-davinci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ This sample code is provided "as is" and is not a supported product of Ping Iden
- BooleanCollector
- ValidatedBooleanCollector
- PollingCollector
- ImageCollector
- MetadataCollector

## Requirements

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,26 @@
import React, { useState, useEffect } from 'react';
import { fido } from '@forgerock/davinci-client';

/**
* @function describeFidoError - Maps a typed FIDO GenericError to sample-app-friendly copy.
* @param {Object} fidoError - The typed error returned by the FIDO API
* @param {string} fidoError.type - 'fido_error', the type of a FIDO API failure
* @param {string} [fidoError.code] - Error code distinguishing a DOM exception
* (e.g. 'NotAllowedError') from an internal error ('UnknownError')
* @param {string} [fidoError.message] - Optional human-readable detail from the SDK
* @returns {{ message: string, code?: string }} - Display message and error code for the UI
*/
function describeFidoError(fidoError) {
if (fidoError.type === 'fido_error') {
return {
message: fidoError.message || 'Your device or browser could not complete this request.',
code: fidoError.code,
};
}

return { message: 'Something unexpected went wrong. Please try again.', code: fidoError.code };
}

/**
* FidoComponent React component for FIDO registration and authentication
* @param {Object} props
Expand All @@ -22,38 +42,55 @@ export default function FidoComponent({ collector, updater, submitForm }) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [hasAttempted, setHasAttempted] = useState(false); // for registration auto-trigger
const fidoClient = fido();
const fidoApi = fido();

async function updateAndSubmit(result, fallbackErrorMessage) {
const updateResult = updater(result);
if (updateResult && 'error' in updateResult) {
setError({ message: updateResult.error?.message || fallbackErrorMessage });
console.error('Error updating fido collector:', updateResult.error);
return;
}
await submitForm();
}

async function handleFido() {
setIsLoading(true);
setError(null);

let response;
if (collector.type === 'FidoRegistrationCollector') {
response = await fidoClient.register(
collector.output.config.publicKeyCredentialCreationOptions,
);
response = await fidoApi.register(collector.output.config.publicKeyCredentialCreationOptions);
} else if (collector.type === 'FidoAuthenticationCollector') {
response = await fidoClient.authenticate(
response = await fidoApi.authenticate(
collector.output.config.publicKeyCredentialRequestOptions,
);
} else {
setError('Unsupported FIDO collector type');
setError({ message: 'Unsupported FIDO collector type' });
setIsLoading(false);
return;
}

if ('error' in response) {
setError(response.error?.message || response?.message || 'FIDO error');
console.error(response);
/** *********************************************************************
* SDK INTEGRATION POINT
* Summary: Handle the FIDO API's typed error
* ----------------------------------------------------------------------
* Details: The FIDO API `register()` and `authenticate()` methods return
* a `GenericError` on failure with type `fido_error`. The error code
* determines if it was a DOM exception (e.g. `NotAllowedError`) vs
* internal error (`UnknownError`). You may choose to handle this error
* client side, or send the error to DaVinci to reach an error branch
* configured in your flow. To send the error to DaVinci, update the
* collector with the error and submit it by calling `davinciClient.next()`.
********************************************************************* */
const fidoError = describeFidoError(response);
setError(fidoError);
console.error('Fido error:', response);

await updateAndSubmit(response, fidoError.message);
} else {
const updateResult = updater(response);
if (updateResult && 'error' in updateResult) {
setError(updateResult.error?.message || 'Update error');
console.error(updateResult.error?.message);
} else {
await submitForm();
}
await updateAndSubmit(response, 'Update error');
}

setIsLoading(false);
Expand All @@ -75,8 +112,13 @@ export default function FidoComponent({ collector, updater, submitForm }) {
return (
<div className="my-3" aria-busy={isLoading ? 'true' : undefined}>
{error && (
<div className="text-danger text-center" role="alert" aria-live="assertive">
<div>{error}</div>
<div
className="text-danger text-center"
role="alert"
aria-live="assertive"
data-error-code={error.code}
>
<div>{error.message}</div>
<button
type="submit"
className="btn btn-primary w-100 my-4"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import FidoComponent from './fido.js';
import PollingComponent from './polling.js';
import BooleanComponent from './boolean.js';
import QrCode from './qr-code.js';
import ImageComponent from './image.js';
import MetadataComponent from './metadata.js';
import Unknown from './unknown.js';
import Alert from './alert.js';
import KeyIcon from '../icons/key-icon';
Expand Down Expand Up @@ -249,6 +251,17 @@ export default function Form() {
return <Protect collector={collector} key={collectorName} />;
case 'QrCodeCollector':
return <QrCode collector={collector} key={collectorName} />;
case 'ImageCollector':
return <ImageComponent collector={collector} key={collectorName} />;
case 'MetadataCollector':
return (
<MetadataComponent
collector={collector}
updater={updater(collector)}
submitForm={setNext}
key={collectorName}
/>
);
case 'SubmitCollector':
return <SubmitButton collector={collector} isLoading={isLoading} key={collectorName} />;
case 'FlowCollector':
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* ping-sample-web-react-davinci
*
* image.js
*
* Copyright (c) 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 React from 'react';

/**
* @function parseSafeHref - Only http/https URLs are safe to render in an href;
* DaVinci's own type doc requires consumers to sanitize this value.
* @param {string} href - The unsanitized href from collector.output.href
* @returns {string|null} - The href if its scheme is allowed, otherwise null
*/
function parseSafeHref(href) {
try {
const url = new URL(href, window.location.origin);
return ['http:', 'https:'].includes(url.protocol) ? href : null;
} catch {
return null;
}
}

export default function ImageComponent({ collector }) {
if (collector.error) {
return (
<p className="alert alert-danger mt-1" role="alert">
{`Image error: ${collector.error}`}
</p>
);
}

const image = (
<img
src={collector.output.src}
alt={collector.output.alt}
data-testid="form-image"
className="img-fluid"
/>
);

const safeHref = collector.output.href ? parseSafeHref(collector.output.href) : null;

return (
<div className="d-flex flex-column align-items-center mb-3">
{safeHref ? <a href={safeHref}>{image}</a> : image}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* ping-sample-web-react-davinci
*
* metadata.js
*
* Copyright (c) 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 React, { useState } from 'react';

/**
* @function runThirdPartySdk - Stand-in for invoking a third-party SDK with the
* DaVinci-provided config payload (e.g. an identity-verification or fraud SDK
* paused mid-flow via MetadataCollector). Per the MetadataCollector design, the
* payload is guaranteed to already be a valid JSON object -- DaVinci's server
* validates it before the app ever sees it.
* @param {Object} config - The payload from collector.output.config
* @param {boolean} shouldSucceed - Which outcome to simulate (demo-only; a real
* integration's outcome is decided by the third-party SDK, not the caller)
* @returns {Promise<{value: Object}|{error: string}>}
*/
async function runThirdPartySdk(config, shouldSucceed) {
// Simulate calling a real third-party SDK with `config`. A real integration
// reports whatever success value or failure reason that SDK itself returns.
return new Promise((resolve) =>
setTimeout(() => {
if (shouldSucceed) {
resolve({ value: { verification: 'successful', status: true, config } });
} else {
resolve({ error: 'Third-party SDK verification failed' });
}
}, 500),
);
}

/**
* MetadataComponent React component for the DaVinci MetadataCollector
* @param {Object} props
* @param {Object} props.collector - MetadataCollector
* @param {Function} props.updater - Updater function for collector
* @param {Function} props.submitForm - Function to call to advance the flow
*/
export default function MetadataComponent({ collector, updater, submitForm }) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);

/** *********************************************************************
* SDK INTEGRATION POINT
* Summary: Run a third-party SDK against the metadata payload, then report
* its outcome back to DaVinci
* ----------------------------------------------------------------------
* Details: MetadataCollector pauses the flow so the app can invoke a
* third-party SDK with the DaVinci-provided config (collector.output.config).
* Whatever that third-party SDK returns is reported back: its success value
* via updater(), or a structured MetadataError (a plain {code, message}
* object per the SDK's MetadataError type -- the SDK exposes no builder
* function for it) if it fails. Either way, `updater` can itself return an
* error (e.g. missing ID, invalid state), so its result must be checked
* before advancing the flow. The Success/Failure buttons below simulate both
* outcomes for demo purposes; a real integration has exactly one action that
* calls the third-party SDK and branches on what it returns.
********************************************************************* */
async function handleContinue(shouldSucceed) {
setIsLoading(true);
setError(null);

const sdkResult = await runThirdPartySdk(collector.output.config, shouldSucceed);
const updateResult =
sdkResult && 'error' in sdkResult
? updater({ code: 'METADATA_PROCESSING_ERROR', message: sdkResult.error })
: updater(sdkResult.value);

if (updateResult && 'error' in updateResult) {
setError(updateResult.error?.message || 'Update error');
console.error('Error updating metadata collector:', updateResult.error);
} else {
await submitForm();
}

setIsLoading(false);
}

return (
<div className="my-3">
<pre>{JSON.stringify(collector.output.config, null, 2)}</pre>
{error && (
<div className="text-danger text-center" role="alert" aria-live="assertive">
{error}
</div>
)}
<button
type="button"
className="btn btn-primary w-100 mb-2"
onClick={() => handleContinue(true)}
disabled={isLoading}
>
Success
</button>
<button
type="button"
className="btn btn-danger w-100"
onClick={() => handleContinue(false)}
disabled={isLoading}
>
Failure
</button>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,31 @@
* of the MIT license. See the LICENSE file for details.
*/

import React from 'react';
import React, { useContext } from 'react';
import { ThemeContext } from '../../context/theme.context.js';
import { interpolateRichContent } from '../utilities/rich-content';

export default function ReadOnly({ collector }) {
const theme = useContext(ThemeContext);
const collectorType = collector.type;
const output = collector.output;

if (collectorType === 'ReadOnlyCollector') {
return (
<>
{/* Display agreement title if it exists */}
{output.title && <h3>{output.title}</h3>}
<p>{output.content}</p>
{output.title && <h3 className={theme.textClass}>{output.title}</h3>}
<p className={`mb-3 ${theme.textClass}`}>{output.content}</p>
</>
);
} else if (collectorType === 'RichTextCollector') {
const { richContent } = output;

if (!richContent?.replacements?.length) {
return <p>{output.content}</p>;
return <p className={`mb-3 ${theme.textClass}`}>{output.content}</p>;
}

return <p>{interpolateRichContent(richContent)}</p>;
return <p className={`mb-3 ${theme.textClass}`}>{interpolateRichContent(richContent)}</p>;
} else {
return null;
}
Expand Down
16 changes: 10 additions & 6 deletions javascript/reactjs-todo-davinci/e2e/davinci-fido.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,11 @@ test.describe.skip('WebAuthn Virtual Authenticator Setup', () => {
await expect(page.getByLabel('MFA Device Selection -')).toHaveValue('FIDO2');
await page.getByRole('button', { name: 'Next' }).click();

// Assert that registration has failed
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByRole('button', { name: 'Try Again' })).toBeVisible();
// Assert the DaVinci-routed error node
await expect(page.locator('.alert-danger[role="alert"]')).toContainText(
'FIDO Registration Error - NotAllowedError',
);
await expect(page.getByRole('button', { name: 'Try Again' })).toBeHidden();
});

test('should fail to authenticate with an existing WebAuthn credential', async ({ page }) => {
Expand Down Expand Up @@ -147,8 +149,10 @@ test.describe.skip('WebAuthn Virtual Authenticator Setup', () => {
await deviceSelector.selectOption(lastValue);
await page.getByRole('button', { name: 'Next' }).click();

// Assert that authentication has failed
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByRole('button', { name: 'Try Again' })).toBeVisible();
// Assert the DaVinci-routed error node
await expect(page.locator('.alert-danger[role="alert"]')).toContainText(
'FIDO Authentication Error - NotAllowedError',
);
await expect(page.getByRole('button', { name: 'Try Again' })).toBeHidden();
});
});
Loading
Loading