feat: implement sumsub API module

feat: add 3rd party services splash screen
feat: add sumsub as a configurable 3rd party service
feat: sumsub config loader
fix: small fixes

feat: add external validation as a compliance trigger
feat: add external validation route in l-s
feat: add external validation graphql module
feat: integrate sumsub SDK

feat: improve sumsub form to allow adding multiple applicant levels with enhanced UX
feat: added support for array fields in FormRenderer
feat: allow external validation triggers to dynamically use levels setup in the services page
fix: multiple small fixes

feat: get external compliance customer info
fix: small fixes

feat: add informational card in customer profile regarding external service info

feat: send external customer data for machine trigger verification

feat: restrictions to the creation of custom info requests and external validation triggers
fix: allow for a single applicant level to be setup

fix: account instance access

fix: small fixes

fix: development-only log
This commit is contained in:
Sérgio Salgado 2022-11-03 18:53:08 +00:00 committed by Rafael Taranto
parent 6c8ced3c1f
commit 6ba0632067
31 changed files with 1730 additions and 67 deletions

View file

@ -0,0 +1,53 @@
import { makeStyles } from '@material-ui/core/styles'
import classnames from 'classnames'
import React, { memo } from 'react'
import typographyStyles from 'src/components/typography/styles'
import { ReactComponent as DeleteIcon } from 'src/styling/icons/button/cancel/zodiac.svg'
import { zircon, zircon2, comet, fontColor, white } from 'src/styling/variables'
const { p } = typographyStyles
const styles = {
button: {
extend: p,
border: 'none',
backgroundColor: zircon,
cursor: 'pointer',
outline: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: 167,
height: 48,
color: fontColor,
'&:hover': {
backgroundColor: zircon2
},
'&:active': {
backgroundColor: comet,
color: white,
'& svg g *': {
stroke: white
}
},
'& svg': {
marginRight: 8
}
}
}
const useStyles = makeStyles(styles)
const SimpleButton = memo(({ className, children, ...props }) => {
const classes = useStyles()
return (
<button className={classnames(classes.button, className)} {...props}>
<DeleteIcon />
{children}
</button>
)
})
export default SimpleButton

View file

@ -1,6 +1,7 @@
import ActionButton from './ActionButton'
import AddButton from './AddButton'
import Button from './Button'
import DeleteButton from './DeleteButton'
import FeatureButton from './FeatureButton'
import IDButton from './IDButton'
import IconButton from './IconButton'
@ -19,5 +20,6 @@ export {
IDButton,
AddButton,
SupportLinkButton,
SubpageButton
SubpageButton,
DeleteButton
}

View file

@ -0,0 +1,52 @@
import { useQuery } from '@apollo/react-hooks'
import SumsubWebSdk from '@sumsub/websdk-react'
import gql from 'graphql-tag'
import React from 'react'
import { useLocation } from 'react-router-dom'
const QueryParams = () => new URLSearchParams(useLocation().search)
const CREATE_NEW_TOKEN = gql`
query getApplicantAccessToken($customerId: ID, $triggerId: ID) {
getApplicantAccessToken(customerId: $customerId, triggerId: $triggerId)
}
`
const Sumsub = () => {
const token = QueryParams().get('t')
const customerId = QueryParams().get('customer')
const triggerId = QueryParams().get('trigger')
const { refetch: getNewToken } = useQuery(CREATE_NEW_TOKEN, {
skip: true,
variables: { customerId: customerId, triggerId: triggerId }
})
const config = {
lang: 'en'
}
const options = {
addViewportTag: true,
adaptIframeHeight: true
}
const updateAccessToken = () =>
getNewToken().then(res => {
const { getApplicantAccessToken: _token } = res.data
return _token
})
return (
<SumsubWebSdk
accessToken={token}
expirationHandler={updateAccessToken}
config={config}
options={options}
onMessage={console.log}
onError={console.error}
/>
)
}
export default Sumsub

View file

