UNPKG

onfido-sdk-ui

Version:

JavaScript SDK view layer for Onfido identity verification

323 lines (282 loc) 11 kB
import { h, Component } from 'preact' import { connect } from 'react-redux' import theme from '../Theme/style.css' import style from './style.css' import classNames from 'classnames' import { isOfFileType } from '../utils/file' import { includes } from '../utils/array' import {preventDefaultOnClick} from '../utils' import { cleanFalsy } from '../utils/array' import { uploadDocument, uploadLivePhoto, uploadLiveVideo } from '../utils/onfidoApi' import { poaDocumentTypes } from '../DocumentSelector/documentTypes' import PdfViewer from './PdfPreview' import EnlargedPreview from '../EnlargedPreview' import Error from '../Error' import Spinner from '../Spinner' import Title from '../Title' import { trackException, trackComponentAndMode, appendToTracking, sendEvent } from '../../Tracker' import { localised } from '../../locales' const imageSrc = (blob, base64, previewUrl) => blob instanceof File ? base64 : previewUrl const CaptureViewerPure = ({capture:{blob, base64, previewUrl, variant, id}, isDocument, isFullScreen}) => <div className={style.captures}> {isOfFileType(['pdf'], blob) ? <PdfViewer previewUrl={previewUrl} blob={blob}/> : variant === 'video' ? <video className={style.video} src={previewUrl} controls/> : <span className={classNames(style.imageWrapper, { [style.fullscreenImageWrapper]: isFullScreen, })}> { isDocument && <EnlargedPreview src={imageSrc(blob, base64, previewUrl)}/> } <img key={id}//WORKAROUND necessary to prevent img recycling, see bug: https://github.com/developit/preact/issues/351 className={style.image} //we use base64 if the capture is a File, since its base64 version is exif rotated //if it's not a File (just a Blob), it means it comes from the webcam, //so the base64 version is actually lossy and since no rotation is necessary //the blob is the best candidate in this case src={imageSrc(blob, base64, previewUrl)} /> </span> } </div> class CaptureViewer extends Component { constructor (props) { super(props) const {capture:{blob}} = props this.state = this.previewUrlState(blob) } previewUrlState = blob => blob ? { previewUrl: URL.createObjectURL(blob) } : {} updateBlobPreview(blob) { this.revokePreviewURL() this.setState(this.previewUrlState(blob)) } revokePreviewURL(){ URL.revokeObjectURL(this.state.previewUrl) } componentWillReceiveProps({capture:{blob}}) { if (this.props.capture.blob !== blob) this.updateBlobPreview(blob) } componentWillUnmount() { this.revokePreviewURL() } render () { const {capture, method, isFullScreen} = this.props return <CaptureViewerPure isFullScreen={isFullScreen} isDocument={ method === 'document' } capture={{ ...capture, previewUrl: this.state.previewUrl }}/> } } const RetakeAction = localised(({retakeAction, translate}) => <button onClick={retakeAction} className={`${theme.btn} ${theme['btn-outline']} ${style.retake}`}> {translate('confirm.redo')} </button> ) const ConfirmAction = localised(({confirmAction, translate, error}) => <button href='#' className={`${theme.btn} ${theme["btn-primary"]}`} onClick={preventDefaultOnClick(confirmAction)}> { error.type === 'warn' ? translate('confirm.continue') : translate('confirm.confirm') } </button> ) const Actions = ({retakeAction, confirmAction, error}) => <div className={style.actionsContainer}> <div className={classNames( theme.actions, style.actions, {[style.error]: error.type === 'error'} )}> <RetakeAction {...{retakeAction}} /> { error.type === 'error' ? null : <ConfirmAction {...{confirmAction, error}} /> } </div> </div> const Previews = localised(({capture, retakeAction, confirmAction, error, method, documentType, translate, isFullScreen}) => { const title = method === 'face' ? translate(`confirm.face.${capture.variant}.title`) : translate(`confirm.${method}.title`) const subTitle = method === 'face' ? translate(`confirm.face.${capture.variant}.message`) : translate(`confirm.${documentType}.message`) return ( <div className={classNames(style.previewsContainer, { [style.previewsContainerIsFullScreen]: isFullScreen, })}> { error.type ? <Error {...{error, withArrow: true}} /> : <Title title={title} subTitle={subTitle} smaller={true} className={style.title}/> } <div className={classNames(theme.imageWrapper, { [style.videoWrapper]: capture.variant === 'video', })}> <CaptureViewer {...{ capture, method, isFullScreen }} /> </div> <Actions {...{retakeAction, confirmAction, error}} /> </div> ) }) const chainMultiframeUpload = (snapshot, selfie, token, onSuccess, onError) => { const snapshotData = { file: { blob: snapshot.blob, filename: snapshot.filename }, sdkMetadata: snapshot.sdkMetadata, snapshot: true, advanced_validation: false } const { blob, filename, sdkMetadata } = selfie // try to upload snapshot first, if success upload selfie, else handle error uploadLivePhoto(snapshotData, token, () => uploadLivePhoto({ file: { blob, filename }, sdkMetadata }, token, onSuccess, onError ), onError ) } class Confirm extends Component { constructor(props){ super(props) this.state = { uploadInProgress: false, error: {}, captureId: null, onfidoId: null, } } onGlareWarning = () => { this.setWarning('GLARE_DETECTED') } setError = (name) => this.setState({error: {name, type: 'error'}}) setWarning = (name) => this.setState({error: {name, type: 'warn'}}) onfidoErrorFieldMap = ([key, val]) => { if (key === 'document_detection') return 'INVALID_CAPTURE' // on corrupted PDF or other unsupported file types if (key === 'file') return 'INVALID_TYPE' // hit on PDF/invalid file type submission for face detection if (key === 'attachment' || key === 'attachment_content_type') return 'UNSUPPORTED_FILE' if (key === 'face_detection') { return val[0].indexOf('Multiple faces') === -1 ? 'NO_FACE_ERROR' : 'MULTIPLE_FACES_ERROR' } } onfidoErrorReduce = ({fields}) => { const [first] = Object.entries(fields).map(this.onfidoErrorFieldMap) return first } onApiError = ({status, response}) => { let errorKey; if (this.props.mobileFlow && status === 401) { return this.props.crossDeviceClientError() } else if (status === 422) { errorKey = this.onfidoErrorReduce(response.error) } else { trackException(`${status} - ${response}`) errorKey = 'SERVER_ERROR' } this.setState({uploadInProgress: false}) this.setError(errorKey) } onApiSuccess = (apiResponse) => { const duration = Math.round(performance.now() - this.startTime) sendEvent('Completed upload', {duration, method: this.props.method}) this.setState({onfidoId: apiResponse.id}) const warnings = apiResponse.sdk_warnings if (warnings && !warnings.detect_glare.valid) { this.setState({uploadInProgress: false}) this.onGlareWarning() } else { this.props.nextStep() } } handleSelfieUpload = ({snapshot, ...selfie }, token) => { // if snapshot is present, it needs to be uploaded together with the user initiated selfie if (snapshot) { chainMultiframeUpload(snapshot, selfie, token, this.onApiSuccess, this.onApiError ) } else { const { blob, filename, sdkMetadata } = selfie // filename is only present for images taken via webcam. // Captures that have been taken via the Uploader component do not have filename // and the blob is a File type const filePayload = filename ? { blob, filename } : blob uploadLivePhoto({ file: filePayload, sdkMetadata }, token, this.onApiSuccess, this.onApiError ) } } uploadCaptureToOnfido = () => { const {capture, method, side, token, documentType, language} = this.props this.startTime = performance.now() sendEvent('Starting upload', {method}) this.setState({uploadInProgress: true}) const {blob, documentType: type, id, variant, challengeData, sdkMetadata} = capture this.setState({captureId: id}) if (method === 'document') { const isPoA = includes(poaDocumentTypes, documentType) const shouldDetectGlare = !isOfFileType(['pdf'], blob) && !isPoA const shouldDetectDocument = !isPoA const validations = { ...(shouldDetectDocument ? { 'detect_document': 'error' } : {}), ...(shouldDetectGlare ? { 'detect_glare': 'warn' } : {}), } const issuingCountry = isPoA ? { 'issuing_country': this.props.country || 'GBR' } : {} const data = { file: blob, type, side, validations, ...issuingCountry} uploadDocument(data, token, this.onApiSuccess, this.onApiError) } else if (method === 'face') { if (variant === 'video') { const data = { challengeData, blob, language, sdkMetadata} uploadLiveVideo(data, token, this.onApiSuccess, this.onApiError) } else { this.handleSelfieUpload(capture, token) } } } onConfirm = () => { this.state.error.type === 'warn' ? this.props.nextStep() : this.uploadCaptureToOnfido() } render = ({capture, previousStep, method, documentType, isFullScreen}) => ( this.state.uploadInProgress ? <Spinner /> : <Previews isFullScreen={isFullScreen} capture={capture} retakeAction={previousStep} confirmAction={this.onConfirm} error={this.state.error} method={method} documentType={documentType} /> ) } const captureKey = (...args) => cleanFalsy(args).join('_') const mapStateToProps = (state, { method, side }) => ({ capture: state.captures[captureKey(method, side)], isFullScreen: state.globals.isFullScreen, }) const TrackedConfirmComponent = trackComponentAndMode(Confirm, 'confirmation', 'error') const MapConfirm = connect(mapStateToProps)(localised(TrackedConfirmComponent)) const DocumentFrontWrapper = (props) => <MapConfirm {...props} method="document" side="front" /> const DocumentBackWrapper = (props) => <MapConfirm {...props} method="document" side="back" /> const BaseFaceConfirm = (props) => <MapConfirm {...props} method="face" /> const DocumentFrontConfirm = appendToTracking(DocumentFrontWrapper, 'front') const DocumentBackConfirm = appendToTracking(DocumentBackWrapper, 'back') const SelfieConfirm = appendToTracking(BaseFaceConfirm, 'selfie') const VideoConfirm = appendToTracking(BaseFaceConfirm, 'video') export { DocumentFrontConfirm, DocumentBackConfirm, SelfieConfirm, VideoConfirm}