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';