diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 777e180c..51690697 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -3,12 +3,16 @@ on: schedule: - cron: "30 16 * * *" +permissions: + issues: write + pull-requests: write + jobs: stale: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/stale@v7 + - uses: actions/stale@v9 with: stale-issue-message: "This issue is stale because it has been open 14 days with no activity. Remove stale label or comment or this will be closed in 7 days." stale-pr-message: "This PR is stale because it has been open 14 days with no activity. Remove stale label or comment or this will be closed in 7 days." diff --git a/packages/components/document-capture/document-instructions/src/DocumentInstruction.js b/packages/components/document-capture/document-instructions/src/DocumentInstruction.js new file mode 100644 index 00000000..3df99f1a --- /dev/null +++ b/packages/components/document-capture/document-instructions/src/DocumentInstruction.js @@ -0,0 +1,404 @@ +"use strict"; +import SmartFileUpload from "../../../domain/SmartFileUpload"; +import styles from "../../../styles"; + +function templateString() { + return ` + ${styles} +
+
+ ${this.showNavigation + ? ` + ` + : "" + } +
+ + + + + + + + + + + + + + + +

${this.title}

+

+ We'll use it to verify your identity. +

+

+ Please follow the instructions below. +

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+

Good Light

+

+ Make sure you are in a well-lit environment where your face is + clear and visible +

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+

Clear Image

+

+ Hold your phone steady so the selfie is clear and sharp. Don't + take blurry images. +

+
+
+
+
+
+
+
+
+ ${this.supportBothCaptureModes || this.documentCaptureModes === "camera" + ? ` + + ` + : "" + } + ${this.supportBothCaptureModes || this.documentCaptureModes === "upload" + ? ` + + ` + : "" + } +
+${this.hideAttribution + ? "" + : ` + +` + } +
+
+ `; +} + +class DocumentInstruction extends HTMLElement { + constructor() { + super(); + this.templateString = templateString.bind(this); + this.render = () => { + return this.templateString(); + }; + + this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + const template = document.createElement("template"); + template.innerHTML = this.render(); + + this.shadowRoot.appendChild(template.content.cloneNode(true)); + + this.backButton = this.shadowRoot.querySelector("#back-button"); + this.takeDocumentPhotoButton = this.shadowRoot.querySelector('#take-photo'); + this.uploadDocumentPhotoButton = this.shadowRoot.querySelector('#upload-photo'); + + const CloseIframeButtons = this.shadowRoot.querySelectorAll(".close-iframe"); + + this.backButton && this.backButton.addEventListener("click", (e) => { + this.handleBackEvents(e); + }); + + CloseIframeButtons.forEach((button) => { + button.addEventListener( + "click", + () => { + this.closeWindow(); + }, + false + ); + }); + + if (this.takeDocumentPhotoButton) this.takeDocumentPhotoButton.addEventListener('click', () => { + this.dispatchEvent( + new CustomEvent("DocumentInstruction::StartCamera", { + detail: {}, + }), + ); + }); + + if (this.uploadDocumentPhotoButton) this.uploadDocumentPhotoButton.addEventListener('change', async (event) => { + this.shadowRoot.querySelector('#error').innerHTML = ''; + try { + const { files } = event.target; + + // validate file, and convert file to data url + const fileData = await SmartFileUpload.retrieve(files); + + this.dispatchEvent( + new CustomEvent("DocumentInstruction::DocumentChange", { + detail: { image: fileData }, + }), + ); + } catch (error) { + this.shadowRoot.querySelector('#error').innerHTML = error.message; + } + }); + } + + get hideBack() { + return this.hasAttribute("hide-back-to-host"); + } + + get showNavigation() { + return this.hasAttribute('show-navigation'); + } + + get themeColor() { + return this.getAttribute("theme-color") || "#043C93"; + } + + get hideAttribution() { + return this.hasAttribute("hide-attribution"); + } + + get documentCaptureModes() { + return this.getAttribute("document-capture-modes") || "camera"; + } + + get supportBothCaptureModes() { + const value = this.documentCaptureModes; + return value.includes("camera") && value.includes("upload"); + } + get title() { + return this.getAttribute('title') || 'Submit Front of ID'; + } + + handleBackEvents() { + this.dispatchEvent(new CustomEvent("SmileIdentity::Exit")); + } + + closeWindow() { + const referenceWindow = window.parent; + referenceWindow.postMessage("SmileIdentity::Close", "*"); + } +} + +if ("customElements" in window && !customElements.get("document-instruction")) { + window.customElements.define("document-instruction", DocumentInstruction); +} + +export { DocumentInstruction }; diff --git a/packages/components/document-capture/document-instructions/src/DocumentInstruction.stories.js b/packages/components/document-capture/document-instructions/src/DocumentInstruction.stories.js new file mode 100644 index 00000000..fb2f0c86 --- /dev/null +++ b/packages/components/document-capture/document-instructions/src/DocumentInstruction.stories.js @@ -0,0 +1,17 @@ +import "./index"; + +const meta = { + component: "document-instruction", +}; + +export default meta; + +export const DocumentInstruction = { + render: () => ` + + + `, +} \ No newline at end of file diff --git a/packages/components/document-capture/document-instructions/src/index.js b/packages/components/document-capture/document-instructions/src/index.js new file mode 100644 index 00000000..f539c7a5 --- /dev/null +++ b/packages/components/document-capture/document-instructions/src/index.js @@ -0,0 +1,3 @@ +export { + DocumentInstruction +} from './DocumentInstruction'; diff --git a/packages/components/domain/Constants.js b/packages/components/domain/Constants.js new file mode 100644 index 00000000..14233f3f --- /dev/null +++ b/packages/components/domain/Constants.js @@ -0,0 +1,23 @@ +/** + * The type of image submitted in the job request + * @readonly + * @enum {number} + */ +export const IMAGE_TYPE = { + /** SELFIE_IMAGE_FILE Selfie image in .png or .jpg file format */ + SELFIE_IMAGE_FILE: 0, + /** ID_CARD_IMAGE_FILE ID card image in .png or .jpg file format */ + ID_CARD_IMAGE_FILE: 1, + /** SELFIE_IMAGE_BASE64 Base64 encoded selfie image (.png or .jpg) */ + SELFIE_IMAGE_BASE64: 2, + /** ID_CARD_IMAGE_BASE64 Base64 encoded ID card image (.png or .jpg) */ + ID_CARD_IMAGE_BASE64: 3, + /** LIVENESS_IMAGE_FILE Liveness image in .png or .jpg file format */ + LIVENESS_IMAGE_FILE: 4, + /** ID_CARD_BACK_IMAGE_FILE Back of ID card image in .png or .jpg file format */ + ID_CARD_BACK_IMAGE_FILE: 5, + /** LIVENESS_IMAGE_BASE64 Base64 encoded liveness image (.jpg or .png) */ + LIVENESS_IMAGE_BASE64: 6, + /** ID_CARD_BACK_IMAGE_BASE64 Base64 encoded back of ID card image (.jpg or .png) */ + ID_CARD_BACK_IMAGE_BASE64: 7, +}; \ No newline at end of file diff --git a/packages/components/domain/SmartFileUpload.js b/packages/components/domain/SmartFileUpload.js new file mode 100644 index 00000000..bda0e9cf --- /dev/null +++ b/packages/components/domain/SmartFileUpload.js @@ -0,0 +1,66 @@ +class SmartFileUpload { + static memoryLimit = 10240000; + + static supportedTypes = ['image/jpeg', 'image/png']; + + static getHumanSize(numberOfBytes) { + // Approximate to the closest prefixed unit + const units = [ + 'B', + 'kB', + 'MB', + 'GB', + 'TB', + 'PB', + 'EB', + 'ZB', + 'YB', + ]; + const exponent = Math.min( + Math.floor(Math.log(numberOfBytes) / Math.log(1024)), + units.length - 1, + ); + const approx = numberOfBytes / 1024 ** exponent; + const output = exponent === 0 + ? `${numberOfBytes} bytes` + : `${approx.toFixed(0)} ${units[exponent]}`; + + return output; + } + + static getData(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + resolve(e.target.result); + }; + reader.onerror = () => { + reject(new Error('An error occurred reading the file. Please check the file, and try again')); + }; + reader.readAsDataURL(file); + }); + } + + static async retrieve(files) { + if (files.length > 1) { + throw new Error('Only one file upload is permitted at a time'); + } + + const file = files[0]; + + if (!SmartFileUpload.supportedTypes.includes(file.type)) { + throw new Error('Unsupported file format. Please ensure that you are providing a JPG or PNG image'); + } + + if (file.size > SmartFileUpload.memoryLimit) { + throw new Error(`${file.name} is too large. Please ensure that the file is less than ${SmartFileUpload.getHumanSize(SmartFileUpload.memoryLimit)}.`); + } + + const imageAsDataUrl = await SmartFileUpload.getData(file); + + return imageAsDataUrl; + } +} + +export default SmartFileUpload; diff --git a/packages/components/domain/camera/SmartCamera.js b/packages/components/domain/camera/SmartCamera.js new file mode 100644 index 00000000..01828dd9 --- /dev/null +++ b/packages/components/domain/camera/SmartCamera.js @@ -0,0 +1,76 @@ +class SmartCamera { + static stream = null; + + static async getMedia(constraints) { + try { + SmartCamera.stream = await navigator.mediaDevices.getUserMedia({ + ...constraints, + video: { + ...constraints.video, + // NOTE: Special case for multi-camera Samsung devices (learnt from Acuant) + // "We found out that some triple camera Samsung devices (S10, S20, Note 20, etc) capture images blurry at edges. + // Zooming to 2X, matching the telephoto lens, doesn't solve it completely but mitigates it." + zoom: SmartCamera.isSamsungMultiCameraDevice() ? 2.0 : 1.0, + } + }); + return SmartCamera.stream; + } catch (error) { + throw error; + } + } + + static stopMedia() { + if (SmartCamera.stream) { + SmartCamera.stream.getTracks().forEach(track => track.stop()); + SmartCamera.stream = null; + } + } + + static isSamsungMultiCameraDevice() { + const matchedModelNumber = navigator.userAgent.match(/SM-[N|G]\d{3}/); + if (!matchedModelNumber) { + return false; + } + + const modelNumber = parseInt(matchedModelNumber[0].match(/\d{3}/)[0], 10); + const smallerModelNumber = 970; // S10e + return !isNaN(modelNumber) && modelNumber >= smallerModelNumber; + } + + static handleCameraError(e) { + switch (e.name) { + case 'NotAllowedError': + case 'SecurityError': + return ` + Looks like camera access was not granted, or was blocked by a browser + level setting / extension. Please follow the prompt from the URL bar, + or extensions, and enable access. + You may need to refresh to start all over again + `; + case 'AbortError': + return ` + Oops! Something happened, and we lost access to your stream. + Please refresh to start all over again + `; + case 'NotReadableError': + return ` + There seems to be a problem with your device's camera, or its connection. + Please check this, and when resolved, try again. Or try another device. + `; + case 'NotFoundError': + return ` + We are unable to find a video stream. + You may need to refresh to start all over again + `; + case 'TypeError': + return ` + This site is insecure, and as such cannot have access to your camera. + Try to navigate to a secure version of this page, or contact the owner. + `; + default: + return e.message; + } + } +} + +export { SmartCamera }; \ No newline at end of file diff --git a/packages/components/styles.js b/packages/components/styles.js new file mode 100644 index 00000000..3e8c2e2f --- /dev/null +++ b/packages/components/styles.js @@ -0,0 +1,353 @@ +import typography from "./typography"; +const styles = ` + +` + +export default styles; \ No newline at end of file diff --git a/packages/components/typography.js b/packages/components/typography.js new file mode 100644 index 00000000..90569318 --- /dev/null +++ b/packages/components/typography.js @@ -0,0 +1,52 @@ +const typography = ` + .text-xs { + font-size: 0.75rem; + line-height: 1rem; + } + .text-sm { + font-size: 0.875rem; + line-height: 1.125rem; + } + .text-base { + font-size: 1rem; + line-height: 1rem; + } + .text-lg { + font-size: 1.125rem; + line-height: 1.75rem; + } + .text-xl { + font-size: 1.25rem; + line-height: 1.75rem; + } + .text-2xl { + font-size: 1.5rem; + line-height: 2rem; + } + .text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; + } + .text-4xl { + font-size: 2rem; + line-height: 2.5rem; + } + .text-5xl { + font-size: 2.25rem; + line-height: 2.5rem; + } + .font-bold { + font-weight: 700; + } + .font-semibold { + font-weight: 600; + } + .font-medium { + font-weight: 500; + } + .font-normal { + font-weight: 400; + } +` + +export default typography;