Skip to content

Commit 037b012

Browse files
authored
Merge pull request #562 from ForgeRock/qr-code
feat(davinci-client): add QRCode collector support
2 parents 916e21c + 8419cd9 commit 037b012

17 files changed

Lines changed: 280 additions & 9 deletions

File tree

.changeset/fast-ways-rest.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@forgerock/davinci-client': minor
3+
---
4+
5+
Add QR code collector support to davinci-client

.github/actions/setup/action.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ runs:
3131
registry-url: 'https://registry.npmjs.org'
3232

3333
- name: Update npm
34-
run: npm install -g npm@latest
34+
run: |
35+
corepack enable
36+
corepack install -g npm@latest
3537
shell: bash
3638

3739
- name: Install dependencies

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
with:
3333
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
3434

35-
- run: npx nx-cloud fix-ci
35+
- run: pnpm nx fix-ci
3636
if: always()
3737

3838
- uses: codecov/codecov-action@v5
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
3+
*
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*/
7+
import type { QrCodeCollector } from '@forgerock/davinci-client/types';
8+
9+
export default function (formEl: HTMLFormElement, collector: QrCodeCollector) {
10+
if (collector.error) {
11+
const errorEl = document.createElement('p');
12+
errorEl.innerText = `QR Code error: ${collector.error}`;
13+
formEl.appendChild(errorEl);
14+
return;
15+
}
16+
17+
const container = document.createElement('div');
18+
19+
const img = document.createElement('img');
20+
img.src = collector.output.src;
21+
img.alt = 'QR Code';
22+
img.setAttribute('data-testid', 'qr-code-image');
23+
container.appendChild(img);
24+
25+
if (collector.output.label) {
26+
const fallback = document.createElement('p');
27+
fallback.innerText = `Manual code: ${collector.output.label}`;
28+
fallback.setAttribute('data-testid', 'qr-code-fallback');
29+
container.appendChild(fallback);
30+
}
31+
32+
formEl.appendChild(container);
33+
}

e2e/davinci-app/main.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import multiValueComponent from './components/multi-value.js';
3232
import labelComponent from './components/label.js';
3333
import objectValueComponent from './components/object-value.js';
3434
import fidoComponent from './components/fido.js';
35+
import qrCodeComponent from './components/qr-code.js';
3536

