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
5 changes: 5 additions & 0 deletions .changeset/clever-chicken-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@forgerock/davinci-client': patch
---

Fixed bugs related to device auth and registration
5 changes: 5 additions & 0 deletions .changeset/plain-books-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@forgerock/davinci-client': minor
---

Implemented phone number collector to support phone number field
15 changes: 15 additions & 0 deletions e2e/davinci-app/components/label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* 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 { ReadOnlyCollector } from '@forgerock/davinci-client/types';

export default function (formEl: HTMLFormElement, collector: ReadOnlyCollector) {
// create paragraph element with text of "Loading ... "
const p = document.createElement('p');

p.innerText = collector.output.label;
formEl?.appendChild(p);
}
90 changes: 90 additions & 0 deletions e2e/davinci-app/components/object-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* 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 {
DeviceAuthenticationCollector,
DeviceRegistrationCollector,
PhoneNumberCollector,
Updater,
} from '@forgerock/davinci-client/types';

/**
* Creates a dropdown component based on the provided data and attaches it to the form
* @param {HTMLFormElement} formEl - The form element to attach the dropdown to
* @param {SingleSelectCollector} collector - Contains the dropdown options and configuration
* @param {Updater} updater - Function to call when selection changes
*/
export default function objectValueComponent(
formEl: HTMLFormElement,
collector: DeviceRegistrationCollector | DeviceAuthenticationCollector | PhoneNumberCollector,
updater: Updater,
submitForm: () => void,
) {
if (
collector.type === 'DeviceAuthenticationCollector' ||
collector.type === 'DeviceRegistrationCollector'
) {
// Create the label element
const paragraphEl = document.createElement('p');
paragraphEl.textContent = collector.output.label || 'Select an option';
paragraphEl.className = 'object-options-title';

// Append elements to the form
formEl.appendChild(paragraphEl);

// Add all options from the data
for (const option of collector.output.options) {
const buttonEl = document.createElement('button');
buttonEl.setAttribute('type', 'button');

// Add change event listener
buttonEl.addEventListener('click', (event) => {
// Properly type the event target
const target = event.target as HTMLButtonElement;
const selectedValue = target.getAttribute('data-id');

if (!selectedValue) {
console.error('No value found for the selected option');
return;
}
updater(selectedValue);
submitForm();
});

buttonEl.setAttribute('data-id', option.value);
buttonEl.textContent = option.label;
formEl.appendChild(buttonEl);
}
} else {
const phoneLabel = document.createElement('label');
phoneLabel.textContent = collector.output.label || 'Phone Number';
phoneLabel.className = 'object-options-title';
phoneLabel.setAttribute('for', 'phone-number-input');

const phoneInput = document.createElement('input');
phoneInput.setAttribute('type', 'tel');
phoneInput.setAttribute('id', 'phone-number-input');
phoneInput.setAttribute('name', 'phone-number-input');
phoneInput.setAttribute('placeholder', 'Enter phone number');

// Add change event listener
phoneInput.addEventListener('change', (event) => {
// Properly type the event target
const target = event.target as HTMLInputElement;
const selectedValue = target.value;

if (!selectedValue) {
console.error('No value found for the selected option');
return;
}

updater({ phoneNumber: selectedValue, countryCode: collector.input.value.countryCode });
});

formEl.appendChild(phoneLabel);
formEl.appendChild(phoneInput);
}
}
1 change: 1 addition & 0 deletions e2e/davinci-app/components/single-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default function singleValueComponent(
// Create the label element
const labelEl = document.createElement('label');
labelEl.textContent = collector.output.label || 'Select an option';
labelEl.setAttribute('for', collector.output.key || 'dropdown-field');
labelEl.className = 'dropdown-label';

// Create the select element
Expand Down
51 changes: 40 additions & 11 deletions e2e/davinci-app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import socialLoginButtonComponent from './components/social-login-button.js';
import { serverConfigs } from './server-configs.js';
import singleValueComponent from './components/single-value.js';
import multiValueComponent from './components/multi-value.js';
import labelComponent from './components/label.js';
import objectValueComponent from './components/object-value.js';

const qs = window.location.search;
const searchParams = new URLSearchParams(qs);
Expand Down Expand Up @@ -58,6 +60,7 @@ const urlParams = new URLSearchParams(window.location.search);
// different middleware type than the old legacy config
await Config.setAsync(config as any);
}

