diff --git a/packages/react-embed/README.md b/packages/react-embed/README.md index 8d7e963..fe22611 100644 --- a/packages/react-embed/README.md +++ b/packages/react-embed/README.md @@ -35,6 +35,26 @@ const EmbedFlowExample: React.FunctionComponent = () => ( ); ``` +Use the separate `SecureEmbedFlow` component to pass responder data in a POST +body: + +```tsx +import { SecureEmbedFlow } from '@formsort/react-embed'; + +; +``` + +`responderUuid` and `initialAnswers` are optional. `SecureEmbedFlow` does not +accept `queryParams` or `formsortEnv`. It has the same configuration and event +props as `EmbedFlow`. + ### Events You can add event listeners to flows like `Flowloaded`, `redirect` etc. See [all event listeners](https://github.com/formsort/oss/tree/master/packages/web-embed-api#event-listeners) @@ -58,6 +78,17 @@ You can add event listeners to flows like `Flowloaded`, `redirect` etc. See [all | onRedirect | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#redirect--url-string-answers--key-string-any-----cancel-boolean---undefined) | no | `(url: string) => { console.log('redirecting to:', url) }` | | onUnauthorized | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#unauthorized---void) | no | `() => { console.log('ID token is missing or invalid.') }` | +### SecureEmbedFlow props + +`SecureEmbedFlow` supports `clientLabel`, `flowLabel`, `variantLabel`, +`embedConfig`, and all event props from `EmbedFlow`. It also supports these +secure POST props: + +| Prop name | Description | Required | Example values | +| -------------- | ------------------------------------- | -------- | -------------------------------------- | +| responderUuid | responder UUID sent in the POST body | no | `e4923baa-dc2d-4555-813c-a166952292fa` | +| initialAnswers | initial answers sent in the POST body | no | `{ firstName: 'Olivia' }` | + ### Loading a specific variant revision You can use query parameters to load a specific variant revision. Don't use it if you want to show latest variant. diff --git a/packages/react-embed/src/__tests__/EmbedFlow.test.tsx b/packages/react-embed/src/__tests__/EmbedFlow.test.tsx index a798e3e..dc6cbc4 100644 --- a/packages/react-embed/src/__tests__/EmbedFlow.test.tsx +++ b/packages/react-embed/src/__tests__/EmbedFlow.test.tsx @@ -1,18 +1,26 @@ -import FormsortWebEmbed, { IFormsortWebEmbed } from '@formsort/web-embed-api'; +import FormsortWebEmbed, { + FormsortSecureWebEmbed, + IFormsortSecureWebEmbed, + IFormsortWebEmbed, +} from '@formsort/web-embed-api'; import { render } from '@testing-library/react'; import React from 'react'; -import EmbedFlow from '..'; +import EmbedFlow, { SecureEmbedFlow } from '..'; jest.mock('@formsort/web-embed-api'); const mockWebEmbedApi = FormsortWebEmbed as jest.MockedFunction< typeof FormsortWebEmbed >; +const mockSecureWebEmbedApi = FormsortSecureWebEmbed as jest.MockedFunction< + typeof FormsortSecureWebEmbed +>; describe('EmbedFlow component', () => { let loadMock: jest.Mock; let embedMock: IFormsortWebEmbed; + let secureEmbedMock: IFormsortSecureWebEmbed; let addEventListenerMock: jest.Mock; let removeEventListenerMock: jest.Mock; @@ -27,10 +35,16 @@ describe('EmbedFlow component', () => { addEventListener: addEventListenerMock, removeEventListener: removeEventListenerMock, }; + secureEmbedMock = { + ...embedMock, + loadFlow: loadMock, + }; mockWebEmbedApi.mockReturnValueOnce(embedMock); + mockSecureWebEmbedApi.mockReturnValue(secureEmbedMock); }); afterEach(() => { mockWebEmbedApi.mockClear(); + mockSecureWebEmbedApi.mockClear(); }); test('should load flows without variant label', () => { @@ -105,7 +119,7 @@ describe('EmbedFlow component', () => { ); }); - test('should load flows with URL params', () => { + test('should load flows with URL parameters', () => { const uuid = 'b1c7d9c8-f4b0-4f3f-9fc3-abf32ae8a061'; render( { variantLabel="test-variant" responderUuid={uuid} formsortEnv="staging" + queryParams={[['campaign', 'summer']]} /> ); expect(loadMock).toBeCalledWith( @@ -121,9 +136,39 @@ describe('EmbedFlow component', () => { 'test-flow', 'test-variant', [ + ['campaign', 'summer'], ['responderUuid', uuid], ['formsortEnv', 'staging'], ] ); }); + + test('should load sensitive data with the secure web embed', () => { + const responderUuid = 'b1c7d9c8-f4b0-4f3f-9fc3-abf32ae8a061'; + const initialAnswers = { + firstName: 'Olivia', + }; + + render( + + ); + + expect(mockSecureWebEmbedApi).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + undefined + ); + expect(mockWebEmbedApi).not.toHaveBeenCalled(); + expect(loadMock).toHaveBeenCalledWith( + 'test-client', + 'test-flow', + undefined, + responderUuid, + initialAnswers + ); + }); }); diff --git a/packages/react-embed/src/index.tsx b/packages/react-embed/src/index.tsx index 20da356..4d4a0e5 100644 --- a/packages/react-embed/src/index.tsx +++ b/packages/react-embed/src/index.tsx @@ -1,6 +1,9 @@ import { SupportedAnalyticsEvent } from '@formsort/embed-messaging-manager'; import FormsortWebEmbed, { + FormsortInitialAnswers, + FormsortSecureWebEmbed, IEventMap, + IFormsortSecureWebEmbed, IFormsortWebEmbed, IFormsortWebEmbedConfig, } from '@formsort/web-embed-api'; @@ -34,6 +37,14 @@ export interface IReactEmbedEventMap { export type EmbedFlowProps = ILoadProps & IReactEmbedEventMap; +export type SecureEmbedFlowProps = Pick< + ILoadProps, + 'clientLabel' | 'flowLabel' | 'variantLabel' | 'embedConfig' +> & { + responderUuid?: string; + initialAnswers?: FormsortInitialAnswers; +} & IReactEmbedEventMap; + export const eventMapping: Record = { onUnauthorized: 'unauthorized', @@ -46,7 +57,7 @@ export const eventMapping: Record = }; const attachEventListenersToEmbed = ( - embed: IFormsortWebEmbed, + embed: IFormsortWebEmbed | IFormsortSecureWebEmbed, events: IReactEmbedEventMap ): void => { for (const [reactEventName, listener] of Object.entries(events)) { @@ -78,7 +89,6 @@ const onMount = ( const embed = FormsortWebEmbed(containerElement, embedConfig); attachEventListenersToEmbed(embed, eventListeners); - if (responderUuid) { queryParams.push(['responderUuid', responderUuid]); } @@ -96,6 +106,36 @@ const onMount = ( return embed; }; +const onSecureMount = ( + containerRef: React.RefObject, + props: SecureEmbedFlowProps +): IFormsortSecureWebEmbed | undefined => { + const containerElement = containerRef.current; + if (!containerElement) { + return; + } + + const { + clientLabel, + flowLabel, + variantLabel, + embedConfig, + responderUuid, + initialAnswers, + ...eventListeners + } = props; + const embed = FormsortSecureWebEmbed(containerElement, embedConfig); + attachEventListenersToEmbed(embed, eventListeners); + embed.loadFlow( + clientLabel, + flowLabel, + variantLabel, + responderUuid, + initialAnswers + ); + return embed; +}; + const EmbedFlow: React.FunctionComponent = (props) => { const containerRef = useRef(null); const style = props.embedConfig?.style; @@ -120,4 +160,30 @@ const EmbedFlow: React.FunctionComponent = (props) => { return
; }; +export const SecureEmbedFlow: React.FunctionComponent = ( + props +) => { + const containerRef = useRef(null); + const style = props.embedConfig?.style; + const [flowClosed, setFlowClosed] = useState(false); + + useEffect(() => { + const embed = onSecureMount(containerRef, props); + + embed?.addEventListener(SupportedAnalyticsEvent.FlowClosed, () => { + setFlowClosed(true); + }); + + return () => { + embed?.unloadFlow(); + }; + }, []); + + if (flowClosed) { + return null; + } + + return
; +}; + export default EmbedFlow; diff --git a/packages/web-embed-api/README.md b/packages/web-embed-api/README.md index a47e7db..ea604c3 100644 --- a/packages/web-embed-api/README.md +++ b/packages/web-embed-api/README.md @@ -28,6 +28,45 @@ Initializes a Formsort iframe as a child of the `rootEl` provided. const embed = FormsortWebEmbed(document.body); ``` +Use `FormsortSecureWebEmbed` to pass responder data in a POST body instead of +the URL. Calling `loadFlow` submits the POST navigation to the iframe. + +```ts +import { FormsortSecureWebEmbed } from '@formsort/web-embed-api'; + +const embed = FormsortSecureWebEmbed(document.body); + +embed.loadFlow( + 'formsort', + 'onboarding' + // 'main', // [optional] variantLabel + // 'e4923baa-dc2d-4555-813c-a166952292fa', // [optional] responderUuid + // { firstName: 'Olivia' } // [optional] initialAnswers +); +``` + +The POST data can contain scalar values, arrays, nested objects, and arrays of +objects. The secure embed uses the same configuration, methods, and events as +`FormsortWebEmbed`. + +### `FormsortSecureWebEmbed(rootEl: HTMLElement, config?: IFormsortWebEmbedConfig)` + +Initializes a secure Formsort iframe. It has the same configuration, methods, +and events as `FormsortWebEmbed`. + +Its `loadFlow` method accepts optional `responderUuid` and `initialAnswers` +values. It does not accept query parameters: + +```ts +loadFlow( + clientLabel: string, + flowLabel: string, + variantLabel?: string, + responderUuid?: string, + initialAnswers?: FormsortInitialAnswers +) => void; +``` + The optional `config` object has the following interface: ```ts diff --git a/packages/web-embed-api/src/index.test.ts b/packages/web-embed-api/src/index.test.ts index 48a4fae..e0b9156 100644 --- a/packages/web-embed-api/src/index.test.ts +++ b/packages/web-embed-api/src/index.test.ts @@ -1,6 +1,9 @@ import { AnalyticsEventType, WebEmbedMessage } from '@formsort/constants'; -import FormsortWebEmbed, { SupportedAnalyticsEvent } from '.'; +import FormsortWebEmbed, { + FormsortSecureWebEmbed, + SupportedAnalyticsEvent, +} from '.'; type MessageListener = (msg: MessageEvent) => any; @@ -844,3 +847,88 @@ describe('FormsortWebEmbed', () => { expect(pushStateSpy).toBeCalledTimes(0); }); }); + +describe('FormsortSecureWebEmbed', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + test('loads a flow by posting data to the iframe', () => { + const submitSpy = jest + .spyOn(HTMLFormElement.prototype, 'submit') + .mockImplementation(jest.fn()); + const embed = FormsortSecureWebEmbed(document.body); + + embed.loadFlow(clientLabel, flowLabel, variantLabel, 'responder-uuid', { + firstName: 'Olivia', + }); + + const iframe = document.body.querySelector('iframe')!; + const form = document.body.querySelector('form')!; + expect(iframe.src).toBe(''); + expect(iframe.name).not.toBe(''); + expect(form.method).toBe('post'); + expect(form.hidden).toBe(true); + expect(form.action).toBe( + `https://testclient.formsort.app/flow/${flowLabel}/variant/${variantLabel}` + ); + expect(form.target).toBe(iframe.name); + expect(form.querySelector('[name="responderUuid"]')).toHaveProperty( + 'value', + 'responder-uuid' + ); + expect(form.querySelector('[name="firstName"]')).toHaveProperty( + 'value', + 'Olivia' + ); + expect(submitSpy).toHaveBeenCalledTimes(1); + submitSpy.mockRestore(); + }); + + test('posts arrays and nested fields with bracket notation', () => { + const submitSpy = jest + .spyOn(HTMLFormElement.prototype, 'submit') + .mockImplementation(jest.fn()); + const embed = FormsortSecureWebEmbed(document.body); + + embed.loadFlow(clientLabel, flowLabel, undefined, undefined, { + colors: ['gray', 'brown'], + address: { city: 'New York' }, + questionGroup: [{ groupText: 'first input' }], + }); + + const form = document.body.querySelector('form')!; + const colors = form.querySelector('[name="colors"]')!; + expect(colors.multiple).toBe(true); + expect(Array.from(colors.selectedOptions, ({ value }) => value)).toEqual([ + 'gray', + 'brown', + ]); + expect(form.querySelector('[name="address[city]"]')).toHaveProperty( + 'value', + 'New York' + ); + expect( + form.querySelector('[name="questionGroup[0][groupText]"]') + ).toHaveProperty('value', 'first input'); + expect(submitSpy).toHaveBeenCalledTimes(1); + submitSpy.mockRestore(); + }); + + test('posts without a query string when secure parameters are omitted', () => { + const submitSpy = jest + .spyOn(HTMLFormElement.prototype, 'submit') + .mockImplementation(jest.fn()); + const embed = FormsortSecureWebEmbed(document.body); + + embed.loadFlow(clientLabel, flowLabel); + + const form = document.body.querySelector('form')!; + expect(form.action).toBe( + `https://testclient.formsort.app/flow/${flowLabel}` + ); + expect(form.querySelectorAll('input, select')).toHaveLength(0); + expect(submitSpy).toHaveBeenCalledTimes(1); + submitSpy.mockRestore(); + }); +}); diff --git a/packages/web-embed-api/src/index.ts b/packages/web-embed-api/src/index.ts index 284f7cb..e5cb1d0 100644 --- a/packages/web-embed-api/src/index.ts +++ b/packages/web-embed-api/src/index.ts @@ -7,13 +7,7 @@ import EmbedMessagingManager, { import { getMessageSender } from './iframe-utils'; import { isLocalOrLegacyFlowOrigin } from './utils'; -interface IFormsortWebEmbed { - loadFlow: ( - clientLabel: string, - flowLabel: string, - variantLabel?: string, - queryParams?: Array<[string, string]> - ) => void; +interface IFormsortEmbedControls { unloadFlow: () => void; setSize: (width: string, height: string) => void; addEventListener( @@ -26,6 +20,25 @@ interface IFormsortWebEmbed { ): void; } +interface IFormsortWebEmbed extends IFormsortEmbedControls { + loadFlow: ( + clientLabel: string, + flowLabel: string, + variantLabel?: string, + queryParams?: Array<[string, string]> + ) => void; +} + +interface IFormsortSecureWebEmbed extends IFormsortEmbedControls { + loadFlow: ( + clientLabel: string, + flowLabel: string, + variantLabel?: string, + responderUuid?: string, + initialAnswers?: FormsortInitialAnswers + ) => void; +} + interface IFormsortWebEmbedConfig extends IFormsortEmbedConfig { useHistoryAPI?: boolean; /** @@ -39,19 +52,78 @@ interface IFormsortWebEmbedConfig extends IFormsortEmbedConfig { iframeAllow?: string; } +type FormsortInitialAnswerValue = + | string + | number + | boolean + | null + | undefined + | FormsortInitialAnswerValue[] + | { [key: string]: FormsortInitialAnswerValue }; + +type FormsortInitialAnswers = Record; + const DEFAULT_CONFIG: IFormsortWebEmbedConfig = { useHistoryAPI: false, }; const DEFAULT_ALLOW = 'camera;'; -const FormsortWebEmbed = ( +let secureIframeCount = 0; + +const addPostData = ( + formEl: HTMLFormElement, + name: string, + value: FormsortInitialAnswerValue +) => { + if (value === undefined) { + return; + } + + if (Array.isArray(value)) { + if (value.every((item) => typeof item !== 'object' || item === null)) { + const selectEl = document.createElement('select'); + selectEl.name = name; + selectEl.multiple = true; + value.forEach((item) => { + const optionEl = document.createElement('option'); + optionEl.value = item === null ? '' : String(item); + optionEl.selected = true; + selectEl.appendChild(optionEl); + }); + formEl.appendChild(selectEl); + return; + } + + value.forEach((item, index) => { + addPostData(formEl, `${name}[${index}]`, item); + }); + return; + } + + if (typeof value === 'object' && value !== null) { + Object.entries(value).forEach(([key, item]) => { + addPostData(formEl, `${name}[${key}]`, item); + }); + return; + } + + const inputEl = document.createElement('input'); + inputEl.type = 'hidden'; + inputEl.name = name; + inputEl.value = value === null ? '' : String(value); + formEl.appendChild(inputEl); +}; + +const createFormsortWebEmbed = ( rootEl: HTMLElement, - config: IFormsortWebEmbedConfig = DEFAULT_CONFIG -): IFormsortWebEmbed => { + config: IFormsortWebEmbedConfig, + secure = false +) => { const iframeEl = document.createElement('iframe'); const { style, iframeAllow = DEFAULT_ALLOW, iframeTitle } = config; let loadedOrigin: string; + let formEl: HTMLFormElement | undefined; iframeEl.style.border = 'none'; iframeEl.allow = iframeAllow || DEFAULT_ALLOW; if (style) { @@ -64,6 +136,11 @@ const FormsortWebEmbed = ( iframeEl.title = iframeTitle; } + const frameName = `formsort-secure-embed-form-${secureIframeCount++}`; + iframeEl.name = frameName; + + // frame name should be set before appending to the DOM + // otherwise POSTs targeting the iframe can open a new tab instead of the iframe rootEl.appendChild(iframeEl); const setSize = (width?: string | number, height?: string | number) => { @@ -77,6 +154,7 @@ const FormsortWebEmbed = ( const unloadFlow = () => { removeListeners(); + formEl?.remove(); try { rootEl.removeChild(iframeEl); } catch { @@ -138,7 +216,8 @@ const FormsortWebEmbed = ( clientLabel: string, flowLabel: string, variantLabel?: string, - queryParams?: Array<[string, string]> + queryParamsOrResponderUuid?: Array<[string, string]> | string, + initialAnswers?: FormsortInitialAnswers ) => { let urlBase: string; if (config.origin) { @@ -161,15 +240,36 @@ const FormsortWebEmbed = ( if (variantLabel) { url += `/variant/${variantLabel}`; } - if (queryParams) { - url += `?${queryParams - .map( - ([key, value]) => - `${encodeURIComponent(key)}=${encodeURIComponent(value)}` - ) - .join('&')}`; + + if (!secure) { + if (Array.isArray(queryParamsOrResponderUuid)) { + url += `?${queryParamsOrResponderUuid + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value)}` + ) + .join('&')}`; + } + + iframeEl.src = url; + return; + } + + formEl?.remove(); + const nextFormEl = document.createElement('form'); + formEl = nextFormEl; + nextFormEl.method = 'POST'; + nextFormEl.hidden = true; + nextFormEl.action = url; + nextFormEl.target = frameName; + Object.entries(initialAnswers ?? {}).forEach(([key, value]) => { + addPostData(nextFormEl, key, value); + }); + if (typeof queryParamsOrResponderUuid === 'string') { + addPostData(nextFormEl, 'responderUuid', queryParamsOrResponderUuid); } - iframeEl.src = url; + rootEl.appendChild(nextFormEl); + nextFormEl.submit(); }; return { @@ -181,7 +281,25 @@ const FormsortWebEmbed = ( }; }; -export { IFormsortWebEmbed, IFormsortWebEmbedConfig, IEventMap }; +const FormsortWebEmbed = ( + rootEl: HTMLElement, + config: IFormsortWebEmbedConfig = DEFAULT_CONFIG +): IFormsortWebEmbed => createFormsortWebEmbed(rootEl, config); + +const FormsortSecureWebEmbed = ( + rootEl: HTMLElement, + config: IFormsortWebEmbedConfig = DEFAULT_CONFIG +): IFormsortSecureWebEmbed => createFormsortWebEmbed(rootEl, config, true); + +export { + FormsortInitialAnswers, + FormsortInitialAnswerValue, + FormsortSecureWebEmbed, + IFormsortSecureWebEmbed, + IFormsortWebEmbed, + IFormsortWebEmbedConfig, + IEventMap, +}; export { SupportedAnalyticsEvent } from '@formsort/embed-messaging-manager';