@ -11,7 +11,8 @@ import { TextInput } from 'src/components/inputs/formik'
import { H3, Info3 } from 'src/components/typography'
import {
OVERRIDE_AUTHORIZED,
OVERRIDE_REJECTED
OVERRIDE_REJECTED,
OVERRIDE_PENDING
} from 'src/pages/Customers/components/propertyCard'
import { ReactComponent as CardIcon } from 'src/styling/icons/ID/card/comet.svg'
import { ReactComponent as PhoneIcon } from 'src/styling/icons/ID/phone/comet.svg'
@ -25,7 +26,7 @@ import { URI } from 'src/utils/apollo'
import { onlyFirstToUpper } from 'src/utils/string'
import styles from './CustomerData.styles.js'
import { EditableCard } from './components'
import { EditableCard, NonEditableCard } from './components'
import {
customerDataElements,
customerDataSchemas,
@ -64,7 +65,7 @@ const Photo = ({ show, src }) => {
const CustomerData = ({
locale,
customer,
customer = {},
updateCustomer,
replacePhoto,
editCustomer,
@ -99,6 +100,7 @@ const CustomerData = ({
const customInfoRequests = sortByName(
R.path(['customInfoRequests'])(customer) ?? []
)
const externalCompliance = []
const phone = R.path(['phone'])(customer)
const email = R.path(['email'])(customer)
@ -399,6 +401,60 @@ const CustomerData = ({
})
}, R.keys(smsData) ?? [])
const externalComplianceProvider =
R.path([`externalCompliance`, `provider`])(customer) ?? undefined
const externalComplianceData = {
sumsub: {
getApplicantInfo: data => {
return R.path(['fixedInfo'])(data) ?? {}
},
getVerificationState: data => {
const reviewStatus = R.path(['review', 'reviewStatus'])(data)
const reviewResult = R.path(['review', 'reviewResult', 'reviewAnswer'])(
data
)
const state =
reviewStatus === 'completed'
? reviewResult === 'GREEN'
? OVERRIDE_AUTHORIZED
: OVERRIDE_REJECTED
: OVERRIDE_PENDING
const comment = R.path(['review', 'reviewResult', 'clientComment'])(
data
)
const labels = R.path(['review', 'reviewResult', 'rejectLabels'])(data)
return { state, comment, labels }
}
}
}
const externalComplianceValues = R.path(['externalCompliance'])(customer)
if (
!R.isNil(externalComplianceValues) &&
!R.isEmpty(externalComplianceValues)
) {
externalCompliance.push({
fields: R.map(it => ({ name: it[0], label: it[0], value: it[1] }))(
R.toPairs(
externalComplianceData[externalComplianceProvider]?.getApplicantInfo(
externalComplianceValues
)
)
),
titleIcon: <CardIcon className={classes.cardIcon} />,
state: externalComplianceData[
externalComplianceProvider
]?.getVerificationState(externalComplianceValues),
title: 'External Info'
})
}
const editableCard = (
{
title,
@ -440,6 +496,21 @@ const CustomerData = ({
)
}
const nonEditableCard = (
{ title, state, titleIcon, fields, hasImage },
idx
) => {
return (
<NonEditableCard
title={title}
key={idx}
state={state}
titleIcon={titleIcon}
hasImage={hasImage}
fields={fields}></NonEditableCard>
)
}
const visibleCards = getVisibleCards(cards)
return (
@ -514,6 +585,25 @@ const CustomerData = ({
</Grid>
</div>
)}
{!R.isEmpty(externalCompliance) && (
<div className={classes.wrapper}>
<span className={classes.separator}>
External compliance information
</span>
<Grid container>
<Grid container direction="column" item xs={6}>
{externalCompliance.map((elem, idx) => {
return isEven(idx) ? nonEditableCard(elem, idx) : null
})}
</Grid>
<Grid container direction="column" item xs={6}>
{externalCompliance.map((elem, idx) => {
return !isEven(idx) ? nonEditableCard(elem, idx) : null
})}
</Grid>
</Grid>
</div>
)}
</div>
{retrieveAdditionalDataDialog}
</div>

View file

@ -82,6 +82,7 @@ const GET_CUSTOMER = gql`
isTestCustomer
subscriberInfo
phoneOverride
externalCompliance
customFields {
id
label
@ -153,6 +154,7 @@ const SET_CUSTOMER = gql`
lastTxClass
subscriberInfo
phoneOverride
externalCompliance
}
}
`

View file

@ -8,7 +8,7 @@ import { useState, React } from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import PromptWhenDirty from 'src/components/PromptWhenDirty'
import { MainStatus } from 'src/components/Status'
// import { HoverableTooltip } from 'src/components/Tooltip'
import { HoverableTooltip } from 'src/components/Tooltip'
import { ActionButton } from 'src/components/buttons'
import { Label1, P, H3 } from 'src/components/typography'
import {
@ -402,4 +402,85 @@ const EditableCard = ({
)
}
export default EditableCard
const NonEditableCard = ({
fields,
hasImage,
state: _state,
title,
titleIcon
}) => {
const classes = useStyles()
const { state, comment, labels } = _state
const label1ClassNames = {
[classes.label1]: true,
[classes.label1Pending]: state === OVERRIDE_PENDING,
[classes.label1Rejected]: state === OVERRIDE_REJECTED,
[classes.label1Accepted]: state === OVERRIDE_AUTHORIZED
}
const authorized =
state === OVERRIDE_PENDING
? { label: 'Pending', type: 'neutral' }
: state === OVERRIDE_REJECTED
? { label: 'Rejected', type: 'error' }
: { label: 'Accepted', type: 'success' }
return (
<div>
<Card className={classes.card}>
<CardContent>
<div className={classes.headerWrapper}>
<div className={classes.cardHeader}>
{titleIcon}
<H3 className={classes.cardTitle}>{title}</H3>
{
// TODO: Enable for next release
/* <HoverableTooltip width={304}></HoverableTooltip> */
}
</div>
{state && (
<div className={classnames(label1ClassNames)}>
<MainStatus statuses={[authorized]} />
{comment && labels && (
<HoverableTooltip width={304}>
<P>Comments about this decision:</P>
{R.map(
it => (
<P noMargin>{it}</P>
),
R.split('\n', comment)
)}
<P>Relevant labels: {R.join(',', labels)}</P>
</HoverableTooltip>
)}
</div>
)}
</div>
<div className={classes.row}>
<Grid container>
<Grid container direction="column" item xs={6}>
{!hasImage &&
fields?.map((field, idx) => {
return idx >= 0 && idx < 4 ? (
<ReadOnlyField field={field} value={field.value} />
) : null
})}
</Grid>
<Grid container direction="column" item xs={6}>
{!hasImage &&
fields?.map((field, idx) => {
return idx >= 4 ? (
<ReadOnlyField field={field} value={field.value} />
) : null
})}
</Grid>
</Grid>
</div>
</CardContent>
</Card>
</div>
)
}
export { EditableCard, NonEditableCard }

View file

@ -2,7 +2,7 @@ import Wizard from '../Wizard'
import CustomerDetails from './CustomerDetails'
import CustomerSidebar from './CustomerSidebar'
import EditableCard from './EditableCard'
import { EditableCard, NonEditableCard } from './EditableCard'
import Field from './Field'
import IdDataCard from './IdDataCard'
import PhotosCarousel from './PhotosCarousel'
@ -17,6 +17,7 @@ export {
CustomerSidebar,
Field,
EditableCard,
NonEditableCard,
Wizard,
Upload
}

View file

@ -1,4 +1,3 @@
/* eslint-disable no-unused-vars */
import { useQuery } from '@apollo/react-hooks'
import { makeStyles } from '@material-ui/core'
import Grid from '@material-ui/core/Grid'
@ -6,7 +5,7 @@ import BigNumber from 'bignumber.js'
import classnames from 'classnames'
import gql from 'graphql-tag'
import * as R from 'ramda'
import React, { useState } from 'react'
import React from 'react'
import { Label2 } from 'src/components/typography'
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
@ -38,7 +37,7 @@ const Footer = () => {
const withCommissions = R.path(['cryptoRates', 'withCommissions'])(data) ?? {}
const classes = useStyles()
const config = R.path(['config'])(data) ?? {}
const canExpand = R.keys(withCommissions).length > 4
// const canExpand = R.keys(withCommissions).length > 4
const wallets = fromNamespace('wallets')(config)
const cryptoCurrencies = R.path(['cryptoCurrencies'])(data) ?? []

View file

@ -11,6 +11,7 @@ import itbit from './itbit'
import kraken from './kraken'
import mailgun from './mailgun'
import scorechain from './scorechain'
import sumsub from './sumsub'
import telnyx from './telnyx'
import trongrid from './trongrid'
import twilio from './twilio'
@ -33,5 +34,6 @@ export default {
[scorechain.code]: scorechain,
[trongrid.code]: trongrid,
[binance.code]: binance,
[bitfinex.code]: bitfinex
[bitfinex.code]: bitfinex,
[sumsub.code]: sumsub
}

View file

@ -0,0 +1,84 @@
import React, { useState } from 'react'
import * as Yup from 'yup'
import { Button } from 'src/components/buttons'
import { Checkbox } from 'src/components/inputs'
import { SecretInput, TextInput } from 'src/components/inputs/formik'
import { P } from 'src/components/typography'
import { secretTest } from './helper'
const SumsubSplash = ({ classes, onContinue }) => {
const [canContinue, setCanContinue] = useState(false)
return (
<div className={classes.form}>
<P>
Before linking the Sumsub 3rd party service to the Lamassu Admin, make
sure you have configured the required parameters in your personal Sumsub
Dashboard.
</P>
<P>
These parameters include the Sumsub Global Settings, Applicant Levels,
Twilio and Webhooks.
</P>
<Checkbox
value={canContinue}
onChange={() => setCanContinue(!canContinue)}
settings={{
enabled: true,
label: 'I have completed the steps needed to configure Sumsub',
rightSideLabel: true
}}
/>
<div className={classes.footer}>
<div className={classes.buttonWrapper}>
<Button disabled={!canContinue} onClick={onContinue}>
Continue
</Button>
</div>
</div>
</div>
)
}
const schema = {
code: 'sumsub',
name: 'Sumsub',
category: 'Compliance',
allowMultiInstances: false,
SplashScreenComponent: SumsubSplash,
elements: [
{
code: 'apiToken',
display: 'API Token',
component: SecretInput
},
{
code: 'secretKey',
display: 'Secret Key',
component: SecretInput
},
{
code: 'applicantLevel',
display: 'Applicant Level',
component: TextInput,
face: true
}
],
getValidationSchema: account => {
return Yup.object().shape({
apiToken: Yup.string('The API token must be a string')
.max(100, 'The API token is too long')
.test(secretTest(account?.apiToken, 'API token')),
secretKey: Yup.string('The secret key must be a string')
.max(100, 'The secret key is too long')
.test(secretTest(account?.secretKey, 'secret key')),
applicantLevel: Yup.string('The applicant level must be a string')
.max(100, 'The applicant level is too long')
.required('The applicant level is required')
})
}
}
export default schema

View file

@ -29,7 +29,8 @@ const TriggerView = ({
toggleWizard,
addNewTriger,
customInfoRequests,
emailAuth
emailAuth,
additionalInfo
}) => {
const currency = R.path(['fiatCurrency'])(
fromNamespace(namespaces.LOCALE)(config)
@ -69,7 +70,12 @@ const TriggerView = ({
error={error?.message}
save={save}
validationSchema={Schema}
elements={getElements(currency, classes, customInfoRequests)}
elements={getElements(
currency,
classes,
customInfoRequests,
additionalInfo
)}
/>
{showWizard && (
<Wizard
@ -79,6 +85,8 @@ const TriggerView = ({
onClose={() => toggleWizard(true)}
customInfoRequests={customInfoRequests}
emailAuth={emailAuth}
triggers={triggers}
additionalInfo={additionalInfo}
/>
)}
{R.isEmpty(triggers) && (

View file

@ -4,6 +4,7 @@ import classnames from 'classnames'
import gql from 'graphql-tag'
import * as R from 'ramda'
import React, { useState } from 'react'
import { getAccountInstance } from 'src/utils/accounts'
import Modal from 'src/components/Modal'
import { HoverableTooltip } from 'src/components/Tooltip'
@ -18,6 +19,7 @@ import { ReactComponent as CustomInfoIcon } from 'src/styling/icons/circle butto
import { ReactComponent as ReverseSettingsIcon } from 'src/styling/icons/circle buttons/settings/white.svg'
import { ReactComponent as SettingsIcon } from 'src/styling/icons/circle buttons/settings/zodiac.svg'
import { fromNamespace, toNamespace } from 'src/utils/config'
import { COMPLIANCE_SERVICES } from 'src/utils/constants'
import CustomInfoRequests from './CustomInfoRequests'
import TriggerView from './TriggerView'
@ -132,32 +134,67 @@ const Triggers = () => {
else toggleWizard('newTrigger')()
}
const accounts = data?.accounts ?? {}
const isAnyExternalValidationAccountEnabled = () => {
try {
return R.any(
it => it === true,
R.map(
ite => getAccountInstance(accounts[ite], ite)?.enabled,
COMPLIANCE_SERVICES
)
)
} catch (e) {
return false
}
}
const buttons = []
const externalValidationLevels = !R.isEmpty(accounts)
? R.reduce(
(acc, value) => {
const instances = accounts[value]?.instances ?? {}
return {
...acc,
[value]: R.map(
it => ({ value: it, display: it }),
R.uniq(R.map(ite => ite.applicantLevel, instances) ?? [])
)
}
},
{},
COMPLIANCE_SERVICES
)
: []
!isAnyExternalValidationAccountEnabled() &&
buttons.push({
text: 'Advanced settings',
icon: SettingsIcon,
inverseIcon: ReverseSettingsIcon,
forceDisable: !(subMenu === 'advancedSettings'),
toggle: show => {
refetch()
setSubMenu(show ? 'advancedSettings' : false)
}
})
buttons.push({
text: 'Custom info requests',
icon: CustomInfoIcon,
inverseIcon: ReverseCustomInfoIcon,
forceDisable: !(subMenu === 'customInfoRequests'),
toggle: show => {
refetch()
setSubMenu(show ? 'customInfoRequests' : false)
}
})
return (
<>
<TitleSection
title="Compliance Triggers"
buttons={[
{
text: 'Advanced settings',
icon: SettingsIcon,
inverseIcon: ReverseSettingsIcon,
forceDisable: !(subMenu === 'advancedSettings'),
toggle: show => {
refetch()
setSubMenu(show ? 'advancedSettings' : false)
}
},
{
text: 'Custom info requests',
icon: CustomInfoIcon,
inverseIcon: ReverseCustomInfoIcon,
forceDisable: !(subMenu === 'customInfoRequests'),
toggle: show => {
refetch()
setSubMenu(show ? 'customInfoRequests' : false)
}
}
]}
title="Compliance triggers"
buttons={buttons}
className={classnames(titleSectionWidth)}>
{!subMenu && (
<Box display="flex" alignItems="center">
@ -219,8 +256,11 @@ const Triggers = () => {
config={data?.config ?? {}}
toggleWizard={toggleWizard('newTrigger')}
addNewTriger={addNewTriger}
customInfoRequests={enabledCustomInfoRequests}
emailAuth={emailAuth}
additionalInfo={{
customInfoRequests: enabledCustomInfoRequests,
externalValidationLevels: externalValidationLevels
}}
/>
)}
{!loading && subMenu === 'advancedSettings' && (

View file

@ -48,14 +48,27 @@ const styles = {
const useStyles = makeStyles(styles)
const getStep = (step, currency, customInfoRequests, emailAuth) => {
const getStep = (
{ step, config },
currency,
customInfoRequests,
emailAuth,
triggers,
additionalInfo
) => {
switch (step) {
// case 1:
// return txDirection
case 1:
return type(currency)
case 2:
return requirements(customInfoRequests, emailAuth)
return requirements(
customInfoRequests,
emailAuth,
config,
triggers,
additionalInfo
)
default:
return Fragment
}
@ -166,6 +179,8 @@ const getRequirementText = (config, classes) => {
return <>blocked</>
case 'custom':
return <>asked to fulfill a custom requirement</>
case 'external':
return <>redirected to an external verification process</>
default:
return orUnderline(null, classes)
}
@ -210,7 +225,9 @@ const Wizard = ({
error,
currency,
customInfoRequests,
emailAuth
emailAuth,
triggers,
additionalInfo
}) => {
const classes = useStyles()
@ -220,7 +237,14 @@ const Wizard = ({
})
const isLastStep = step === LAST_STEP
const stepOptions = getStep(step, currency, customInfoRequests, emailAuth)
const stepOptions = getStep(
{ step, config },
currency,
customInfoRequests,
emailAuth,
triggers,
additionalInfo
)
const onContinue = async it => {
const newConfig = R.merge(config, stepOptions.schema.cast(it))

View file

@ -9,6 +9,7 @@ import { NumberInput, RadioGroup, Dropdown } from 'src/components/inputs/formik'
import { H4, Label2, Label1, Info1, Info2 } from 'src/components/typography'
import { errorColor } from 'src/styling/variables'
import { transformNumber } from 'src/utils/number'
import { onlyFirstToUpper } from 'src/utils/string'
// import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
// import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
@ -82,6 +83,14 @@ const useStyles = makeStyles({
dropdownField: {
marginTop: 16,
minWidth: 155
},
externalFields: {
'& > *': {
marginRight: 15
},
'& > *:last-child': {
marginRight: 0
}
}
})
@ -488,6 +497,20 @@ const requirementSchema = Yup.object()
otherwise: Yup.string()
.nullable()
.transform(() => '')
}),
externalService: Yup.string().when('requirement', {
is: value => value === 'external',
then: Yup.string(),
otherwise: Yup.string()
.nullable()
.transform(() => '')
}),
externalServiceApplicantLevel: Yup.string().when('requirement', {
is: value => value === 'external',
then: Yup.string(),
otherwise: Yup.string()
.nullable()
.transform(() => '')
})
}).required()
})
@ -502,6 +525,11 @@ const requirementSchema = Yup.object()
return requirement.requirement === type
? !R.isNil(requirement.customInfoRequestId)
: true
case 'external':
return requirement.requirement === type
? !R.isNil(requirement.externalService) &&
!R.isNil(requirement.externalServiceApplicantLevel)
: true
default:
return true
}
@ -518,6 +546,12 @@ const requirementSchema = Yup.object()
path: 'requirement',
message: 'You must select an item'
})
if (requirement && !requirementValidator(requirement, 'external'))
return context.createError({
path: 'requirement',
message: 'You must select an item'
})
})
const requirementOptions = [
@ -545,7 +579,24 @@ const hasCustomRequirementError = (errors, touched, values) =>
(!values.requirement?.customInfoRequestId ||
!R.isNil(values.requirement?.customInfoRequestId))
const Requirement = ({ customInfoRequests, emailAuth }) => {
const hasExternalRequirementError = (errors, touched, values) =>
!!errors.requirement &&
!!touched.requirement?.externalService &&
!!touched.requirement?.externalServiceApplicantLevel &&
(!values.requirement?.externalService ||
!R.isNil(values.requirement?.externalService)) &&
(!values.requirement?.externalServiceApplicantLevel ||
!R.isNil(values.requirement?.externalServiceApplicantLevel))
const Requirement = ({
config = {},
triggers,
additionalInfo: {
emailAuth,
customInfoRequests = [],
externalValidationLevels = {}
}
}) => {
const classes = useStyles()
const {
touched,
@ -557,29 +608,74 @@ const Requirement = ({ customInfoRequests, emailAuth }) => {
const isSuspend = values?.requirement?.requirement === 'suspend'
const isCustom = values?.requirement?.requirement === 'custom'
const isExternal = values?.requirement?.requirement === 'external'
const customRequirementsInUse = R.reduce(
(acc, value) => {
if (value.requirement.requirement === 'custom')
acc.push({
triggerType: value.triggerType,
id: value.requirement.customInfoRequestId
})
return acc
},
[],
triggers
)
const availableCustomRequirements = R.filter(
it =>
!R.includes(
{ triggerType: config.triggerType, id: it.id },
customRequirementsInUse
),
customInfoRequests
)
const makeCustomReqOptions = () =>
customInfoRequests.map(it => ({
availableCustomRequirements.map(it => ({
value: it.id,
display: it.customRequest.name
}))
const enableCustomRequirement = customInfoRequests?.length > 0
const enableCustomRequirement = !R.isEmpty(availableCustomRequirements)
const enableExternalRequirement = !R.any(
// TODO: right now this condition is directly related with sumsub. On adding external validation, this needs to be generalized
ite => ite.requirement === 'external' && ite.externalService === 'sumsub',
R.map(it => ({
requirement: it.requirement.requirement,
externalService: it.requirement.externalService
}))(triggers)
)
const customInfoOption = {
display: 'Custom information requirement',
code: 'custom'
}
const externalOption = { display: 'External verification', code: 'external' }
const itemToRemove = emailAuth ? 'sms' : 'email'
const reqOptions = requirementOptions.filter(it => it.code !== itemToRemove)
const options = enableCustomRequirement
? [...reqOptions, customInfoOption]
: [...reqOptions]
const options = R.clone(reqOptions)
enableCustomRequirement && options.push(customInfoOption)
enableExternalRequirement && options.push(externalOption)
const titleClass = {
[classes.error]:
(!!errors.requirement && !isSuspend && !isCustom) ||
(isSuspend && hasRequirementError(errors, touched, values)) ||
(isCustom && hasCustomRequirementError(errors, touched, values))
(isCustom && hasCustomRequirementError(errors, touched, values)) ||
(isExternal && hasExternalRequirementError(errors, touched, values))
}
const externalServices = [
{
value: 'sumsub',
display: 'Sumsub'
}
]
return (
<>
<Box display="flex" alignItems="center">
@ -620,22 +716,49 @@ const Requirement = ({ customInfoRequests, emailAuth }) => {
/>
</div>
)}
{isExternal && (
<div className={classes.externalFields}>
<Field
className={classes.dropdownField}
component={Dropdown}
label="Service"
name="requirement.externalService"
options={externalServices}
/>
{!R.isNil(
externalValidationLevels[values.requirement.externalService]
) && (
<Field
className={classes.dropdownField}
component={Dropdown}
label="Applicant level"
name="requirement.externalServiceApplicantLevel"
options={
externalValidationLevels[values.requirement.externalService]
}
/>
)}
</div>
)}
</>
)
}
const requirements = (customInfoRequests, emailAuth) => ({
const requirements = (config, triggers, additionalInfo) => ({
schema: requirementSchema,
options: requirementOptions,
Component: Requirement,
props: { customInfoRequests, emailAuth },
props: { config, triggers, additionalInfo },
hasRequirementError: hasRequirementError,
hasCustomRequirementError: hasCustomRequirementError,
hasExternalRequirementError: hasExternalRequirementError,
initialValues: {
requirement: {
requirement: '',
suspensionDays: '',
customInfoRequestId: ''
customInfoRequestId: '',
externalService: '',
externalServiceApplicantLevel: ''
}
}
})
@ -665,7 +788,9 @@ const customReqIdMatches = customReqId => it => {
return it.id === customReqId
}
const RequirementInput = ({ customInfoRequests }) => {
const RequirementInput = ({
additionalInfo: { customInfoRequests = [], externalValidationLevels = {} }
}) => {
const { values } = useFormikContext()
const classes = useStyles()
@ -700,7 +825,8 @@ const RequirementView = ({
requirement,
suspensionDays,
customInfoRequestId,
customInfoRequests
externalService,
additionalInfo: { customInfoRequests = [], externalValidationLevels = {} }
}) => {
const classes = useStyles()
const display =
@ -708,6 +834,8 @@ const RequirementView = ({
? R.path(['customRequest', 'name'])(
R.find(customReqIdMatches(customInfoRequestId))(customInfoRequests)
) ?? ''
: requirement === 'external'
? `External validation (${onlyFirstToUpper(externalService)})`
: getView(requirementOptions, 'display')(requirement)
const isSuspend = requirement === 'suspend'
return (
@ -821,7 +949,7 @@ const ThresholdView = ({ config, currency }) => {
return <DisplayThreshold config={config} currency={currency} />
}
const getElements = (currency, classes, customInfoRequests) => [
const getElements = (currency, classes, additionalInfo) => [
{
name: 'triggerType',
size: 'sm',
@ -840,17 +968,15 @@ const getElements = (currency, classes, customInfoRequests) => [
{
name: 'requirement',
size: 'sm',
width: 230,
width: 260,
bypassField: true,
input: () => <RequirementInput customInfoRequests={customInfoRequests} />,
view: it => (
<RequirementView {...it} customInfoRequests={customInfoRequests} />
)
input: () => <RequirementInput additionalInfo={additionalInfo} />,
view: it => <RequirementView {...it} additionalInfo={additionalInfo} />
},
{
name: 'threshold',
size: 'sm',
width: 284,
width: 254,
textAlign: 'right',
input: () => <ThresholdInput currency={currency} />,
view: (it, config) => <ThresholdView config={config} currency={currency} />
@ -885,12 +1011,16 @@ const fromServer = (triggers, customInfoRequests) => {
threshold,
thresholdDays,
customInfoRequestId,
externalService,
externalServiceApplicantLevel,
...rest
}) => ({
requirement: {
requirement,
suspensionDays,
customInfoRequestId
customInfoRequestId,
externalService,
externalServiceApplicantLevel
},
threshold: {
threshold,
@ -908,6 +1038,8 @@ const toServer = triggers =>
threshold: threshold.threshold,
thresholdDays: threshold.thresholdDays,
customInfoRequestId: requirement.customInfoRequestId,
externalService: requirement.externalService,
externalServiceApplicantLevel: requirement.externalServiceApplicantLevel,
...rest
}))(triggers)

View file

@ -16,6 +16,7 @@ import Login from 'src/pages/Authentication/Login'
import Register from 'src/pages/Authentication/Register'
import Reset2FA from 'src/pages/Authentication/Reset2FA'
import ResetPassword from 'src/pages/Authentication/ResetPassword'
import Sumsub from 'src/pages/Compliance/Sumsub'
import Dashboard from 'src/pages/Dashboard'
import Machines from 'src/pages/Machines'
import Wizard from 'src/pages/Wizard'
@ -91,7 +92,8 @@ const Routes = () => {
'/login',
'/register',
'/resetpassword',
'/reset2fa'
'/reset2fa',
'/sumsub'
]
if (!wizardTested && !R.contains(location.pathname)(dontTriggerPages)) {
@ -142,6 +144,7 @@ const Routes = () => {
</PrivateRoute>
<PrivateRoute path="/machines" component={Machines} />
<PrivateRoute path="/wizard" component={Wizard} />
<PublicRoute path="/sumsub" component={Sumsub} />
<PublicRoute path="/register" component={Register} />
{/* <PublicRoute path="/configmigration" component={ConfigMigration} /> */}
<PublicRoute path="/login" restricted component={Login} />

View file

@ -10,6 +10,8 @@ const IP_CHECK_REGEX = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[
const SWEEPABLE_CRYPTOS = ['ETH']
const COMPLIANCE_SERVICES = ['sumsub']
export {
CURRENCY_MAX,
MIN_NUMBER_OF_CASSETTES,
@ -18,5 +20,6 @@ export {
MANUAL,
WALLET_SCORING_DEFAULT_THRESHOLD,
IP_CHECK_REGEX,
SWEEPABLE_CRYPTOS
SWEEPABLE_CRYPTOS,
COMPLIANCE_SERVICES
}