function renderComplete() {
const clientInfo = davinciClient.getClient();
const serverInfo = davinciClient.getServer();
Expand Down Expand Up @@ -164,6 +167,21 @@ const urlParams = new URLSearchParams(window.location.search);
collector, // This is the plain object of the collector
davinciClient.update(collector), // Returns an update function for this collector
);
} else if (
collector.type === 'DeviceRegistrationCollector' ||
collector.type === 'DeviceAuthenticationCollector'
) {
objectValueComponent(
formEl, // You can ignore this; it's just for rendering
collector, // This is the plain object of the collector
davinciClient.update(collector), // Returns an update function for this collector
submitForm,
);
} else if (collector.type === 'ReadOnlyCollector') {
labelComponent(
formEl, // You can ignore this; it's just for rendering
collector, // This is the plain object of the collector
);
} else if (collector.type === 'TextCollector') {
textComponent(
formEl, // You can ignore this; it's just for rendering
Expand All @@ -184,6 +202,13 @@ 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 === 'PhoneNumberCollector') {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i know this doesn't have to be separated so we can remove this and add the if condition above but i just separated it for reading

objectValueComponent(
formEl, // You can ignore this; it's just for rendering
collector, // This is the plain object of the collector
davinciClient.update(collector), // Returns an update function for this collector
submitForm,
);
} else if (collector.type === 'IdpCollector') {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
collector;
Expand All @@ -206,17 +231,21 @@ const urlParams = new URLSearchParams(window.location.search);
});

if (davinciClient.getCollectors().find((collector) => collector.name === 'protectsdk')) {
const newNode = await davinciClient.next();

if (newNode.status === 'continue') {
renderForm();
} else if (newNode.status === 'success') {
renderComplete();
} else if (newNode.status === 'error') {
renderForm();
} else {
console.error('Unknown node status', newNode);
}
submitForm();
}
}

async function submitForm() {
const newNode = await davinciClient.next();

if (newNode.status === 'continue') {
renderForm();
} else if (newNode.status === 'success') {
renderComplete();
} else if (newNode.status === 'error') {
renderForm();
} else {
console.error('Unknown node status', newNode);
}
}

Expand Down
13 changes: 13 additions & 0 deletions e2e/davinci-app/server-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,17 @@ export const serverConfigs: Record<string, DaVinciConfig> = {
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration',
},
},
/**
* Phone Number Input With Email and Password
*
*/
'20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0': {
clientId: '20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0',
redirectUri: window.location.origin + '/',
scope: 'openid profile email revoke',
serverConfig: {
wellknown:
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration',
},
},
};
49 changes: 49 additions & 0 deletions e2e/davinci-suites/src/mfa.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* 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 { expect, test } from '@playwright/test';
import { asyncEvents } from './utils/async-events.js';
import { password } from './utils/demo-user.js';

test('Using ACR Values, lets render an OTP form and submit the request', async ({ page }) => {
const { navigate } = asyncEvents(page);
await navigate(
'/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0&acr_value=22eb75b5d31d371afe089d6e4a824f5c',
);

expect(page.url()).toBe(
'http://localhost:5829/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0&acr_value=22eb75b5d31d371afe089d6e4a824f5c',
);

await page.getByLabel('Email Address').fill('mfauser+' + Date.now() + '@user.com');
await page.getByLabel('Password').fill(password);
await page.getByLabel('Placeholder').fill('12345678901');
await page.getByRole('button', { name: 'Submit' }).click();

await page.getByRole('button', { name: 'Text Message' }).click();

await page.waitForEvent('requestfinished');

await page.getByText('MFA - Enter Phone Number');

await page.getByLabel('Country Code').selectOption('United States (1)');
await page.getByLabel('Enter Phone Number').fill('12345678901');

const request = page.waitForRequest((request) =>
request.url().endsWith('/capabilities/customForm?next=true'),
);
await page.getByRole('button', { name: 'Submit' }).click();
const posted = await request;
const postedData = JSON.parse(posted.postData());
const data = postedData.parameters.data;
expect(data).toEqual({
actionKey: 'submit',
formData: {
countryCode: '1',
phoneNumber: '12345678901',
},
});
});
43 changes: 43 additions & 0 deletions e2e/davinci-suites/src/phone-number-field.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* 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 { expect, test } from '@playwright/test';
import { asyncEvents } from './utils/async-events.js';

