Skip to content

Commit 0c6466c

Browse files
authored
Merge pull request #270 from ForgeRock/feat_phone-number-component
feat(davinci-client): implement phone number field support
2 parents 54a8029 + 1167ca2 commit 0c6466c

23 files changed

Lines changed: 702 additions & 66 deletions

.changeset/clever-chicken-smile.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@forgerock/davinci-client': patch
3+
---
4+
5+
Fixed bugs related to device auth and registration

.changeset/plain-books-beam.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+
Implemented phone number collector to support phone number field
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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 { ReadOnlyCollector } from '@forgerock/davinci-client/types';
8+
9+
export default function (formEl: HTMLFormElement, collector: ReadOnlyCollector) {
10+
// create paragraph element with text of "Loading ... "
11+
const p = document.createElement('p');
12+
13+
p.innerText = collector.output.label;
14+
formEl?.appendChild(p);
15+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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 {
8+
DeviceAuthenticationCollector,
9+
DeviceRegistrationCollector,
10+
PhoneNumberCollector,
11+
Updater,
12+
} from '@forgerock/davinci-client/types';
13+
14+
/**
15+
* Creates a dropdown component based on the provided data and attaches it to the form
16+
* @param {HTMLFormElement} formEl - The form element to attach the dropdown to
17+
* @param {SingleSelectCollector} collector - Contains the dropdown options and configuration
18+
* @param {Updater} updater - Function to call when selection changes
19+
*/
20+
export default function objectValueComponent(
21+
formEl: HTMLFormElement,
22+
collector: DeviceRegistrationCollector | DeviceAuthenticationCollector | PhoneNumberCollector,
23+
updater: Updater,
24+
submitForm: () => void,
25+
) {
26+
if (
27+
collector.type === 'DeviceAuthenticationCollector' ||
28+
collector.type === 'DeviceRegistrationCollector'
29+
) {
30+
// Create the label element
31+
const paragraphEl = document.createElement('p');
32+
paragraphEl.textContent = collector.output.label || 'Select an option';
33+
paragraphEl.className = 'object-options-title';
34+
35+
// Append elements to the form
36+
formEl.appendChild(paragraphEl);
37+
38+
// Add all options from the data
39+
for (const option of collector.output.options) {
40+
const buttonEl = document.createElement('button');
41+
buttonEl.setAttribute('type', 'button');
42+
43+
// Add change event listener
44+
buttonEl.addEventListener('click', (event) => {
45+
// Properly type the event target
46+
const target = event.target as HTMLButtonElement;
47+
const selectedValue = target.getAttribute('data-id');
48+
49+
if (!selectedValue) {
50+
console.error('No value found for the selected option');
51+
return;
52+
}
53+
updater(selectedValue);
54+
submitForm();
55+
});
56+
57+
buttonEl.setAttribute('data-id', option.value);
58+
buttonEl.textContent = option.label;
59+
formEl.appendChild(buttonEl);
60+
}
61+
} else {
62+
const phoneLabel = document.createElement('label');
63+
phoneLabel.textContent = collector.output.label || 'Phone Number';
64+
phoneLabel.className = 'object-options-title';
65+
phoneLabel.setAttribute('for', 'phone-number-input');
66+
67+
const phoneInput = document.createElement('input');
68+
phoneInput.setAttribute('type', 'tel');
69+
phoneInput.setAttribute('id', 'phone-number-input');
70+
phoneInput.setAttribute('name', 'phone-number-input');
71+
phoneInput.setAttribute('placeholder', 'Enter phone number');
72+
73+
// Add change event listener
74+
phoneInput.addEventListener('change', (event) => {
75+
// Properly type the event target
76+
const target = event.target as HTMLInputElement;
77+
const selectedValue = target.value;
78+
79+
if (!selectedValue) {
80+
console.error('No value found for the selected option');
81+
return;
82+
}
83+
84+
updater({ phoneNumber: selectedValue, countryCode: collector.input.value.countryCode });
85+
});
86+
87+
formEl.appendChild(phoneLabel);
88+
formEl.appendChild(phoneInput);
89+
}
90+
}

e2e/davinci-app/components/single-value.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default function singleValueComponent(
2020
// Create the label element
2121
const labelEl = document.createElement('label');
2222
labelEl.textContent = collector.output.label || 'Select an option';
23+
labelEl.setAttribute('for', collector.output.key || 'dropdown-field');
2324
labelEl.className = 'dropdown-label';
2425

2526
// Create the select element

e2e/davinci-app/main.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import socialLoginButtonComponent from './components/social-login-button.js';
1919
import { serverConfigs } from './server-configs.js';
2020
import singleValueComponent from './components/single-value.js';
2121
import multiValueComponent from './components/multi-value.js';
22+
import labelComponent from './components/label.js';
23+
import objectValueComponent from './components/object-value.js';
2224

2325
const qs = window.location.search;
2426
const searchParams = new URLSearchParams(qs);
@@ -58,6 +60,7 @@ const urlParams = new URLSearchParams(window.location.search);
5860
// different middleware type than the old legacy config
5961
await Config.setAsync(config as any);
6062
}
63+
6164
function renderComplete() {
6265
const clientInfo = davinciClient.getClient();
6366
const serverInfo = davinciClient.getServer();
@@ -164,6 +167,21 @@ const urlParams = new URLSearchParams(window.location.search);
164167
collector, // This is the plain object of the collector
165168
davinciClient.update(collector), // Returns an update function for this collector
166169
);
170+
} else if (
171+
collector.type === 'DeviceRegistrationCollector' ||
172+
collector.type === 'DeviceAuthenticationCollector'
173+
) {
174+
objectValueComponent(
175+
formEl, // You can ignore this; it's just for rendering
176+
collector, // This is the plain object of the collector
177+
davinciClient.update(collector), // Returns an update function for this collector
178+
submitForm,
179+
);
180+
} else if (collector.type === 'ReadOnlyCollector') {
181+
labelComponent(
182+
formEl, // You can ignore this; it's just for rendering
183+
collector, // This is the plain object of the collector
184+
);
167185
} else if (collector.type === 'TextCollector') {
168186
textComponent(
169187
formEl, // You can ignore this; it's just for rendering
@@ -184,6 +202,13 @@ const urlParams = new URLSearchParams(window.location.search);
184202
formEl, // You can ignore this; it's just for rendering
185203
collector, // This is the plain object of the collector
186204
);
205+
} else if (collector.type === 'PhoneNumberCollector') {
206+
objectValueComponent(
207+
formEl, // You can ignore this; it's just for rendering
208+
collector, // This is the plain object of the collector
209+
davinciClient.update(collector), // Returns an update function for this collector
210+
submitForm,
211+
);
187212
} else if (collector.type === 'IdpCollector') {
188213
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
189214
collector;
@@ -206,17 +231,21 @@ const urlParams = new URLSearchParams(window.location.search);
206231
});
207232

