import EventEmitter from "eventemitter3";
import isPlainObject from 'lodash.isplainobject';
import pick from 'lodash.pick';
import qs from 'qs';
export const PUBLIC_API_KEY_PARAM = 'publicAPIKey';
export const IFRAME_CLASSNAME = 'blueink-sig-iframe';
/**
* Event types that can be emitted by BlueInkEmbed.
* These can be accessed via the BlueInkEmbed class, e.g. BlueInkEmbed.EVENT.COMPLETE, etc.
* @readonly
* @enum {string}
*
* @example
* embed = new BlueInkEmbed(myPublicAPIKey);
*
* embed.on(BlueInkEmbed.EVENT.COMPLETE, () => {
* console.log('signing complete!');
* });
*
* @example
* embed.on(BlueInkEmbed.EVENT.ERROR, (eventData) => {
* console.log('Signing error occurred.');
* console.log(eventData.message);
* });
*/
const EVENT = {
/** Any event occurred. Use this if you want to listen for all events with a single callback. */
ANY: 'any',
/** The initial load of the iFrame content is complete. Note, the documents may not yet be ready to sign. */
LOAD: 'load',
/** The documents are ready to sign. */
READY: 'ready',
/** The signing is complete. */
COMPLETE: 'complete',
/** Signer authentication failed. */
AUTH_FAIL: 'auth_fail',
/** Signer authentication succeeded. Note, this event is still fired if no authentication options were selected. */
AUTH_SUCCESS: 'auth_success',
/** An error that prevents embedded signing from continuing. */
ERROR: 'error',
};
export { EVENT };
const MIN_PUBLIC_API_KEY_LENGTH = 40;
const PUBLIC_API_KEY_PREFIX = 'public_';
const ALLOWED_MOUNT_OPTIONS = [
'class',
'debug',
'isTest',
'locale',
'redirectURL',
'replace',
];
/**
* Custom Error class thrown for some errors in BlueInkEmbed
*/
export class BlueInkEmbedError extends Error {
/**
* Create a BlueInkEmbedError
* @param {string} message - a message providing additional details about the error
*/
constructor(message) {
super(message);
this.name = 'BlueInkEmbedError';
}
}
/**
* Class that helps create and manage embedded signing iFrames powered by BlueInk.
* @extends EventEmitter
*/
class BlueInkEmbed extends EventEmitter {
static _mounted = false;
_containerEl = null;
_debugMode = false;
_iFrameEl = null;
_iFrameOrigin = null;
_publicAPIKey = null;
static EVENT = EVENT;
static Error = BlueInkEmbedError;
/**
* Create a new BlueInkEmbed instance, which you can use to mount and unmount embedded signing iFrames.
* @param {string} publicAPIKey - the public API key for your BlueInk API App. This can be obtained
* by logging into your BlueInk Dashboard, or via the BlueInk API at /account/
*/
constructor(publicAPIKey) {
super();
if (!publicAPIKey) {
throw new TypeError('publicAPIKey must be provided.')
}
if (publicAPIKey.length < MIN_PUBLIC_API_KEY_LENGTH) {
throw new TypeError(
'publicAPIKey is too short. Please verify you are using a valid BlueInk public API key.'
);
}
if (!publicAPIKey.startsWith(PUBLIC_API_KEY_PREFIX)) {
throw new TypeError(
'publicAPIKey is invalid. Please verify you are using a valid BlueInk public API key.'
)
}
this._publicAPIKey = publicAPIKey;
}
// Document the methods from the EventEmitter
/**
*
* @param {string} container - the css selector of the container into which the embedded signing iFrame
* will be injected
* @param {string} embeddedSigningURL - the embedded signing URL, as returned by an API call to
* /packets/<packet_id>/embed_url/
* @param {object} options - optional arguments
* @param {boolean} options.debug - if true, additional information will be printed to the console
* and displayed in the iFrame (if there is an error)
* @param {boolean} options.isTest - set to true when testing in a development environment (or locally), to
* turn off verification of the referring domain. This is only valid for embedded signing of test Bundles
* (which were created with is_test set to true)
* @param {string} options.locale - set the initial language / locale for the embedded signature iFrame
* @param {string} options.redirectURL - If provided, the parent page (not the iFrame) will be redirected
* to this URL upon successful completion of a signing
* @param {string} options.replace - If true, the signing iFrame replaces the contents of container, instead
* of being appended to the contents
* @throws {BlueInkEmbedError} when (1) there is already a mounted BlueInkEmbed iFrame instance,
* (2) the container element does not exist, or (3) the container selector corresponds to more than one element.
*/
mount(embeddedSigningURL, container = 'body', options = {}) {
if (BlueInkEmbed._mounted) {
throw new BlueInkEmbedError('Cannot mount multiple iFrames at once')
}
let cleanOptions;
// allow container to be omitted
if (isPlainObject(container)) {
cleanOptions = pick(container, ALLOWED_MOUNT_OPTIONS);
container = 'body';
} else {
cleanOptions = pick(options, ALLOWED_MOUNT_OPTIONS);
}
const containerNodes = document.querySelectorAll(container);
if (containerNodes.length > 1) {
throw new BlueInkEmbedError(`More than one element found matching container selector "${container}"`);
}
if (containerNodes.length === 0) {
throw new BlueInkEmbedError(`Cannot find element matching container selector "${container}"`);
}
this._debugMode = Boolean(cleanOptions.debug);
this._containerEl = containerNodes[0];
this._iFrameOrigin = this._extractOrigin(embeddedSigningURL);
const processedURL = this._buildFinalURL(embeddedSigningURL, cleanOptions);
const className = cleanOptions.className || IFRAME_CLASSNAME;
this._iFrameEl = this._createIframe(processedURL, className);
if (options.replace) {
this._containerEl.innerHTML = '';
}
this._containerEl.appendChild(this._iFrameEl);
this.registerMessageListener();
BlueInkEmbed._mounted = true;
// FIXME - sanity checks on size of container element, after rendering
}
/**
* Remove the BlueInk eSignature iFrame. If there is no iFrame (ie, mount(...) was not
* called previously), this is a noop.
*/
unmount() {
if (this._iFrameEl) {
this._iFrameEl.parentNode.removeChild(this._iFrameEl);
this._iFrameEl = null;
this._iFrameOrigin = null;
this._containerEl = null;
this._debugMode = false;
this.removeMessageListener()
}
BlueInkEmbed._mounted = false;
}
registerMessageListener() {
window.addEventListener('message', this._receiveMessage, false);
}
removeMessageListener() {
window.removeEventListener('message', this._receiveMessage, false);
}
/**
*
* @returns {Element|null} The DOM Element, or null if no embedded signing iFrame currently exists.
*/
get iFrameEl() {
return this._iFrameEl;
}
_debug(...args) {
if (this._debugMode) {
console.debug(...args);
}
}
/**
* Receive a message from the embedded iFrame and emit an event
* @param {object} event - an event as dispatched from window.postMessage()
* @private
*/
_receiveMessage = (event) => {
if (event.origin !== this._iFrameOrigin) {
return;
}
if (this._iFrameEl && (event.source !== this._iFrameEl.contentWindow)) {
this._debug('Received message with correct origin but wrong source. Silently dropping.', event.data);
return;
}
const eventType = event.data && event.data.eventType;
if (!eventType) {
this._debug('Received message with no eventType. Silently dropping.');
return;
}
if (!Object.values(EVENT).includes(eventType)) {
this._debug(`Received message with unknown eventType "${eventType}". Silently dropping.`);
return;
}
this.emit(eventType, event.data);
this.emit(EVENT.ANY, eventType, event.data);
};
/**
* Create a URL suitable to be used as the embedded iFrame src, by querystring parameters
* @param {string} embeddedSigningURL - the embedded signing URL, as returned by a BlueInk
* API v2 call to /packet/<packet_id>/embed_url/
* @param {object} options - options, as passed into mount()
* @returns {string} a URL with querystring parameters which can be used as the src of the embedded iFrame
* @private
*/
_buildFinalURL(embeddedSigningURL, options) {
const optionsWithKey = {
[PUBLIC_API_KEY_PARAM]: this._publicAPIKey,
...options,
};
return `${embeddedSigningURL}?${qs.stringify(optionsWithKey)}`;
}
/**
* Create an iFrame Element
* @param {string} url - the url to be used as the src attribute of the iFrame
* @param {string} className - a className (or space separated list of classNames) to be used as the className
* attribute of the iFrame. Defaults to IFRAME_CLASSNAME if not provided.
* @returns {HTMLIFrameElement} the (unattached) iFrame element
* @private
*/
_createIframe(url, className = IFRAME_CLASSNAME) {
const iFrame = document.createElement('iframe');
const origin = this._extractOrigin(url);
iFrame.className = className;
iFrame.src = url;
iFrame.allow = `camera ${origin}; geolocation ${origin}`;
return iFrame;
}
/**
* Extract a URL origin, suitable for filtering incoming messages sent by window.postMessage
* to those sent from our embedded iFrame.
* @param embeddedSigningURL - the original embedded signing URL
* @returns {string} The origin part of the URL (consisting of protocol, host name and port,
* which might be implicit)
* @private
*/
_extractOrigin(embeddedSigningURL) {
const url = new URL(embeddedSigningURL);
return url.origin;
}
/**
* Register a listener for events that occur in the embedded iFrame
* @method on
* @memberof BlueInkEmbed
* @param {string} eventType - One of the EVENT constants, e.g. EVENT.COMPLETE, EVENT.READY, etc.
* @param {BlueInkEmbed~eventCallback} callback - The callback function that will be invoked when the event occurs.
*/
/**
* Remove a previously registered event listener
* @method off
* @memberof BlueInkEmbed
* @param {string} eventType - One of the EVENT constants, e.g. EVENT.COMPLETE, EVENT.READY, etc.
* @param {BlueInkEmbed~eventCallback} callback - The callback function to remove
*/
/**
* Register a listener for a single occurrence of an event
* @method once
* @memberof BlueInkEmbed
* @param {string} eventType - One of the EVENT constants, e.g. EVENT.COMPLETE, EVENT.READY, etc.
* @param {BlueInkEmbed~eventCallback} callback - The callback function that will be invokeds
*/
}
/**
* An event payload for an error event
* @typedef {Object} EventErrorData
* @property {string} message - A description of the error that occurred.
*/
/**
* An event payload for a signing event
* @typedef {Object} EventAnyData
* @property {string} eventType - The type of the event. One of EVENT.COMPLETE, EVENT.READY, etc.
* @property {EventErrorData|null} eventData - Additional data for the event, or {}.
*/
/**
* A callback function that will be invoked with event data.
* @callback BlueInkEmbed~eventCallback
* @param {EventAnyData|EventErrorData|null} eventData - depending on the event type
*/
export default BlueInkEmbed;