velocity-verification
Version:
JavaScript SDK view layer for identity verification
344 lines (307 loc) • 10.3 kB
JavaScript
import { h, Component } from 'preact'
import classNames from 'classnames'
import theme from '../../Theme/style.css'
import style from './style.css'
import Spinner from '../../Spinner'
import Button from '../../Button'
import CopyLink from './CopyLink'
import PhoneNumberInputLazy from '../../PhoneNumberInput/Lazy'
import QRCodeGenerator from '../../QRCode'
import QRCodeHowTo from '../../QRCode/HowTo'
import Error from '../../Error'
import PageTitle from '../../PageTitle'
import { trackComponent, sendEvent } from '../../../Tracker'
import { localised } from '../../../locales'
import { createSocket } from '~utils/crossDeviceSync'
class SmsError extends Component {
componentDidMount() {
const errorName = this.props.error.name.toLowerCase()
this.props.trackScreen([errorName])
}
render = ({ error }) => <Error role="alert" {...{ error }} />
}
class CrossDeviceLink extends Component {
constructor(props) {
super(props)
if (!props.socket) {
const url = props.urls.sync_url
const socket = createSocket(url)
socket.on('connect', () => {
const roomId = this.props.roomId || null
socket.emit('join', {roomId})
})
socket.on('joined', this.onJoined)
socket.open()
props.actions.setSocket(socket)
}
}
componentDidMount() {
this.listen(this.props.socket)
}
componentWillReceiveProps(nextProps) {
if (nextProps.socket !== this.props.socket) {
this.unlisten(this.props.socket)
this.listen(nextProps.socket)
}
}
componentWillUnmount() {
this.unlisten(this.props.socket)
}
unlisten = (socket) => {
if (!socket) return
socket.off('get config', this.onGetConfig)
socket.off('client success', this.onClientSuccess)
}
listen = (socket) => {
if (!socket) return
socket.on('get config', this.onGetConfig)
socket.on('client success', this.onClientSuccess)
}
onJoined = (data) => {
const {actions, roomId} = this.props
if (!roomId) {
actions.setRoomId(data.roomId)
}
}
onGetConfig = (data) => {
const { roomId, mobileConfig, socket, actions, nextStep } = this.props
if (roomId && roomId !== data.roomId) {
socket.emit('leave', {roomId})
}
actions.mobileConnected(true)
this.sendMessage('config', data.roomId, mobileConfig)
nextStep()
}
onClientSuccess = () => {
const { actions } = this.props
actions.setClientSuccess(true)
this.props.nextStep()
}
sendMessage = (event, roomId, payload) => {
this.props.socket.emit('message', {event, payload, roomId})
}
render = () =>
this.props.roomId ? <CrossDeviceLinkUI {...this.props} /> : <Spinner />
}
class CrossDeviceLinkUI extends Component {
constructor(props) {
super(props)
this.state = {
currentViewId: 'qr_code',
sending: false,
error: {},
validNumber: true
}
}
componentDidMount() {
this.checkUploadTimeoutId = setInterval(this.handleDocumentCheck, 3000)
}
componentWillUnmount() {
this.clearCheckInterval()
this.clearSendLinkClickTimeout()
}
handleDocumentCheck = async () => {
const { coreRequest, Method, verification:{verification_submission_id}} = this.props
const res = await coreRequest.fetch(Method.GET, `/verification/document-check/${verification_submission_id}`)
if(res.submitted === true){
this.clearCheckInterval()
this.props.actions.setClientSuccess(true)
this.props.nextStep()
}
}
clearCheckInterval = () => {
if (this.checkUploadTimeoutId) {
clearInterval(this.checkUploadTimeoutId)
}
}
linkId = `${process.env.BASE_32_VERSION}${this.props.roomId}`
setError = (name) => this.setState({ error: { name, type: 'error' } })
clearErrors = () => {
this.clearSendLinkClickTimeout()
this.setState({
error: {},
validNumber: true
})
}
handleResponse = (response) => {
this.clearSendLinkClickTimeout()
this.setState({ sending: false })
if (response.status === 'OK') {
this.props.nextStep()
} else {
this.setError('SMS_FAILED')
}
}
handleSMSError = (error) => {
this.clearSendLinkClickTimeout()
this.setState({ sending: false })
this.props.triggerOnError(error)
this.setError('SMS_FAILED')
}
handleSendSmsLinkClick = () => {
if (!this.props.sms.valid) {
this.clearSendLinkClickTimeout()
this.setState({ validNumber: false })
} else if (!this.sendLinkClickTimeoutId) {
this.sendLinkClickTimeoutId = setTimeout(this.sendSms, 500)
}
}
sendSms = async () => {
this.setState({ sending: true })
// add a quick note that this will send a production SMS, so non-production
// environment users will need to amend any URLs that they receive.
if (
process.env.NODE_ENV === 'test' ||
process.env.NODE_ENV === 'development'
) {
alert(
`An SMS will be sent, but the link in it will be to production, not to ${window.location.origin}`
)
}
// On staging, inform devs that sms will not be sent and link must be copy-pasted
if (process.env.NODE_ENV === 'staging') {
alert(
`No SMS will be sent, please copy this link ${window.location.origin}`
)
}
const { sms, coreRequest, Method, documentType, verification, urls: { hosted_sdk_url } } = this.props
const data = {
to_phone_number: sms.number,
url: `${hosted_sdk_url}/upload/${documentType}/${verification.verification_submission_id}`
}
try{
await coreRequest.fetch(Method.POST, '/verification/send-sms/', data)
this.handleResponse({status: 'OK'})
}catch(e){
this.handleSMSError(String(e))
}
// performHttpReq(options, this.handleResponse, this.handleSMSError)
}
getMobileUrl = () => {
const { urls: { hosted_sdk_url }, documentType, verification } = this.props
// This lets us test the cross device flow locally and on Surge.
// We use the same location to test the same bundle as the desktop flow.
return process.env.MOBILE_URL === "/" ?
`${window.location.origin}?link_id=${this.linkId}` :
`${hosted_sdk_url}upload/${documentType}/${verification.verification_submission_id}`
}
clearSendLinkClickTimeout() {
if (this.sendLinkClickTimeoutId) {
clearTimeout(this.sendLinkClickTimeoutId)
}
}
renderSmsLinkSection = () => {
this.clearCheckInterval()
const { translate } = this.props
const { sending, validNumber } = this.state
const buttonCopyKey = sending ?
'cross_device.link.button_copy.status' :
'cross_device.link.button_copy.action'
const invalidNumber = !validNumber
return (
<div style={{display: 'none'}}>
<div className={style.smsSection}>
<div className={style.label}>
{translate('cross_device.link.sms_label')}
</div>
<div className={style.numberInputSection}>
<div
className={classNames(style.inputContainer, {
[style.fieldError]: invalidNumber
})}
>
<PhoneNumberInputLazy
{...this.props}
clearErrors={this.clearErrors}
/>
</div>
<Button
ariaLive="polite"
ariaRelevant="text"
ariaBusy={sending}
className={classNames(style.btn, { [style.sending]: sending })}
variants={['primary']}
onClick={this.handleSendSmsLinkClick}
disabled={sending || invalidNumber}
>
{translate(buttonCopyKey)}
</Button>
</div>
</div>
<div role="alert" hidden={!invalidNumber}>
{invalidNumber && (
<div className={style.numberError}>
{translate('errors.invalid_number.message')}
</div>
)}
</div>
</div>
)
}
renderCopyLinkSection = () => {
this.clearCheckInterval()
return (
<CopyLink mobileUrl={this.getMobileUrl()} {...this.props} />
)
}
renderQrCodeSection = () => (
<div className={style.qrCodeSection}>
<div className={style.qrCodeContainer}>
<QRCodeGenerator url={this.getMobileUrl()} size={144} />
</div>
<QRCodeHowTo />
</div>
)
handleViewOptionSelect = (newViewId) => {
sendEvent(`${newViewId.replace('_',' ')} selected`)
this.setState({ currentViewId: newViewId })
}
render() {
const { translate, trackScreen } = this.props
const { error, currentViewId } = this.state
const secureLinkViews = [{
id: 'qr_code',
className: 'qrCodeLinkOption',
label: 'cross_device.link.qr_code_option',
render: this.renderQrCodeSection
},{
id: 'copy_link',
className: 'copyLinkOption',
label: 'cross_device.link.copy_link_option',
render: this.renderCopyLinkSection
}]
const currentView = secureLinkViews.find(view => view.id === currentViewId)
return (
<div className={style.container}>
{error.type ? (
<SmsError error={error} trackScreen={trackScreen} />
) : (
<PageTitle
title={translate('cross_device.link.title')}
subTitle={translate(`cross_device.link.${currentViewId}_sub_title`)} />
)}
<div className={classNames(theme.thickWrapper, style.secureLinkView)}>
<div role="region" id="selectedLinkView">
{currentView.render()}
</div>
<p className={style.styledLabel}>
{translate('cross_device.link.options_divider_label')}
</p>
<div className={style.viewOptions} aria-controls="selectedLinkView">
{secureLinkViews
.filter(view => view.id !== this.state.currentViewId)
.map(view => (
<button
type="button"
className={classNames(theme.link, style.viewOption, style[view.className])}
onClick={() => this.handleViewOptionSelect(view.id)}>
{translate(view.label)}
</button>
))}
</div>
</div>
</div>
)
}
}
export default trackComponent(localised(CrossDeviceLink), 'crossdevice_link')