test('Test happy paths on test page', async ({ page }) => {
const { navigate } = asyncEvents(page);
await navigate('/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0');

expect(page.url()).toBe('http://localhost:5829/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0');

await expect(page.getByText('Create Your Profile')).toBeVisible();

await page.getByLabel('Email Address').fill('test@test.com');
await page.getByLabel('Password').fill('apassword');
await page.getByLabel('Placeholder').fill('12345678901');

const requestPromise = page.waitForRequest((request) =>
request
.url()
.includes(
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/davinci/connections/8209285e0d2f3fc76bfd23fd10d45e6f/capabilities/customForm?next=true',
),
);

await page.getByRole('button', { name: 'Submit' }).click();

const request = await requestPromise;
const postedData = JSON.parse(request.postData());
const data = postedData.parameters.data;
expect(data).toEqual({
actionKey: 'submit',
formData: {
'user.email': 'test@test.com',
'user.password': 'apassword',
'phone-field': { phoneNumber: '12345678901', countryCode: 'CA' },
},
});
});
3 changes: 2 additions & 1 deletion packages/davinci-client/src/lib/client.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
IdpCollector,
MultiSelectCollector,
ObjectValueCollectors,
PhoneNumberInputValue,
} from './collector.types.js';
import type { InitFlow, Updater, Validator } from './client.types.js';
import { returnValidator } from './collector.utils.js';
Expand Down Expand Up @@ -223,7 +224,7 @@ export async function davinci<ActionType extends ActionTypes = ActionTypes>({
};
}

return function (value: string | string[], index?: number) {
return function (value: string | string[] | PhoneNumberInputValue, index?: number) {
try {
store.dispatch(nodeSlice.actions.update({ id, value, index }));
return null;
Expand Down
8 changes: 6 additions & 2 deletions packages/davinci-client/src/lib/client.types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { describe, expectTypeOf, it } from 'vitest';
import type { InitFlow, InternalErrorResponse, Updater } from './client.types.js';
import type { GenericError } from './error.types.js';
import type { ErrorNode, FailureNode, ContinueNode, StartNode, SuccessNode } from './node.types.js';
import { PhoneNumberInputValue } from './collector.types.js';

describe('Client Types', () => {
it('should allow function returning error', async () => {
Expand Down Expand Up @@ -166,13 +167,16 @@ describe('Client Types', () => {

describe('Updater', () => {
it('should accept string value and optional index', () => {
const updater: Updater = (value: string | string[] | boolean, index?: number) => {
const updater: Updater = (
value: string | string[] | boolean | PhoneNumberInputValue,
index?: number,
) => {
return {
error: { message: 'Invalid value', code: 'INVALID', type: 'state_error' },
type: 'internal_error',
};
};
expectTypeOf(updater).parameter(0).toEqualTypeOf<string | string[]>();
expectTypeOf(updater).parameter(0).toEqualTypeOf<string | string[] | PhoneNumberInputValue>();
expectTypeOf(updater).parameter(1).toBeNullable();
expectTypeOf(updater).parameter(1).toBeNullable();
});
Expand Down
6 changes: 5 additions & 1 deletion packages/davinci-client/src/lib/client.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
import { PhoneNumberInputValue } from './collector.types.js';
import { GenericError } from './error.types.js';
import { ErrorNode, FailureNode, ContinueNode, StartNode, SuccessNode } from './node.types.js';

Expand All @@ -16,7 +17,10 @@ export interface InternalErrorResponse {

export type InitFlow = () => Promise<FlowNode | InternalErrorResponse>;

export type Updater = (value: string | string[], index?: number) => InternalErrorResponse | null;
export type Updater = (
value: string | string[] | PhoneNumberInputValue,
index?: number,
) => InternalErrorResponse | null;
export type Validator = (value: string) =>
| string[]
| {
Expand Down
Loading
Loading