3637
const loggerFn = {
3738
error: () => {
@@ -223,6 +224,8 @@ const urlParams = new URLSearchParams(window.location.search);
223224
formEl, // You can ignore this; it's just for rendering
224225
collector, // This is the plain object of the collector
225226
);
227+
} else if (collector.type === 'QrCodeCollector') {
228+
qrCodeComponent(formEl, collector);
226229
} else if (collector.type === 'TextCollector') {
227230
textComponent(
228231
formEl, // You can ignore this; it's just for rendering

e2e/davinci-app/server-configs.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ export const serverConfigs: Record<string, DaVinciConfig> = {
5757
},
5858
/**
5959
* Phone Number Input With Email and Password
60-
*
6160
*/
6261
'20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0': {
6362
clientId: '20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0',
@@ -68,4 +67,14 @@ export const serverConfigs: Record<string, DaVinciConfig> = {
6867
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration',
6968
},
7069
},
70+
/** QR Code policy id : aa3c00c3ec25a9721be078f7bf44678d **/
71+
'c12743f9-08e8-4420-a624-71bbb08e9fe1': {
72+
clientId: 'c12743f9-08e8-4420-a624-71bbb08e9fe1',
73+
redirectUri: window.location.origin + '/',
74+
scope: 'openid profile email',
75+
serverConfig: {
76+
wellknown:
77+
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration',
78+
},
79+
},
7180
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
3+
*
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*/
7+
import { expect, test } from '@playwright/test';
8+
import { asyncEvents } from './utils/async-events.js';
9+
import { password, username } from './utils/demo-user.js';
10+
11+
const qrCodeUrl =
12+
'/?clientId=c12743f9-08e8-4420-a624-71bbb08e9fe1&acr_values=9da1b93991bcd577947da228ad4c741f';
13+
14+
test('QR code renders after navigating through device registration flow', async ({ page }) => {
15+
const { navigate } = asyncEvents(page);
16+
17+
await navigate(qrCodeUrl);
18+
19+
// Step 1: Login
20+
await page.getByRole('button', { name: 'USER_LOGIN' }).click();
21+
await page.waitForEvent('requestfinished');
22+
23+
await page.getByLabel('Username').fill(username);
24+
await page.getByLabel('Password').fill(password);
25+
await page.getByRole('button', { name: 'Sign On' }).click();
26+
await page.waitForEvent('requestfinished');
27+
28+
// Step 2: Select device registration
29+
await page.getByRole('button', { name: 'DEVICE_REGISTRATION' }).click();
30+
await page.waitForEvent('requestfinished');
31+
32+
// Step 3: Choose "Mobile App" from the device selection screen
33+
await expect(page.getByText('MFA Device Selection - Registration')).toBeVisible();
34+
await page.getByRole('button', { name: 'Mobile App' }).click();
35+
await page.waitForEvent('requestfinished');
36+
37+
// Step 4: QR code should now be visible
38+
const qrImage = page.locator('[data-testid="qr-code-image"]');
39+
await expect(qrImage).toBeVisible({ timeout: 10000 });
40+
41+
// Verify the image has a base64-encoded src
42+
const src = await qrImage.getAttribute('src');
43+
expect(src).toBeTruthy();
44+
expect(src).toContain('data:image/png;base64,');
45+
46+
// Verify fallback text is displayed if present
47+
const fallback = page.locator('[data-testid="qr-code-fallback"]');
48+
const fallbackVisible = await fallback.isVisible();
49+
if (fallbackVisible) {
50+
const fallbackText = await fallback.textContent();
51+
expect(fallbackText).toBeTruthy();
52+
}
53+
});

packages/davinci-client/src/lib/collector.types.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,7 @@ export type SubmitCollector = ActionCollectorNoUrl<'SubmitCollector'>;
482482
/**
483483
* @interface NoValueCollector - Represents a collector that collects no value; text only for display.
484484
*/
485-
export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'NoValueCollector';
485+
export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'NoValueCollector' | 'QrCodeCollector';
486486

487487
export interface NoValueCollectorBase<T extends NoValueCollectorTypes> {
488488
category: 'NoValueCollector';
@@ -497,6 +497,20 @@ export interface NoValueCollectorBase<T extends NoValueCollectorTypes> {
497497
};
498498
}
499499

500+
export interface QrCodeCollectorBase {
501+
category: 'NoValueCollector';
502+
error: string | null;
503+
type: 'QrCodeCollector';
504+
id: string;
505+
name: string;
506+
output: {
507+
key: string;
508+
label: string;
509+
type: string;
510+
src: string;
511+
};
512+
}
513+
500514
/**
501515
* Type to help infer the collector based on the collector type
502516
* Used specifically in the returnNoValueCollector wrapper function.
@@ -507,16 +521,21 @@ export interface NoValueCollectorBase<T extends NoValueCollectorTypes> {
507521
export type InferNoValueCollectorType<T extends NoValueCollectorTypes> =
508522
T extends 'ReadOnlyCollector'
509523
? NoValueCollectorBase<'ReadOnlyCollector'>
510-
: NoValueCollectorBase<'NoValueCollector'>;
524+
: T extends 'QrCodeCollector'
525+
? QrCodeCollectorBase
526+
: NoValueCollectorBase<'NoValueCollector'>;
511527

512528
export type NoValueCollectors =
513529
| NoValueCollectorBase<'NoValueCollector'>
514-
| NoValueCollectorBase<'ReadOnlyCollector'>;
530+
| NoValueCollectorBase<'ReadOnlyCollector'>
531+
| QrCodeCollectorBase;
515532

516533
export type NoValueCollector<T extends NoValueCollectorTypes> = NoValueCollectorBase<T>;
517534

518535
export type ReadOnlyCollector = NoValueCollectorBase<'ReadOnlyCollector'>;
519536

537+
export type QrCodeCollector = QrCodeCollectorBase;
538+
520539
export type UnknownCollector = {
521540
category: 'UnknownCollector';
522541
error: string | null;

packages/davinci-client/src/lib/collector.utils.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
returnObjectValueCollector,
2323
returnSingleValueAutoCollector,
2424
returnObjectValueAutoCollector,
25+
returnQrCodeCollector,
2526
} from './collector.utils.js';
2627
import type {
2728
DaVinciField,
@@ -31,6 +32,7 @@ import type {
3132
FidoRegistrationField,
3233
PhoneNumberField,
3334
ProtectField,
35+
QrCodeField,
3436
ReadOnlyField,
3537
RedirectField,
3638
StandardField,
@@ -809,6 +811,77 @@ describe('No Value Collectors', () => {
809811
});
810812
});
811813

814+
describe('returnQrCodeCollector', () => {
815+
it('should return a valid QrCodeCollector with src and label from fallbackText', () => {
816+
const mockField: QrCodeField = {
817+
type: 'QR_CODE',
818+
key: 'qr-code-field',
819+
content: 'data:image/png;base64,abc123',
820+
fallbackText: '04ZKS2KCIWKXT8FHRX',
821+
};
822+
const result = returnQrCodeCollector(mockField, 2);
823+
expect(result).toEqual({
824+
category: 'NoValueCollector',
825+
error: null,
826+
type: 'QrCodeCollector',
827+
id: 'qr-code-field-2',
828+
name: 'qr-code-field-2',
829+
output: {
830+
key: 'qr-code-field-2',
831+
label: '04ZKS2KCIWKXT8FHRX',
832+
type: 'QR_CODE',
833+
src: 'data:image/png;base64,abc123',
834+
},
835+
});
836+
});
837+
838+
it('should handle missing fallbackText gracefully', () => {
839+
const mockField: QrCodeField = {
840+
type: 'QR_CODE',
841+
key: 'qr-code-field',
842+
content: 'data:image/png;base64,abc123',
843+
};
844+
const result = returnQrCodeCollector(mockField, 0);
845+
expect(result).toEqual({
846+
category: 'NoValueCollector',
847+
error: null,
848+
type: 'QrCodeCollector',
849+
id: 'qr-code-field-0',
850+
name: 'qr-code-field-0',
851+
output: {
852+
key: 'qr-code-field-0',
853+
label: '',
854+
type: 'QR_CODE',
855+
src: 'data:image/png;base64,abc123',
856+
},
857+
});
858+
});
859+
860+
it('should set error when content is missing', () => {
861+
const mockField = { type: 'QR_CODE', key: 'qr-code-field' } as unknown as QrCodeField;
862+
const result = returnQrCodeCollector(mockField, 0);
863+
expect(result.error).toContain('Content is not found');
864+
expect(result.output.src).toBe('');
865+
});
866+
867+
it('should fall back to type for id/name when key is missing', () => {
868+
const mockField = {
869+
type: 'QR_CODE',
870+
content: 'data:image/png;base64,abc123',
871+
} as unknown as QrCodeField;
872+
const result = returnQrCodeCollector(mockField, 0);
873+
expect(result.error).toBeNull();
874+
expect(result.id).toBe('QR_CODE-0');
875+
expect(result.name).toBe('QR_CODE-0');
876+
});
877+
878+
it('should only report content error when both key and content are missing', () => {
879+
const mockField = { type: 'QR_CODE' } as unknown as QrCodeField;
880+
const result = returnQrCodeCollector(mockField, 0);
881+
expect(result.error).toContain('Content is not found');
882+
});
883+
});
884+
812885
describe('returnSingleValueAutoCollector', () => {
813886
it('should create a valid ProtectCollector', () => {
814887
const mockField: ProtectField = {

packages/davinci-client/src/lib/collector.utils.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type {
2929
AutoCollectors,
3030
SingleValueAutoCollectorTypes,
3131
ObjectValueAutoCollectorTypes,
32+
QrCodeCollectorBase,
3233
} from './collector.types.js';
3334
import type {
3435
DeviceAuthenticationField,
@@ -38,6 +39,7 @@ import type {
3839
MultiSelectField,
3940
PhoneNumberField,
4041
ProtectField,
42+
QrCodeField,
4143
ReadOnlyField,
4244
RedirectField,
4345
SingleSelectField,
@@ -678,7 +680,7 @@ export function returnObjectValueCollector(
678680
* @returns {NoValueCollector} The constructed NoValueCollector object.
679681
*/
680682
export function returnNoValueCollector<
681-
Field extends ReadOnlyField,
683+
Field extends ReadOnlyField | QrCodeField,
682684
CollectorType extends NoValueCollectorTypes = 'NoValueCollector',
683685
>(field: Field, idx: number, collectorType: CollectorType) {
684686
let error = '';
@@ -713,6 +715,25 @@ export function returnReadOnlyCollector(field: ReadOnlyField, idx: number) {
713715
return returnNoValueCollector(field, idx, 'ReadOnlyCollector');
714716
}
715717

718+
/**
719+
* @function returnQrCodeCollector - Creates a QrCodeCollector object for displaying QR code images.
720+
* @param {QrCodeField} field - The field object containing key, content, type, and optional fallbackText.
721+
* @param {number} idx - The index to be used in the id of the QrCodeCollector.
722+
* @returns {QrCodeCollectorBase} The constructed QrCodeCollector object.
723+
*/
724+
export function returnQrCodeCollector(field: QrCodeField, idx: number): QrCodeCollectorBase {
725+
const base = returnNoValueCollector(field, idx, 'QrCodeCollector');
726+
727+
return {
728+
...base,
729+
output: {
730+
...base.output,
731+
label: field.fallbackText || '',
732+
src: field.content || '',
733+
},
734+
};
735+
}
736+
716737
/**
717738
* @function returnValidator - Creates a validator function based on the provided collector
718739
* @param {ValidatedTextCollector | ObjectValueCollectors | MultiValueCollectors | AutoCollectors} collector - The collector to which the value will be validated

0 commit comments

Comments
 (0)