208233
if (davinciClient.getCollectors().find((collector) => collector.name === 'protectsdk')) {
209-
const newNode = await davinciClient.next();
210-
211-
if (newNode.status === 'continue') {
212-
renderForm();
213-
} else if (newNode.status === 'success') {
214-
renderComplete();
215-
} else if (newNode.status === 'error') {
216-
renderForm();
217-
} else {
218-
console.error('Unknown node status', newNode);
219-
}
234+
submitForm();
235+
}
236+
}
237+
238+
async function submitForm() {
239+
const newNode = await davinciClient.next();
240+
241+
if (newNode.status === 'continue') {
242+
renderForm();
243+
} else if (newNode.status === 'success') {
244+
renderComplete();
245+
} else if (newNode.status === 'error') {
246+
renderForm();
247+
} else {
248+
console.error('Unknown node status', newNode);
220249
}
221250
}
222251

e2e/davinci-app/server-configs.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,17 @@ export const serverConfigs: Record<string, DaVinciConfig> = {
5555
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration',
5656
},
5757
},
58+
/**
59+
* Phone Number Input With Email and Password
60+
*
61+
*/
62+
'20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0': {
63+
clientId: '20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0',
64+
redirectUri: window.location.origin + '/',
65+
scope: 'openid profile email revoke',
66+
serverConfig: {
67+
wellknown:
68+
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration',
69+
},
70+
},
5871
};

