From 65a6b010dfee8813c17e1ff2296a96fc52a1e6b6 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Mon, 30 Mar 2026 13:53:33 -0600 Subject: [PATCH] feat(davinci-client): add QRCode collector support Add QR_CODE field type support to the DaVinci client SDK, enabling applications to render QR codes returned by DaVinci flows. - Add QrCodeField type and QrCodeCollectorBase interface - Add returnQrCodeCollector factory with defensive fallbacks - Wire QR_CODE dispatch in node reducer (early return, same as LABEL) - Export QrCodeCollector in public API types - Add QR code component and config to sample davinci-app - Add unit tests for factory and reducer integration --- .changeset/fast-ways-rest.md | 5 ++ e2e/davinci-app/components/qr-code.ts | 33 ++++++++ e2e/davinci-app/main.ts | 3 + e2e/davinci-app/server-configs.ts | 11 ++- .../davinci-client/src/lib/collector.types.ts | 26 ++++++- .../src/lib/collector.utils.test.ts | 76 +++++++++++++++++++ .../davinci-client/src/lib/collector.utils.ts | 38 ++++++++++ .../davinci-client/src/lib/davinci.types.ts | 9 ++- .../src/lib/node.reducer.test.ts | 35 +++++++++ .../davinci-client/src/lib/node.reducer.ts | 7 ++ .../src/lib/node.types.test-d.ts | 2 + packages/davinci-client/src/lib/node.types.ts | 2 + packages/davinci-client/src/types.ts | 1 + 13 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 .changeset/fast-ways-rest.md create mode 100644 e2e/davinci-app/components/qr-code.ts diff --git a/.changeset/fast-ways-rest.md b/.changeset/fast-ways-rest.md new file mode 100644 index 0000000000..a940229df3 --- /dev/null +++ b/.changeset/fast-ways-rest.md @@ -0,0 +1,5 @@ +--- +'@forgerock/davinci-client': minor +--- + +Add QR code collector support to davinci-client diff --git a/e2e/davinci-app/components/qr-code.ts b/e2e/davinci-app/components/qr-code.ts new file mode 100644 index 0000000000..01fe9ddc26 --- /dev/null +++ b/e2e/davinci-app/components/qr-code.ts @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 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 type { QrCodeCollector } from '@forgerock/davinci-client/types'; + +export default function (formEl: HTMLFormElement, collector: QrCodeCollector) { + if (collector.error) { + const errorEl = document.createElement('p'); + errorEl.innerText = `QR Code error: ${collector.error}`; + formEl.appendChild(errorEl); + return; + } + + const container = document.createElement('div'); + + const img = document.createElement('img'); + img.src = collector.output.src; + img.alt = 'QR Code'; + img.setAttribute('data-testid', 'qr-code-image'); + container.appendChild(img); + + if (collector.output.fallbackText) { + const fallback = document.createElement('p'); + fallback.innerText = `Manual code: ${collector.output.fallbackText}`; + fallback.setAttribute('data-testid', 'qr-code-fallback'); + container.appendChild(fallback); + } + + formEl.appendChild(container); +} diff --git a/e2e/davinci-app/main.ts b/e2e/davinci-app/main.ts index e1048cd37a..cf4010b158 100644 --- a/e2e/davinci-app/main.ts +++ b/e2e/davinci-app/main.ts @@ -32,6 +32,7 @@ import multiValueComponent from './components/multi-value.js'; import labelComponent from './components/label.js'; import objectValueComponent from './components/object-value.js'; import fidoComponent from './components/fido.js'; +import qrCodeComponent from './components/qr-code.js'; const loggerFn = { error: () => { @@ -223,6 +224,8 @@ const urlParams = new URLSearchParams(window.location.search); formEl, // You can ignore this; it's just for rendering collector, // This is the plain object of the collector ); + } else if (collector.type === 'QrCodeCollector') { + qrCodeComponent(formEl, collector); } else if (collector.type === 'TextCollector') { textComponent( formEl, // You can ignore this; it's just for rendering diff --git a/e2e/davinci-app/server-configs.ts b/e2e/davinci-app/server-configs.ts index d00e309223..10a49423de 100644 --- a/e2e/davinci-app/server-configs.ts +++ b/e2e/davinci-app/server-configs.ts @@ -57,7 +57,6 @@ export const serverConfigs: Record = { }, /** * Phone Number Input With Email and Password - * */ '20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0': { clientId: '20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0', @@ -68,4 +67,14 @@ export const serverConfigs: Record = { 'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration', }, }, + /** QR Code policy id : aa3c00c3ec25a9721be078f7bf44678d **/ + 'c12743f9-08e8-4420-a624-71bbb08e9fe1': { + clientId: 'c12743f9-08e8-4420-a624-71bbb08e9fe1', + redirectUri: window.location.origin + '/', + scope: 'openid profile email', + serverConfig: { + wellknown: + 'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration', + }, + }, }; diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index bce4061b7e..7bf637a113 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -482,7 +482,7 @@ export type SubmitCollector = ActionCollectorNoUrl<'SubmitCollector'>; /** * @interface NoValueCollector - Represents a collector that collects no value; text only for display. */ -export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'NoValueCollector'; +export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'NoValueCollector' | 'QrCodeCollector'; export interface NoValueCollectorBase { category: 'NoValueCollector'; @@ -497,6 +497,21 @@ export interface NoValueCollectorBase { }; } +export interface QrCodeCollectorBase { + category: 'NoValueCollector'; + error: string | null; + type: 'QrCodeCollector'; + id: string; + name: string; + output: { + key: string; + label: string; + type: string; + src: string; + fallbackText: string; + }; +} + /** * Type to help infer the collector based on the collector type * Used specifically in the returnNoValueCollector wrapper function. @@ -507,16 +522,21 @@ export interface NoValueCollectorBase { export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' ? NoValueCollectorBase<'ReadOnlyCollector'> - : NoValueCollectorBase<'NoValueCollector'>; + : T extends 'QrCodeCollector' + ? QrCodeCollectorBase + : NoValueCollectorBase<'NoValueCollector'>; export type NoValueCollectors = | NoValueCollectorBase<'NoValueCollector'> - | NoValueCollectorBase<'ReadOnlyCollector'>; + | NoValueCollectorBase<'ReadOnlyCollector'> + | QrCodeCollectorBase; export type NoValueCollector = NoValueCollectorBase; export type ReadOnlyCollector = NoValueCollectorBase<'ReadOnlyCollector'>; +export type QrCodeCollector = QrCodeCollectorBase; + export type UnknownCollector = { category: 'UnknownCollector'; error: string | null; diff --git a/packages/davinci-client/src/lib/collector.utils.test.ts b/packages/davinci-client/src/lib/collector.utils.test.ts index a50925d4d0..c8509038b1 100644 --- a/packages/davinci-client/src/lib/collector.utils.test.ts +++ b/packages/davinci-client/src/lib/collector.utils.test.ts @@ -22,6 +22,7 @@ import { returnObjectValueCollector, returnSingleValueAutoCollector, returnObjectValueAutoCollector, + returnQrCodeCollector, } from './collector.utils.js'; import type { DaVinciField, @@ -31,6 +32,7 @@ import type { FidoRegistrationField, PhoneNumberField, ProtectField, + QrCodeField, ReadOnlyField, RedirectField, StandardField, @@ -809,6 +811,80 @@ describe('No Value Collectors', () => { }); }); +describe('returnQrCodeCollector', () => { + it('should return a valid QrCodeCollector with src and fallbackText', () => { + const mockField: QrCodeField = { + type: 'QR_CODE', + key: 'qr-code-field', + content: 'data:image/png;base64,abc123', + fallbackText: '04ZKS2KCIWKXT8FHRX', + }; + const result = returnQrCodeCollector(mockField, 2); + expect(result).toEqual({ + category: 'NoValueCollector', + error: null, + type: 'QrCodeCollector', + id: 'qr-code-field-2', + name: 'qr-code-field-2', + output: { + key: 'qr-code-field-2', + label: 'data:image/png;base64,abc123', + type: 'QR_CODE', + src: 'data:image/png;base64,abc123', + fallbackText: '04ZKS2KCIWKXT8FHRX', + }, + }); + }); + + it('should handle missing fallbackText gracefully', () => { + const mockField: QrCodeField = { + type: 'QR_CODE', + key: 'qr-code-field', + content: 'data:image/png;base64,abc123', + }; + const result = returnQrCodeCollector(mockField, 0); + expect(result).toEqual({ + category: 'NoValueCollector', + error: null, + type: 'QrCodeCollector', + id: 'qr-code-field-0', + name: 'qr-code-field-0', + output: { + key: 'qr-code-field-0', + label: 'data:image/png;base64,abc123', + type: 'QR_CODE', + src: 'data:image/png;base64,abc123', + fallbackText: '', + }, + }); + }); + + it('should set error when content is missing', () => { + const mockField = { type: 'QR_CODE', key: 'qr-code-field' } as unknown as QrCodeField; + const result = returnQrCodeCollector(mockField, 0); + expect(result.error).toContain('Content is not found'); + expect(result.output.src).toBe(''); + }); + + it('should set error and fall back to type when key is missing', () => { + const mockField = { + type: 'QR_CODE', + content: 'data:image/png;base64,abc123', + } as unknown as QrCodeField; + const result = returnQrCodeCollector(mockField, 0); + expect(result.error).toContain('Key is not found'); + expect(result.id).toBe('QR_CODE-0'); + expect(result.name).toBe('QR_CODE-0'); + }); + + it('should accumulate multiple errors when key and content are missing', () => { + const mockField = { type: 'QR_CODE' } as unknown as QrCodeField; + const result = returnQrCodeCollector(mockField, 0); + expect(result.error).toContain('Content is not found'); + expect(result.error).toContain('Key is not found'); + }); +}); + describe('returnSingleValueAutoCollector', () => { it('should create a valid ProtectCollector', () => { const mockField: ProtectField = { diff --git a/packages/davinci-client/src/lib/collector.utils.ts b/packages/davinci-client/src/lib/collector.utils.ts index 68ac1c27e8..59ce659101 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -29,6 +29,7 @@ import type { AutoCollectors, SingleValueAutoCollectorTypes, ObjectValueAutoCollectorTypes, + QrCodeCollectorBase, } from './collector.types.js'; import type { DeviceAuthenticationField, @@ -38,6 +39,7 @@ import type { MultiSelectField, PhoneNumberField, ProtectField, + QrCodeField, ReadOnlyField, RedirectField, SingleSelectField, @@ -713,6 +715,42 @@ export function returnReadOnlyCollector(field: ReadOnlyField, idx: number) { return returnNoValueCollector(field, idx, 'ReadOnlyCollector'); } +/** + * @function returnQrCodeCollector - Creates a QrCodeCollector object for displaying QR code images. + * @param {QrCodeField} field - The field object containing key, content, type, and optional fallbackText. + * @param {number} idx - The index to be used in the id of the QrCodeCollector. + * @returns {QrCodeCollectorBase} The constructed QrCodeCollector object. + */ +export function returnQrCodeCollector(field: QrCodeField, idx: number): QrCodeCollectorBase { + let error = ''; + if (!('content' in field) || !field.content) { + error = `${error}Content is not found in the field object. `; + } + if (!('key' in field) || !field.key) { + error = `${error}Key is not found in the field object. `; + } + if (!('type' in field)) { + error = `${error}Type is not found in the field object. `; + } + + const key = field.key || field.type; + + return { + category: 'NoValueCollector', + error: error || null, + type: 'QrCodeCollector', + id: `${key}-${idx}`, + name: `${key}-${idx}`, + output: { + key: `${key}-${idx}`, + label: field.content || '', + type: field.type, + src: field.content || '', + fallbackText: field.fallbackText || '', + }, + }; +} + /** * @function returnValidator - Creates a validator function based on the provided collector * @param {ValidatedTextCollector | ObjectValueCollectors | MultiValueCollectors | AutoCollectors} collector - The collector to which the value will be validated diff --git a/packages/davinci-client/src/lib/davinci.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index 1d249953f2..ef6ebc23ee 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -74,6 +74,13 @@ export type ReadOnlyField = { key?: string; }; +export type QrCodeField = { + type: 'QR_CODE'; + key: string; + content: string; + fallbackText?: string; +}; + export type RedirectField = { type: 'SOCIAL_LOGIN_BUTTON'; key: string; @@ -221,7 +228,7 @@ export type ComplexValueFields = | FidoRegistrationField | FidoAuthenticationField; export type MultiValueFields = MultiSelectField; -export type ReadOnlyFields = ReadOnlyField; +export type ReadOnlyFields = ReadOnlyField | QrCodeField; export type RedirectFields = RedirectField; export type SingleValueFields = StandardField | ValidatedField | SingleSelectField | ProtectField; diff --git a/packages/davinci-client/src/lib/node.reducer.test.ts b/packages/davinci-client/src/lib/node.reducer.test.ts index d3ff78408d..dee32981ff 100644 --- a/packages/davinci-client/src/lib/node.reducer.test.ts +++ b/packages/davinci-client/src/lib/node.reducer.test.ts @@ -15,6 +15,7 @@ import type { MultiSelectCollector, PhoneNumberCollector, ProtectCollector, + QrCodeCollector, SubmitCollector, TextCollector, } from './collector.types.js'; @@ -415,6 +416,40 @@ describe('The node collector reducer', () => { 'ActionCollectors are read-only', ); }); + + it('should handle QR_CODE field type', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'QR_CODE', + key: 'qr-code-field', + content: 'data:image/png;base64,abc123', + fallbackText: '04ZKS2KCIWKXT8FHRX', + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result).toEqual([ + { + category: 'NoValueCollector', + error: null, + type: 'QrCodeCollector', + id: 'qr-code-field-0', + name: 'qr-code-field-0', + output: { + key: 'qr-code-field-0', + label: 'data:image/png;base64,abc123', + type: 'QR_CODE', + src: 'data:image/png;base64,abc123', + fallbackText: '04ZKS2KCIWKXT8FHRX', + }, + } satisfies QrCodeCollector, + ]); + }); }); describe('The node collector reducer with MultiValueCollector', () => { diff --git a/packages/davinci-client/src/lib/node.reducer.ts b/packages/davinci-client/src/lib/node.reducer.ts index 63bf2e36ec..867c3c5d0b 100644 --- a/packages/davinci-client/src/lib/node.reducer.ts +++ b/packages/davinci-client/src/lib/node.reducer.ts @@ -28,6 +28,7 @@ import { returnUnknownCollector, returnFidoRegistrationCollector, returnFidoAuthenticationCollector, + returnQrCodeCollector, } from './collector.utils.js'; import type { DaVinciField, UnknownField } from './davinci.types.js'; import type { @@ -53,6 +54,7 @@ import type { FidoAuthenticationCollector, FidoAuthenticationInputValue, FidoRegistrationInputValue, + QrCodeCollector, } from './collector.types.js'; /** @@ -98,6 +100,7 @@ const initialCollectorValues: ( | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector + | QrCodeCollector )[] = []; /** @@ -124,6 +127,10 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build return returnReadOnlyCollector(field, idx); } + if (field.type === 'QR_CODE') { + return returnQrCodeCollector(field, idx); + } + // *Some* collectors may have default or existing data to display const data = action.payload.formData && diff --git a/packages/davinci-client/src/lib/node.types.test-d.ts b/packages/davinci-client/src/lib/node.types.test-d.ts index de5f5b67f0..904088051d 100644 --- a/packages/davinci-client/src/lib/node.types.test-d.ts +++ b/packages/davinci-client/src/lib/node.types.test-d.ts @@ -35,6 +35,7 @@ import { ProtectCollector, FidoRegistrationCollector, FidoAuthenticationCollector, + QrCodeCollector, } from './collector.types.js'; // ErrorDetail and Links are used as part of the DaVinciError and server._links types respectively @@ -236,6 +237,7 @@ describe('Node Types', () => { | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector + | QrCodeCollector | UnknownCollector >(); diff --git a/packages/davinci-client/src/lib/node.types.ts b/packages/davinci-client/src/lib/node.types.ts index 2bcc57a5f2..96753a4109 100644 --- a/packages/davinci-client/src/lib/node.types.ts +++ b/packages/davinci-client/src/lib/node.types.ts @@ -25,6 +25,7 @@ import type { UnknownCollector, FidoRegistrationCollector, FidoAuthenticationCollector, + QrCodeCollector, } from './collector.types.js'; import type { Links } from './davinci.types.js'; @@ -46,6 +47,7 @@ export type Collectors = | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector + | QrCodeCollector | UnknownCollector; export interface CollectorErrors { diff --git a/packages/davinci-client/src/types.ts b/packages/davinci-client/src/types.ts index 691be0679e..f274efcb95 100644 --- a/packages/davinci-client/src/types.ts +++ b/packages/davinci-client/src/types.ts @@ -52,6 +52,7 @@ export type PhoneNumberCollector = collectors.PhoneNumberCollector; export type ProtectCollector = collectors.ProtectCollector; export type FidoRegistrationCollector = collectors.FidoRegistrationCollector; export type FidoAuthenticationCollector = collectors.FidoAuthenticationCollector; +export type QrCodeCollector = collectors.QrCodeCollector; export type InternalErrorResponse = client.InternalErrorResponse; export type { RequestMiddleware, ActionTypes } from '@forgerock/sdk-request-middleware';