velocity-verification
Version:
JavaScript SDK view layer for identity verification
382 lines (329 loc) • 12 kB
JavaScript
import { h, Component } from 'preact'
import createHistory from 'history/createBrowserHistory'
import { pick } from '~utils/object'
import { isDesktop } from '~utils'
import { jwtExpired } from '~utils/jwt'
import { createSocket } from '~utils/crossDeviceSync'
import { componentsList } from './StepComponentMap'
import StepsRouter from './StepsRouter'
import { themeWrap } from '../Theme'
import Spinner from '../Spinner'
import GenericError from '../GenericError'
import { getWoopraCookie, setWoopraCookie, trackException } from '../../Tracker'
import { LocaleProvider } from '../../locales'
const restrictedXDevice = process.env.RESTRICTED_XDEVICE_FEATURE_ENABLED
const Router = (props) => {
const RouterComponent = props.options.mobileFlow ? CrossDeviceMobileRouter : MainRouter
return <RouterComponent {...props} allowCrossDeviceFlow={!props.options.mobileFlow && isDesktop}/>
}
// Wrap components with theme that include navigations and footer
const WrappedSpinner = themeWrap(Spinner)
const WrappedError = themeWrap(GenericError)
class CrossDeviceMobileRouter extends Component {
constructor(props) {
super(props)
// Some environments put the link ID in the query string so they can serve
// the cross device flow without running nginx
const url = props.options.urls.sync_url
const roomId = window.location.pathname.substring(3) ||
props.options.roomId
this.state = {
token: null,
steps: null,
step: null,
socket: createSocket(url),
roomId,
crossDeviceError: false,
loading: true
}
if (restrictedXDevice && isDesktop) {
return this.setError('FORBIDDEN_CLIENT_ERROR')
}
this.state.socket.on('config', this.setMobileConfig(props.actions))
this.state.socket.on('connect', () => {
this.state.socket.emit('join', {roomId: this.state.roomId})
})
this.state.socket.open()
this.requestMobileConfig()
}
configTimeoutId = null
pingTimeoutId = null
componentDidMount() {
this.state.socket.on('custom disconnect', this.onDisconnect)
this.state.socket.on('disconnect pong', this.onDisconnectPong)
}
componentWillUnmount() {
this.clearConfigTimeout()
this.clearPingTimeout()
this.state.socket.close()
}
sendMessage = (event, payload) => {
const roomId = this.state.roomId
this.state.socket.emit('message', {roomId, event, payload})
}
requestMobileConfig = () => {
this.sendMessage('get config')
this.clearConfigTimeout()
this.configTimeoutId = setTimeout(() => {
if (this.state.loading) this.setError()
}, 10000)
}
clearConfigTimeout = () =>
this.configTimeoutId && clearTimeout(this.configTimeoutId)
clearPingTimeout = () => {
if (this.pingTimeoutId) {
clearTimeout(this.pingTimeoutId)
this.pingTimeoutId = null
}
}
setMobileConfig = (actions) => (data) => {
const {
token,
steps,
language,
documentType,
poaDocumentType,
step: userStepIndex,
clientStepIndex,
woopraCookie
} = data
setWoopraCookie(woopraCookie)
if (!token) {
console.error('Desktop did not send token')
trackException('Desktop did not send token')
return this.setError()
}
if (jwtExpired(token)) {
console.error('Desktop token has expired')
trackException(`Token has expired: ${token}`)
return this.setError()
}
const isFaceStep = steps[clientStepIndex].type === "face"
this.setState(
{ token, steps,
step: isFaceStep ? clientStepIndex : userStepIndex,
stepIndexType: isFaceStep ? 'client' : 'user',
crossDeviceError: false, language },
// Temporary fix for https://github.com/valotas/preact-context/issues/20
// Once a fix is released, it should be done in CX-2571
() => this.setState({ loading: false })
)
if (poaDocumentType) {
actions.setPoADocumentType(poaDocumentType)
} else {
actions.setIdDocumentType(documentType)
}
actions.acceptTerms()
}
setError = (name='GENERIC_CLIENT_ERROR') => {
this.setState({crossDeviceError: { name }, loading: false})
}
onDisconnect = () => {
this.pingTimeoutId = setTimeout(this.setError, 3000)
this.sendMessage('disconnect ping')
}
onDisconnectPong = () =>
this.clearPingTimeout()
sendClientSuccess = () => {
this.state.socket.off('custom disconnect', this.onDisconnect)
const captures = Object.keys(this.props.captures).reduce((acc, key) => {
const dataWhitelist = ['documentType', 'poaDocumentType', 'id', 'metadata', 'method', 'side']
return acc.concat(pick(this.props.captures[key], dataWhitelist))
}, [])
this.sendMessage('client success', { captures })
}
render = () => {
const { language } = this.state
return (
<LocaleProvider language={language}>
{
this.state.loading ? <WrappedSpinner disableNavigation={true} /> :
this.state.crossDeviceError ? <WrappedError disableNavigation={true} error={this.state.crossDeviceError} /> :
<HistoryRouter {...this.props} {...this.state}
sendClientSuccess={this.sendClientSuccess}
crossDeviceClientError={this.setError}
/>
}
</LocaleProvider>
)
}
}
class MainRouter extends Component {
constructor(props) {
super(props)
this.state = {
crossDeviceInitialStep: null,
}
}
generateMobileConfig = () => {
const {documentType, poaDocumentType, deviceHasCameraSupport, options} = this.props
const {steps, token, language} = options
const woopraCookie = getWoopraCookie()
return {
steps, token, language, documentType, poaDocumentType, deviceHasCameraSupport, woopraCookie,
step: this.state.crossDeviceInitialStep,
clientStepIndex:this.state.crossDeviceInitialClientStep
}
}
onFlowChange = (
newFlow, newStep,
previousFlow, {userStepIndex,clientStepIndex}) => {
if (newFlow === "crossDeviceSteps"){
this.setState({
crossDeviceInitialStep: userStepIndex,
crossDeviceInitialClientStep: clientStepIndex
})
}
}
render = (props) =>
<HistoryRouter {...props}
steps={props.options.steps}
onFlowChange={this.onFlowChange}
mobileConfig={this.generateMobileConfig()}
/>
}
const findFirstIndex = (componentsList, clientStepIndex) =>
componentsList.findIndex(({stepIndex})=> stepIndex === clientStepIndex)
class HistoryRouter extends Component {
constructor(props) {
super(props)
const componentsList = this.buildComponentsList({flow:'captureSteps'},this.props)
const stepIndex = this.props.stepIndexType === "client" ?
findFirstIndex(componentsList, this.props.step || 0) :
this.props.step || 0
this.state = {
flow: 'captureSteps',
step: stepIndex,
initialStep: stepIndex,
}
this.history = createHistory()
this.unlisten = this.history.listen(this.onHistoryChange)
this.setStepIndex(this.state.step, this.state.flow)
}
onHistoryChange = ({ state: historyState }) => {
this.setState({...historyState})
}
componentWillUnmount () {
this.unlisten()
}
getStepType = step => {
const componentList = this.getComponentsList()
return componentList[step] ? componentList[step].step.type : null
}
disableNavigation = () => {
return this.props.isNavigationDisabled || this.initialStep() || this.getStepType(this.state.step) === 'complete'
}
initialStep = () => this.state.initialStep === this.state.step && this.state.flow === 'captureSteps'
changeFlowTo = (newFlow, newStep = 0, excludeStepFromHistory = false) => {
const {flow: previousFlow, step: previousUserStepIndex} = this.state
if (previousFlow === newFlow) return
const previousUserStep = this.getComponentsList()[previousUserStepIndex]
this.props.onFlowChange(newFlow, newStep,
previousFlow,
{
userStepIndex: previousUserStepIndex,
clientStepIndex: previousUserStep.stepIndex,
clientStep: previousUserStep
}
)
this.setStepIndex(newStep, newFlow, excludeStepFromHistory)
}
nextStep = () => {
const { step: currentStep } = this.state
const componentsList = this.getComponentsList()
const newStepIndex = currentStep + 1
if (componentsList.length === newStepIndex) {
this.triggerOnComplete()
}
else {
this.setStepIndex(newStepIndex)
}
}
triggerOnComplete = () => {
const { captures } = this.props;
const data = Object.keys(captures).reduce((acc, key) => ({
...acc,
[key]: captures[key].metadata
}), {})
this.props.options.events.emit('complete', data)
}
formattedError = (response, status) => {
if (typeof response === 'string') {
// TODO: this if statement should be deleted once all APIs start using the same signature for responses
// Currently detect_documents returns just a string. Examples:
// `Could not decode token: hello`
// `Token has expired.`
// Telephony returns the a JSON response
// {“unauthorized”:”Could not decode token: hello"}
// {"unauthorized":"Token has expired."}
// Tickets in backlog to update all APIs to use signature similar to main Onfido API
let message;
try {
const jsonRes = JSON.parse(response)
message = jsonRes.unauthorized || jsonRes.error || response
}
catch {
// response is just a string so we will return it as the message
message = response
}
const type = message.includes('expired') ? 'expired_token' : 'exception'
return { type, message }
}
const apiError = response.error || {}
const isExpiredTokenError = status === 401 && apiError.type === 'expired_token'
const type = isExpiredTokenError ? 'expired_token' : 'exception'
// TODO: delete response.reason once `v2/live_video_challenge` endpoints starts using the same signature for responses
// `v2/live_video_challenge` returns a generic message for both invalid and expired tokens. Example:
// {"reason":"invalid_token","status":"error"}
// Ticket in backlog to update all APIs to use signature similar to main Onfido API
const message = apiError.message || response.reason
return { type, message }
}
triggerOnError = (apiResponse) => {
const { status, response } = apiResponse
if (status === 0) return
const error = this.formattedError(response, status)
const { type, message } = error
this.props.options.events.emit('error', { type, message })
trackException(`${type} - ${message}`)
}
previousStep = () => {
const {step: currentStep} = this.state
this.setStepIndex(currentStep - 1)
}
back = () => {
this.history.goBack()
}
setStepIndex = (newStepIndex, newFlow, excludeStepFromHistory) => {
const { flow:currentFlow } = this.state
const newState = {
step: newStepIndex,
flow: newFlow || currentFlow,
}
if (excludeStepFromHistory) {
this.setState(newState)
} else {
const path = `${location.pathname}${location.search}${location.hash}`
this.history.push(path, newState)
}
}
getComponentsList = () => this.buildComponentsList(this.state, this.props)
buildComponentsList = ({flow},{documentType, poaDocumentType, steps, deviceHasCameraSupport, options: {mobileFlow}}) =>
componentsList({flow, documentType, poaDocumentType, steps, mobileFlow, deviceHasCameraSupport});
render = (props) =>
<StepsRouter {...props}
componentsList={this.getComponentsList()}
step={this.state.step}
disableNavigation={this.disableNavigation()}
changeFlowTo={this.changeFlowTo}
nextStep={this.nextStep}
previousStep={this.previousStep}
triggerOnError={this.triggerOnError}
back={this.back}
/>;
}
HistoryRouter.defaultProps = {
onFlowChange: ()=>{},
stepIndexType: 'user'
}
export default Router