e2e/davinci-suites/src/mfa.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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 } from './utils/demo-user.js';
10+
11+
test('Using ACR Values, lets render an OTP form and submit the request', async ({ page }) => {
12+
const { navigate } = asyncEvents(page);
13+
await navigate(
14+
'/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0&acr_value=22eb75b5d31d371afe089d6e4a824f5c',
15+
);
16+
17+
expect(page.url()).toBe(
18+
'http://localhost:5829/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0&acr_value=22eb75b5d31d371afe089d6e4a824f5c',
19+
);
20+
21+
await page.getByLabel('Email Address').fill('mfauser+' + Date.now() + '@user.com');
22+
await page.getByLabel('Password').fill(password);
23+
await page.getByLabel('Placeholder').fill('12345678901');
24+
await page.getByRole('button', { name: 'Submit' }).click();
25+
26+
await page.getByRole('button', { name: 'Text Message' }).click();
27+
28+
await page.waitForEvent('requestfinished');
29+
30+
await page.getByText('MFA - Enter Phone Number');
31+
32+
await page.getByLabel('Country Code').selectOption('United States (1)');
33+
await page.getByLabel('Enter Phone Number').fill('12345678901');
34+
35+
const request = page.waitForRequest((request) =>
36+
request.url().endsWith('/capabilities/customForm?next=true'),
37+
);
38+
await page.getByRole('button', { name: 'Submit' }).click();
39+
const posted = await request;
40+
const postedData = JSON.parse(posted.postData());
41+
const data = postedData.parameters.data;
42+
expect(data).toEqual({
43+
actionKey: 'submit',
44+
formData: {
45+
countryCode: '1',
46+
phoneNumber: '12345678901',
47+
},
48+
});
49+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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+
10+
test('Test happy paths on test page', async ({ page }) => {
11+
const { navigate } = asyncEvents(page);
12+
await navigate('/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0');
13+
14+
expect(page.url()).toBe('http://localhost:5829/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0');
15+
16+
await expect(page.getByText('Create Your Profile')).toBeVisible();
17+
18+
await page.getByLabel('Email Address').fill('test@test.com');
19+
await page.getByLabel('Password').fill('apassword');
20+
await page.getByLabel('Placeholder').fill('12345678901');
21+
22+
const requestPromise = page.waitForRequest((request) =>
23+
request
24+
.url()
25+
.includes(
26+
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/davinci/connections/8209285e0d2f3fc76bfd23fd10d45e6f/capabilities/customForm?next=true',
27+
),
28+
);
29+
30+
await page.getByRole('button', { name: 'Submit' }).click();
31+
32+
const request = await requestPromise;
33+
const postedData = JSON.parse(request.postData());
34+
const data = postedData.parameters.data;
35+
expect(data).toEqual({
36+
actionKey: 'submit',
37+
formData: {
38+
'user.email': 'test@test.com',
39+
'user.password': 'apassword',
40+
'phone-field': { phoneNumber: '12345678901', countryCode: 'CA' },
41+
},
42+
});
43+
});

packages/davinci-client/src/lib/client.store.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type {
2929
IdpCollector,
3030
MultiSelectCollector,
3131
ObjectValueCollectors,
32+
PhoneNumberInputValue,
3233
} from './collector.types.js';
3334
import type { InitFlow, Updater, Validator } from './client.types.js';
3435
import { returnValidator } from './collector.utils.js';
@@ -223,7 +224,7 @@ export async function davinci<ActionType extends ActionTypes = ActionTypes>({
223224
};
224225
}
225226

226-
return function (value: string | string[], index?: number) {
227+
return function (value: string | string[] | PhoneNumberInputValue, index?: number) {
227228
try {
228229
store.dispatch(nodeSlice.actions.update({ id, value, index }));
229230
return null;

0 commit comments

Comments
 (0)