diff --git a/packages/account/package.json b/packages/account/package.json index cdbb5de88c96..91d5bd63d16a 100644 --- a/packages/account/package.json +++ b/packages/account/package.json @@ -76,12 +76,12 @@ "eslint-plugin-react-hooks": "^4.2.0", "history": "^5.0.0", "mini-css-extract-plugin": "^1.3.4", - "sass": "^1.62.1", "postcss-loader": "^6.2.1", "postcss-preset-env": "^7.4.3", "postcss-scss": "^4.0.6", "resolve-url-loader": "^3.1.2", "rimraf": "^3.0.2", + "sass": "^1.62.1", "sass-loader": "^12.6.0", "sass-resources-loader": "^2.1.1", "terser-webpack-plugin": "^5.1.1", diff --git a/packages/account/src/App.tsx b/packages/account/src/App.tsx index 58d34d3b6f71..64861a8e79a8 100644 --- a/packages/account/src/App.tsx +++ b/packages/account/src/App.tsx @@ -3,7 +3,8 @@ import Routes from './Containers/routes'; import ResetTradingPassword from './Containers/reset-trading-password'; import { APIProvider } from '@deriv/api'; import { StoreProvider } from '@deriv/stores'; -import type { TCoreStores } from '@deriv/stores/types'; +import { TCoreStores } from '@deriv/stores/types'; +import { POIProvider } from './Helpers/poi-context'; // TODO: add correct types for WS after implementing them type TAppProps = { @@ -19,13 +20,15 @@ const App = ({ passthrough }: TAppProps) => { const { notification_messages_ui: Notifications } = root_store.ui; return ( - - - {Notifications && } - - - - + + + + {Notifications && } + + + + + ); }; diff --git a/packages/account/src/Components/personal-details/personal-details.jsx b/packages/account/src/Components/personal-details/personal-details.jsx index 87203a58dffe..fe85ba151d89 100644 --- a/packages/account/src/Components/personal-details/personal-details.jsx +++ b/packages/account/src/Components/personal-details/personal-details.jsx @@ -65,6 +65,7 @@ const PersonalDetails = ({ residence_list, real_account_signup_target, }); + const IDV_NOT_APPLICABLE_OPTION = React.useMemo(() => getIDVNotApplicableOption(), []); const validateIDV = values => { diff --git a/packages/account/src/Components/poi/poi-country-selector/__tests__/poi-country-selector.spec.tsx b/packages/account/src/Components/poi/poi-country-selector/__tests__/poi-country-selector.spec.tsx index 79e8de7583b4..c08cd8136db8 100644 --- a/packages/account/src/Components/poi/poi-country-selector/__tests__/poi-country-selector.spec.tsx +++ b/packages/account/src/Components/poi/poi-country-selector/__tests__/poi-country-selector.spec.tsx @@ -2,6 +2,9 @@ import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { isDesktop, isMobile } from '@deriv/shared'; import CountrySelector from '../poi-country-selector'; +import { APIProvider } from '@deriv/api'; +import { StoreProvider, mockStore } from '@deriv/stores'; +import { POIContext } from 'Helpers/poi-context'; jest.mock('@deriv/shared', () => ({ ...jest.requireActual('@deriv/shared'), @@ -9,33 +12,56 @@ jest.mock('@deriv/shared', () => ({ isMobile: jest.fn(() => false), })); +jest.mock('@deriv/api', () => ({ + ...jest.requireActual('@deriv/api'), + useResidenceList: jest.fn().mockReturnValue({ + data: [ + { value: 'Country 1', text: 'Country 1' }, + { value: 'Country 2', text: 'Country 2' }, + { value: 'Country 3', text: 'Country 3' }, + ], + }), +})); + describe('', () => { - let mock_props = { + let mock_props: React.ComponentProps = { handleSelectionNext: jest.fn(), is_from_external: false, - residence_list: [{ value: '', text: '' }], - selected_country: '', - setSelectedCountry: jest.fn(), }; beforeEach(() => { mock_props = { handleSelectionNext: jest.fn(), is_from_external: false, - residence_list: [ - { value: 'Country 1', text: 'Country 1' }, - { value: 'Country 2', text: 'Country 2' }, - { value: 'Country 3', text: 'Country 3' }, - ], - selected_country: '', - setSelectedCountry: jest.fn(), }; }); + const store_config = mockStore({}); + + const poi_context_config = { + setSelectedCountry: jest.fn(), + selected_country: {}, + setSubmissionService: jest.fn(), + submission_status: 'selecting', + setSubmissionStatus: jest.fn(), + submission_service: 'idv', + }; + + const renderComponent = ({ props = mock_props, context = poi_context_config }) => + render( + + + + + + + + ); + it('should render CountrySelector component external', () => { mock_props.is_from_external = true; - render(); + renderComponent({ props: mock_props }); expect(screen.getByText('Proof of identity')).toBeInTheDocument(); expect(screen.getByText('In which country was your document issued?')).toBeInTheDocument(); @@ -45,7 +71,7 @@ describe('', () => { }); it('should show error message after clicking the input without choosing the country', async () => { - render(); + renderComponent({}); const field = screen.getByLabelText('Country'); const next_btn = screen.getByRole('button'); @@ -63,12 +89,30 @@ describe('', () => { expect(next_btn).toBeDisabled(); }); + it('should render error status and country selector when error is verification failed or expired', () => { + mock_props.mismatch_status = 'POI_FAILED'; + + renderComponent({ props: mock_props }); + + expect(screen.getByText('Your identity verification failed because:')).toBeInTheDocument(); + expect( + screen.getByText('We were unable to verify the identity document with the details provided.') + ).toBeInTheDocument(); + expect(screen.getByText('In which country was your document issued?')).toBeInTheDocument(); + + expect(screen.getByTestId('dt_external_dropdown')).toBeInTheDocument(); + }); + it('should trigger selection functions and next button', async () => { (isDesktop as jest.Mock).mockReturnValue(false); (isMobile as jest.Mock).mockReturnValue(true); - mock_props.selected_country = 'Country 2'; - render(); + const new_poi_context_config = { + ...poi_context_config, + selected_country: { value: 'Country 2', text: 'Country 2' }, + }; + + renderComponent({ context: new_poi_context_config }); const field = screen.getByRole('combobox'); @@ -76,7 +120,6 @@ describe('', () => { const next_btn = screen.getByRole('button'); expect(next_btn).toBeEnabled(); - expect(mock_props.setSelectedCountry).toHaveBeenCalledTimes(1); fireEvent.click(next_btn); await waitFor(() => { diff --git a/packages/account/src/Components/poi/poi-country-selector/index.js b/packages/account/src/Components/poi/poi-country-selector/index.ts similarity index 100% rename from packages/account/src/Components/poi/poi-country-selector/index.js rename to packages/account/src/Components/poi/poi-country-selector/index.ts diff --git a/packages/account/src/Components/poi/poi-country-selector/poi-country-selector.tsx b/packages/account/src/Components/poi/poi-country-selector/poi-country-selector.tsx index 6b8ffec4f25d..9beb162b2524 100644 --- a/packages/account/src/Components/poi/poi-country-selector/poi-country-selector.tsx +++ b/packages/account/src/Components/poi/poi-country-selector/poi-country-selector.tsx @@ -5,27 +5,18 @@ import { Autocomplete, Button, DesktopWrapper, HintBox, MobileWrapper, Text, Sel import { idv_error_statuses, isMobile, TIDVErrorStatus } from '@deriv/shared'; import { Localize, localize } from '@deriv/translations'; import FormFooter from 'Components/form-footer'; - -type TCountry = Record; +import { POIContext } from '../../../Helpers/poi-context'; +import { useResidenceList } from '@deriv/api'; type TCountrySelector = { handleSelectionNext: () => void; is_from_external: boolean; mismatch_status?: TIDVErrorStatus; - residence_list: TCountry[]; - selected_country: string; - setSelectedCountry: (value: TCountry) => void; }; -const CountrySelector = ({ - handleSelectionNext, - is_from_external, - mismatch_status, - residence_list, - selected_country, - setSelectedCountry, -}: TCountrySelector) => { - const [country_list, setCountryList] = React.useState([]); +const CountrySelector = ({ handleSelectionNext, is_from_external, mismatch_status }: TCountrySelector) => { + const { setSelectedCountry } = React.useContext(POIContext); + const { data: country_list, isLoading } = useResidenceList(); const initial_form_values: FormikValues = { country_input: '', @@ -45,7 +36,7 @@ const CountrySelector = ({ }; const updateSelectedCountry = (country_name: string) => { - const matching_country: TCountry | undefined = country_list.find((c: FormikValues) => c.text === country_name); + const matching_country = country_list?.find((c: FormikValues) => c.text === country_name); if (matching_country) { setSelectedCountry?.(matching_country); } @@ -57,10 +48,6 @@ const CountrySelector = ({ handleSelectionNext?.(); }; - React.useEffect(() => { - setCountryList(residence_list); - }, [residence_list]); - let failed_message: JSX.Element | null = null; if (mismatch_status === idv_error_statuses.poi_expired) { failed_message = ; @@ -187,7 +174,7 @@ const CountrySelector = ({ type='submit' onClick={() => handleSubmit()} has_effect - is_disabled={!dirty || isSubmitting || !isValid || !selected_country} + is_disabled={!dirty || isSubmitting || !isValid || isLoading} is_loading={false} text={localize('Next')} large diff --git a/packages/account/src/Constants/onfido-phrases.js b/packages/account/src/Constants/onfido.js similarity index 99% rename from packages/account/src/Constants/onfido-phrases.js rename to packages/account/src/Constants/onfido.js index e5064fc51566..1b6ae73e56f0 100644 --- a/packages/account/src/Constants/onfido-phrases.js +++ b/packages/account/src/Constants/onfido.js @@ -1,7 +1,8 @@ import React from 'react'; import { Localize } from '@deriv/translations'; -const getOnfidoPhrases = () => ({ +//TODO: Check if these phrases are required; +export const getOnfidoPhrases = () => ({ country_select: { alert_dropdown: { country_not_found: , @@ -341,4 +342,7 @@ const getOnfidoPhrases = () => ({ }, }); -export default getOnfidoPhrases; +export const LANGUAGE_CODES = { + ID: 'id_ID', + EN: 'en_US', +}; diff --git a/packages/account/src/Helpers/__tests__/utils.spec.tsx b/packages/account/src/Helpers/__tests__/utils.spec.tsx index 2ac1f113f3e5..e8faed2780ac 100644 --- a/packages/account/src/Helpers/__tests__/utils.spec.tsx +++ b/packages/account/src/Helpers/__tests__/utils.spec.tsx @@ -10,6 +10,7 @@ import { isFieldImmutable, preventEmptyClipboardPaste, shouldShowIdentityInformation, + getOnfidoSupportedLocaleCode, verifyFields, } from '../utils'; @@ -230,6 +231,20 @@ describe('isDocumentNumberValid', () => { }); }); +describe('getOnfidoSupportedLocaleCode', () => { + it('should return the correct language tag for German', () => { + expect(getOnfidoSupportedLocaleCode('DE')).toEqual('de'); + }); + + it('should return the correct language tag for Indonesian', () => { + expect(getOnfidoSupportedLocaleCode('ID')).toEqual('id_ID'); + }); + + it('should return the correct language tag for Chinese', () => { + expect(getOnfidoSupportedLocaleCode('Zh_CN')).toEqual('zh_CN'); + }); +}); + describe('verifyFields', () => { it('should return date field in the list when the error is date of birth', () => { expect(verifyFields('POI_DOB_MISMATCH')).toEqual(['date_of_birth']); diff --git a/packages/account/src/Helpers/poi-context.tsx b/packages/account/src/Helpers/poi-context.tsx new file mode 100644 index 000000000000..6a09623662c1 --- /dev/null +++ b/packages/account/src/Helpers/poi-context.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { useLocation } from 'react-router-dom'; +import { ResidenceList } from '@deriv/api-types'; +import { service_code, submission_status_code } from '../Sections/Verification/ProofOfIdentity/proof-of-identity-utils'; + +type TSubmissionStatus = keyof typeof submission_status_code; +type TSubmissionService = keyof typeof service_code; + +type TPOIContext = { + submission_status: TSubmissionStatus; + setSubmissionStatus: React.Dispatch>; + submission_service: TSubmissionService; + setSubmissionService: React.Dispatch>; + selected_country: ResidenceList[number]; + setSelectedCountry: React.Dispatch>; +}; + +export const POIContextInitialState: TPOIContext = { + submission_status: submission_status_code.selecting, + setSubmissionStatus: () => submission_status_code.selecting, + submission_service: service_code.idv, + setSubmissionService: () => service_code.idv, + selected_country: {}, + setSelectedCountry: () => ({}), +}; + +export const POIContext = React.createContext(POIContextInitialState); + +export const POIProvider = ({ children }: React.PropsWithChildren) => { + const [submission_status, setSubmissionStatus] = React.useState( + submission_status_code.selecting + ); + const [submission_service, setSubmissionService] = React.useState(service_code.idv); + const [selected_country, setSelectedCountry] = React.useState({}); + const location = useLocation(); + + const state = React.useMemo( + () => ({ + submission_status, + setSubmissionStatus, + submission_service, + setSubmissionService, + selected_country, + setSelectedCountry, + }), + [selected_country, submission_service, submission_status] + ); + + React.useEffect(() => { + setSubmissionStatus(submission_status_code.selecting); + setSubmissionService(service_code.idv); + setSelectedCountry({}); + }, [location.pathname]); + + return {children}; +}; diff --git a/packages/account/src/Helpers/utils.tsx b/packages/account/src/Helpers/utils.tsx index ca80a440d407..7dd38ecac073 100644 --- a/packages/account/src/Helpers/utils.tsx +++ b/packages/account/src/Helpers/utils.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import countries from 'i18n-iso-countries'; import { Localize, localize } from '@deriv/translations'; import { filterObjProperties, @@ -13,6 +14,7 @@ import { ResidenceList, GetAccountStatus } from '@deriv/api-types'; import { FormikValues } from 'formik'; import { getIDVDocuments } from '../Constants/idv-document-config'; import { TServerError } from '../Types'; +import { LANGUAGE_CODES } from '../Constants/onfido'; export const documentAdditionalError = ( document_additional: string | undefined, @@ -193,6 +195,43 @@ export const flatten = >(arr: T) => [].concat(...arr); export const isServerError = (error: unknown): error is TServerError => typeof error === 'object' && error !== null && 'code' in error; +/** + * Returns the alpha 3 code for a given country code + * @name convertAlpha2toAlpha3 + * @param country_code - country code + * @returns alpha 3 code + */ +export const convertAlpha2toAlpha3 = (country_code: string) => + country_code.length !== 3 ? countries.alpha2ToAlpha3(country_code.toUpperCase()) : country_code; + +/** + * Returns the alpha 2 code for a given country code + * @name convertAlpha3toAlpha2 + * @param country_code - country code + * @returns alpha 2 code + */ + +export const convertAlpha3toAlpha2 = (country_code: string) => + country_code.length !== 2 ? countries.alpha3ToAlpha2(country_code.toUpperCase()) : country_code; + +/** + * Generates a language code supported by Onfido + * @name getOnfidoSupportedLocaleCode + * @param language_code + * @returns language code supported by Onfido + */ +export const getOnfidoSupportedLocaleCode = (language_code: string) => { + try { + const code = language_code.toLowerCase().split('_'); + if (code[0] === 'id') { + return LANGUAGE_CODES.ID; + } + return code.length > 1 ? `${code[0]}_${code[1].toUpperCase()}` : code[0]; + } catch (e) { + return LANGUAGE_CODES.EN; + } +}; + export const getIDVDocumentType = ( idv_latest_attempt: DeepRequired['authentication']['attempts']['latest'], residence: DeepRequired diff --git a/packages/account/src/Sections/Verification/ProofOfIdentity/onfido-sdk-view-container.tsx b/packages/account/src/Sections/Verification/ProofOfIdentity/onfido-sdk-view-container.tsx index 81229c563e4d..314c61ae2622 100644 --- a/packages/account/src/Sections/Verification/ProofOfIdentity/onfido-sdk-view-container.tsx +++ b/packages/account/src/Sections/Verification/ProofOfIdentity/onfido-sdk-view-container.tsx @@ -1,15 +1,16 @@ import React, { useCallback } from 'react'; -import countries from 'i18n-iso-countries'; import { CSSTransition } from 'react-transition-group'; -import { GetSettings, ResidenceList } from '@deriv/api-types'; +import { useSettings } from '@deriv/api'; +import { ResidenceList } from '@deriv/api-types'; import { Loading, ThemedScrollbars } from '@deriv/components'; -import { cryptoMathRandom, isMobile, WS } from '@deriv/shared'; -import { getLanguage } from '@deriv/translations'; +import { useNotificationEvent, useServiceToken } from '@deriv/hooks'; +import { observer, useStore } from '@deriv/stores'; import ErrorMessage from '../../../Components/error-component'; -import getOnfidoPhrases from '../../../Constants/onfido-phrases'; import MissingPersonalDetails from '../../../Components/poi/missing-personal-details'; import PoiConfirmWithExampleFormContainer from '../../../Components/poi/poi-confirm-with-example-form-container'; import OnfidoSdkView from './onfido-sdk-view'; +import type { SdkHandle, SdkResponse, SupportedLanguages } from '../../../Types'; +import { convertAlpha2toAlpha3, convertAlpha3toAlpha2, getOnfidoSupportedLocaleCode } from '../../../Helpers/utils'; type TAPIError = { code?: string; @@ -17,13 +18,7 @@ type TAPIError = { type?: string; }; -type TServiceToken = { - error?: TAPIError; - service_token?: { onfido?: { token: string } }; -}; - type TOnfidoSdkViewContainer = { - account_settings: GetSettings; country_code: string; documents_supported: | string[] @@ -34,242 +29,209 @@ type TOnfidoSdkViewContainer = { is_default_enabled?: boolean; }; -// TODO: Update types for this later once bundle and CDN issue with Onfido is resolved -type SdkHandle = any; -type SupportedLanguages = any; -type SdkResponse = any; - -const OnfidoSdkViewContainer = ({ - account_settings, - country_code, - documents_supported, - getChangeableFields, - handleViewComplete, - height, - is_default_enabled, -}: TOnfidoSdkViewContainer) => { - const [api_error, setAPIError] = React.useState(); - const [missing_personal_details, setMissingPersonalDetails] = React.useState(''); - const [is_status_loading, setStatusLoading] = React.useState(true); - const [retry_count, setRetryCount] = React.useState(0); - const [is_onfido_disabled, setIsOnfidoDisabled] = React.useState(true); - const token_timeout_ref = React.useRef>(); - const [is_confirmed, setIsConfirmed] = React.useState(false); - const [is_onfido_initialized, setIsOnfidoInitialized] = React.useState(false); - // IDV country code - Alpha ISO2. Onfido country code - Alpha ISO3 - // Ensures that any form of country code passed here is supported. - const onfido_country_code = - country_code.length !== 3 ? countries.alpha2ToAlpha3(country_code.toUpperCase()) : country_code; - - // Service Token country code - Alpha ISO2 - const token_country_code = - country_code.length !== 2 ? countries.alpha3ToAlpha2(country_code.toUpperCase()) : country_code; - - // Onfido `document_supported` checks are made for an array of string. - // Ensure that `document_supported` passed respects this no the matter source. - const onfido_documents = Array.isArray(documents_supported) - ? documents_supported - : Object.keys(documents_supported).map(d => documents_supported[d].display_name); - - const onfido_init = React.useRef(); - - // pass is_default_enabled to enable onfido immediately if personal detail component is not required - // so no user prompt will be there so submit the details in i.e. in case of flow for nigerian clients ATM - React.useEffect(() => { - if (is_default_enabled) { - setIsOnfidoDisabled(false); - } - }, [is_default_enabled]); - - const onComplete = React.useCallback( - (data: Omit & { data?: { id?: string } }) => { - onfido_init?.current?.tearDown(); - const document_ids = Object.keys(data).map(key => data[key as keyof SdkResponse]?.id); - WS.notificationEvent({ - notification_event: 1, - category: 'authentication', - event: 'poi_documents_uploaded', - args: { - documents: document_ids, - }, - }).then(() => { - handleViewComplete(); - }); - }, - [handleViewComplete] - ); +const OnfidoSdkViewContainer = observer( + ({ + country_code, + documents_supported, + getChangeableFields, + handleViewComplete, + height, + is_default_enabled, + }: TOnfidoSdkViewContainer) => { + const [api_error, setAPIError] = React.useState(); + const [missing_personal_details, setMissingPersonalDetails] = React.useState(''); + const [is_onfido_disabled, setIsOnfidoDisabled] = React.useState(true); + const [is_confirmed, setIsConfirmed] = React.useState(false); + const [is_onfido_initialized, setIsOnfidoInitialized] = React.useState(false); + + const { data: account_settings } = useSettings(); + + const { send, isSuccess: isNotified } = useNotificationEvent(); + const { common, ui } = useStore(); + const { current_language } = common; + const { is_mobile } = ui; + + // IDV country code - Alpha ISO2. Onfido country code - Alpha ISO3 + const onfido_country_code = convertAlpha2toAlpha3(country_code); + + // Service Token country code - Alpha ISO2 + const token_country_code = convertAlpha3toAlpha2(country_code); + + const { service_token, isSuccess, isError, error, isLoading } = useServiceToken({ + service: 'onfido', + country: token_country_code, + }); + + const onfido_init = React.useRef(); + + // Onfido `document_supported` checks are made for an array of string. + // Ensure that `document_supported` passed respects this no the matter source. + const onfido_documents = Array.isArray(documents_supported) + ? documents_supported + : Object.keys(documents_supported).map(d => documents_supported[d].display_name); + + let component_to_load: React.ReactNode; + + const onComplete = React.useCallback( + (data: Omit & { data?: { id?: string } }) => { + onfido_init?.current?.tearDown(); + const document_ids = Object.keys(data).map(key => data[key as keyof SdkResponse]?.id); + if (document_ids?.length) { + send({ + category: 'authentication', + event: 'poi_documents_uploaded', + args: { + documents: document_ids as Array, + }, + }); + } + }, + [send] + ); - const initOnfido = React.useCallback( - async (service_token: string) => { - if (!service_token) return; - try { - onfido_init.current = await window.Onfido.init({ - containerId: 'onfido', - language: { - locale: (getLanguage().toLowerCase() as SupportedLanguages) || 'en', - phrases: getOnfidoPhrases(), - mobilePhrases: getOnfidoPhrases(), - }, - token: service_token, - useModal: false, - useMemoryHistory: true, - onComplete, - steps: [ - { - type: 'document', - options: { - documentTypes: { - passport: onfido_documents.some(doc => /Passport/g.test(doc)), - driving_licence: onfido_documents.some(doc => /Driving Licence/g.test(doc)) - ? { - country: onfido_country_code, - } - : false, - national_identity_card: onfido_documents.some(doc => - /National Identity Card/g.test(doc) - ) - ? { - country: onfido_country_code, - } - : false, + const initOnfido = React.useCallback( + async (service_token: string) => { + if (!service_token) return; + try { + onfido_init.current = await window.Onfido.init({ + containerId: 'onfido', + language: getOnfidoSupportedLocaleCode(current_language) as SupportedLanguages, + token: service_token, + useModal: false, + useMemoryHistory: true, + onComplete, + steps: [ + { + type: 'document', + options: { + documentTypes: { + passport: onfido_documents.some(doc => /Passport/g.test(doc)), + driving_licence: onfido_documents.some(doc => /Driving Licence/g.test(doc)) + ? { + country: onfido_country_code, + } + : false, + national_identity_card: onfido_documents.some(doc => + /National Identity Card/g.test(doc) + ) + ? { + country: onfido_country_code, + } + : false, + }, + hideCountrySelection: true, }, - hideCountrySelection: true, }, - }, - 'face', - ], - }); - setIsOnfidoInitialized(true); - } catch (err) { - setAPIError(err as TAPIError); - setIsOnfidoDisabled(true); - onfido_init.current = undefined; - } - }, - [onComplete, onfido_documents, onfido_country_code] - ); - - const getOnfidoServiceToken = React.useCallback( - (): Promise => - new Promise(resolve => { - WS.serviceToken({ - service_token: 1, - service: 'onfido', - country: token_country_code, - }).then((response: TServiceToken) => { - if (response.error) { - resolve({ error: response.error }); - return; - } - if (response.service_token?.onfido) { - const { token } = response.service_token.onfido; - resolve(token); - } - }); - }), - [token_country_code] - ); - - const handleError = (error: TAPIError) => { - switch (error.code) { - case 'MissingPersonalDetails': - setMissingPersonalDetails('all'); - break; - case 'InvalidPostalCode': - setMissingPersonalDetails('postal_code'); - break; - default: - setAPIError(error); - break; - } - }; - - const onConfirm = useCallback(() => { - setIsConfirmed(true); - setIsOnfidoDisabled(false); - }, []); - - React.useEffect(() => { - const fetchServiceToken = () => { - if (onfido_init.current) return; - getOnfidoServiceToken().then(response_token => { - if (typeof response_token !== 'string' && response_token?.error) { - handleError(response_token.error); - setStatusLoading(false); - setRetryCount(retry_count + 1); - onfido_init.current = undefined; - } else if (typeof response_token === 'string') { - initOnfido(response_token).then(() => { - setStatusLoading(false); + 'face', + ], }); + setIsOnfidoInitialized(true); + } catch (err) { + setAPIError(err?.message ?? err); + setIsOnfidoDisabled(true); + onfido_init.current = undefined; } - if (token_timeout_ref.current) clearTimeout(token_timeout_ref.current); - }); - }; + }, + [onComplete, onfido_documents, onfido_country_code, current_language] + ); - // retry state will re-run the token fetching - if (retry_count === 0) { - fetchServiceToken(); - } else if (retry_count !== 0 && retry_count < 3) { - // Incorporating Exponential_backoff algo to prevent immediate throttling - token_timeout_ref.current = setTimeout(() => { - fetchServiceToken(); - }, Math.pow(2, retry_count) + cryptoMathRandom() * 1000); - } - return () => { - clearTimeout(token_timeout_ref.current); + const handleError = (error: TAPIError) => { + switch (error.code) { + case 'MissingPersonalDetails': + setMissingPersonalDetails('all'); + break; + case 'InvalidPostalCode': + setMissingPersonalDetails('postal_code'); + break; + default: + setAPIError(error.message); + break; + } }; - }, [getOnfidoServiceToken, initOnfido, retry_count]); - let component_to_load; + const onConfirm = useCallback(() => { + setIsConfirmed(true); + setIsOnfidoDisabled(false); + }, []); - if (is_status_loading) { - component_to_load = ; - } else if (missing_personal_details) { - component_to_load = ( - + React.useEffect(() => { + if (isSuccess && service_token?.onfido?.token) { + initOnfido(service_token?.onfido?.token); + } else if (isError) { + handleError(error as TAPIError); + } + }, [error, initOnfido, isError, isSuccess, service_token?.onfido?.token]); + + React.useEffect(() => { + /** + * Enables onfido sdk + * Pass is_default_enabled to enable onfido immediately if personal detail component is not required + * so no user prompt will be there so submit the details in i.e. in case of flow for nigerian clients ATM + */ + if (is_default_enabled) { + setIsOnfidoDisabled(false); + } + }, [is_default_enabled]); + + React.useEffect(() => { + /** + * Handles cleanup operations when document submission is completed + */ + if (isNotified) { + handleViewComplete(); + } + }, [handleViewComplete, isNotified]); + + if (isLoading) { + component_to_load = ; + } else if (missing_personal_details) { + component_to_load = ( + + ); + } else if (api_error) { + // Error message will only display if retry count exceeds 3 + component_to_load = ; + } + + return ( + +
+ {component_to_load || ( + +
+ +
+
+ )} + +
+
); - } else if (retry_count >= 3 && api_error) { - // Error message will only display if retry count exceeds 3 - component_to_load = ; } +); - return ( - -
- {component_to_load || ( - -
- -
-
- )} - -
-
- ); -}; +OnfidoSdkViewContainer.displayName = 'OnfidoSdkViewContainer'; export default OnfidoSdkViewContainer; diff --git a/packages/account/src/Sections/Verification/ProofOfIdentity/onfido-sdk-view.tsx b/packages/account/src/Sections/Verification/ProofOfIdentity/onfido-sdk-view.tsx index b1346972ed40..6707a4a9857e 100644 --- a/packages/account/src/Sections/Verification/ProofOfIdentity/onfido-sdk-view.tsx +++ b/packages/account/src/Sections/Verification/ProofOfIdentity/onfido-sdk-view.tsx @@ -74,26 +74,29 @@ const OnfidoSdkView = ({ /> - {is_onfido_disabled && is_onfido_initialized && ( - - - - } - is_info +
+ {is_onfido_disabled && is_onfido_initialized && ( +
+ + + + } + is_info + /> +
+ )} +
- )} -
+
); }; diff --git a/packages/account/src/Sections/Verification/ProofOfIdentity/proof-of-identity-container.jsx b/packages/account/src/Sections/Verification/ProofOfIdentity/proof-of-identity-container.jsx index f7b5db916306..35b5963e68c3 100644 --- a/packages/account/src/Sections/Verification/ProofOfIdentity/proof-of-identity-container.jsx +++ b/packages/account/src/Sections/Verification/ProofOfIdentity/proof-of-identity-container.jsx @@ -1,3 +1,5 @@ +import React from 'react'; +import { useHistory } from 'react-router'; import { Button, Loading } from '@deriv/components'; import { isEmptyObject, WS, getPlatformRedirect, platforms } from '@deriv/shared'; import { observer, useStore } from '@deriv/stores'; @@ -11,12 +13,10 @@ import { Localize } from '@deriv/translations'; import NotRequired from 'Components/poi/status/not-required'; import Onfido from './onfido.jsx'; import POISubmission from './proof-of-identity-submission.jsx'; -import React from 'react'; import Unsupported from 'Components/poi/status/unsupported'; import UploadComplete from 'Components/poi/status/upload-complete'; import Verified from 'Components/poi/status/verified'; import { populateVerificationStatus } from '../Helpers/verification'; -import { useHistory } from 'react-router'; const ProofOfIdentityContainer = observer( ({ height, is_from_external, onStateChange, setIsCfdPoiCompleted, getChangeableFields, updateAccountStatus }) => { @@ -38,7 +38,7 @@ const ProofOfIdentityContainer = observer( should_allow_authentication, is_virtual, } = client; - const { app_routing_history, is_language_changing, routeBackInApp } = common; + const { app_routing_history, routeBackInApp, is_language_changing } = common; const { refreshNotifications } = notifications; const from_platform = getPlatformRedirect(app_routing_history); @@ -55,8 +55,6 @@ const ProofOfIdentityContainer = observer( }); }; const loadResidenceList = React.useCallback(() => { - setAPIError(null); - setStatusLoading(true); fetchResidenceList().then(response_residence_list => { if (response_residence_list.error) { setAPIError(response_residence_list.error); @@ -64,7 +62,6 @@ const ProofOfIdentityContainer = observer( setResidenceList(response_residence_list.residence_list); } }); - setStatusLoading(false); }, [fetchResidenceList]); React.useEffect(() => { @@ -83,8 +80,8 @@ const ProofOfIdentityContainer = observer( setStatusLoading(false); return; } - loadResidenceList(); + setStatusLoading(false); }); } }, [loadResidenceList, is_switching]); @@ -92,9 +89,9 @@ const ProofOfIdentityContainer = observer( if (api_error) { return ; } else if (is_status_loading || is_switching || isEmptyObject(account_status) || residence_list.length === 0) { - /** - * Display loader while waiting for the account status and residence list to be populated - */ + /** + * Display loader while waiting for the account status and residence list to be populated + */ return ; } else if (is_virtual) { return ; diff --git a/packages/account/src/Sections/Verification/ProofOfIdentity/proof-of-identity-submission.jsx b/packages/account/src/Sections/Verification/ProofOfIdentity/proof-of-identity-submission.jsx index 687d85ac89ef..2ab63a7e4fb2 100644 --- a/packages/account/src/Sections/Verification/ProofOfIdentity/proof-of-identity-submission.jsx +++ b/packages/account/src/Sections/Verification/ProofOfIdentity/proof-of-identity-submission.jsx @@ -7,9 +7,9 @@ import IdvFailed from 'Components/poi/idv-status/idv-failed'; import IdvSubmitComplete from 'Components/poi/idv-status/idv-submit-complete'; import Unsupported from 'Components/poi/status/unsupported'; import UploadComplete from 'Components/poi/status/upload-complete'; - -import OnfidoUpload from './onfido-sdk-view-container'; -import { identity_status_codes, service_code, submission_status_code } from './proof-of-identity-utils'; +import OnfidoSdkViewContainer from './onfido-sdk-view-container'; +import { identity_status_codes, submission_status_code, service_code } from './proof-of-identity-utils'; +import { POIContext } from '../../../Helpers/poi-context'; const POISubmission = observer( ({ @@ -28,9 +28,14 @@ const POISubmission = observer( setIsCfdPoiCompleted, should_show_mismatch_form, }) => { - const [submission_status, setSubmissionStatus] = React.useState(); // selecting, submitting, complete - const [submission_service, setSubmissionService] = React.useState(); - const [selected_country, setSelectedCountry] = React.useState({}); + const { + submission_service, + setSubmissionService, + submission_status, + setSubmissionStatus, + selected_country, + setSelectedCountry, + } = React.useContext(POIContext); const { client, notifications } = useStore(); @@ -74,57 +79,72 @@ const POISubmission = observer( [residence_list] ); + const needs_resubmission = has_require_submission || allow_poi_resubmission; + const mismatch_status = formatIDVError(idv.last_rejected, idv.status); - React.useEffect(() => { - if (submission_status !== submission_status_code.complete) { - if ((has_require_submission || allow_poi_resubmission) && identity_last_attempt) { - switch (identity_last_attempt.service) { - case service_code.idv: { - if (Number(idv.submissions_left) > 0 || Number(onfido.submissions_left) > 0) { - setSubmissionStatus(submission_status_code.selecting); - } else { - setSubmissionService(service_code.manual); - setSubmissionStatus(submission_status_code.submitting); - } - break; - } - case service_code.onfido: { - if (Number(onfido.submissions_left) > 0) { - setSubmissionStatus(submission_status_code.selecting); - } else { - setSubmissionService(service_code.manual); - setSubmissionStatus(submission_status_code.submitting); - } - break; + const setIdentityService = React.useCallback( + identity_last_attempt => { + const { service, country_code } = identity_last_attempt; + switch (service) { + case service_code.idv: { + if (Number(idv.submissions_left) < 1) { + setSubmissionService(service_code.manual); } - case service_code.manual: { - setSelectedCountry(getCountryFromResidence(identity_last_attempt.country_code)); - setSubmissionStatus(submission_status_code.submitting); + break; + } + case service_code.onfido: { + if (Number(onfido.submissions_left) < 1) { setSubmissionService(service_code.manual); - break; } - default: - break; + break; } - } else if ( - mismatch_status && - ![idv_error_statuses.poi_expired, idv_error_statuses.poi_failed].includes(mismatch_status) && - idv.submissions_left > 0 - ) { - setSubmissionService(service_code.idv); - setSubmissionStatus(submission_status_code.submitting); - } else { - setSubmissionStatus(submission_status_code.selecting); + case service_code.manual: { + setSelectedCountry(getCountryFromResidence(country_code)); + setSubmissionService(service_code.manual); + break; + } + default: + break; } + setSubmissionStatus(submission_status_code.submitting); + }, + [ + getCountryFromResidence, + idv.submissions_left, + onfido.submissions_left, + setSelectedCountry, + setSubmissionService, + setSubmissionStatus, + ] + ); + + React.useEffect(() => { + if (submission_status != submission_status_code.selecting) { + return; + } + if (needs_resubmission && identity_last_attempt) { + setIdentityService(identity_last_attempt); + } else if ( + mismatch_status && + ![idv_error_statuses.poi_expired, idv_error_statuses.poi_failed].includes(mismatch_status) && + idv.submissions_left > 0 + ) { + setSubmissionService(service_code.idv); + setSubmissionStatus(submission_status_code.submitting); + } else { + setSubmissionStatus(submission_status_code.selecting); } }, [ allow_poi_resubmission, - getCountryFromResidence, - has_require_submission, identity_last_attempt, - idv.submissions_left, - onfido.submissions_left, + needs_resubmission, + setIdentityService, + submission_status, + idv, + mismatch_status, + setSubmissionService, + setSubmissionStatus, ]); switch (submission_status) { @@ -134,9 +154,6 @@ const POISubmission = observer( handleSelectionNext={handleSelectionNext} is_from_external={is_from_external} mismatch_status={mismatch_status} - residence_list={residence_list} - selected_country={selected_country} - setSelectedCountry={setSelectedCountry} /> ); } @@ -167,8 +184,7 @@ const POISubmission = observer( const documents_supported = Object.keys(doc_obj).map(d => doc_obj[d].display_name); return ( - ; + mobilePhrases?: Record; +}; +export declare type LocaleDirection = 'ltr' | 'rtl'; +declare const STEP_WELCOME = 'welcome'; +declare const STEP_USER_CONSENT = 'userConsent'; +declare const STEP_DOCUMENT = 'document'; +declare const STEP_POA = 'poa'; +declare const STEP_FACE = 'face'; +declare const STEP_COMPLETE = 'complete'; +declare const STEP_ACTIVE_VIDEO = 'activeVideo'; +declare const STEP_CROSS_DEVICE_SESSION_INTRO = 'crossDeviceSessionIntro'; +declare const STEP_DATA_CAPTURE = 'data'; +declare const STEP_WORKFLOW_RETRY = 'retry'; +export declare type PublicStepTypes = + | typeof STEP_WELCOME + | typeof STEP_DOCUMENT + | typeof STEP_POA + | typeof STEP_FACE + | typeof STEP_COMPLETE + | typeof STEP_CROSS_DEVICE_SESSION_INTRO + | typeof STEP_DATA_CAPTURE; +export declare type PrivateStepTypes = typeof STEP_WORKFLOW_RETRY | typeof STEP_USER_CONSENT | typeof STEP_ACTIVE_VIDEO; +export declare type StepTypes = PublicStepTypes | PrivateStepTypes; +export declare type DocumentTypes = + | 'passport' + | 'driving_licence' + | 'national_identity_card' + | 'residence_permit' + | 'generic_document'; +export declare type PoaTypes = + | 'bank_building_society_statement' + | 'utility_bill' + | 'council_tax' + | 'benefit_letters' + | 'government_letter' + | 'address_certificate'; +export declare type RequestedVariant = 'standard' | 'video' | 'motion'; +export declare type DocumentTypeConfig = boolean | CountryConfig; +export declare type StepOptionDocumentType = { + [Key in DocumentTypes]?: Key extends 'generic_document' ? CustomConfig : DocumentTypeConfig; +}; +export declare type CountryConfig = { + country: string | null; +}; +export declare type CustomConfig = { + id: string; +}; +export declare type CaptureOptions = { + requestedVariant?: RequestedVariant; + useUploader?: boolean; +}; +export declare type StepOptionWelcome = { + title?: string; + descriptions?: string[]; + nextButton?: string; +}; +export declare type StepOptionRetry = { + text?: { + headline?: string; + description?: string; + button_title?: string; + }; +}; +export declare type GenericDocumentType = { + id: string; + title: string; + subTitle: string; + country: string; + pages: number; +}; +export declare type StepOptionDocument = { + hideCountrySelection?: boolean; + genericDocumentTypes?: Array; + documentTypes?: StepOptionDocumentType; + documentSelection?: documentSelectionType[]; + countryFilter?: documentSelectionType[]; + forceCrossDevice?: boolean; + disableCrossDevice?: boolean; + photoCaptureFallback?: never; +} & CaptureOptions; +export declare type StepOptionPoA = Record; +export declare type StepOptionFace = { + forceCrossDevice?: never; + disableCrossDevice?: boolean; + photoCaptureFallback?: boolean; + motionFallbackVariant?: Omit; + useMultipleSelfieCapture?: boolean; + useWorkflow?: boolean; + recordMotionAudio?: boolean; +} & CaptureOptions; +export declare type StepOptionComplete = { + message?: string; + submessage?: string; +}; +export declare type StepOptionData = { + first_name?: string; + last_name?: string; + email?: string; + dob?: string; + country_residence?: string; + phone_number?: string; + company_name?: string; + address?: { + country?: string; + line1?: string; + line2?: string; + line3?: string; + town?: string; + state?: string; + postcode?: string; + }; + ssn_enabled?: boolean; + national_id_number?: { + national_id_type?: string; + national_id_value?: string; + }; + input?: { + ssn_consent_granted?: boolean; + phone_number_consent_granted?: boolean; + }; + profile_data_selection?: { + address_enabled?: boolean; + country_residence_enabled?: boolean; + dob_enabled?: boolean; + email_enabled?: boolean; + first_name_enabled?: boolean; + last_name_enabled?: boolean; + phone_number_enabled?: boolean; + nationality_enabled?: boolean; + pan_enabled?: boolean; + ssn_enabled?: boolean; + national_id_number_enabled?: boolean; + phone_number_consent_required?: boolean; + ssn_consent_required?: boolean; + }; + getPersonalData?: GetPersonalDataFunc; +}; +export declare type GetPersonalDataFunc = () => Record; +export declare type StepOptionsMap = { + welcome: StepOptionWelcome; + userConsent: never; + crossDeviceSessionIntro: never; + document: StepOptionDocument; + poa: StepOptionPoA; + face: StepOptionFace; + activeVideo: never; + complete: StepOptionComplete; + data: StepOptionData; + retry: StepOptionRetry; +}; +export declare type StepConfigMap = { + [Type in StepTypes]: { + type: Type; + options?: StepOptionsMap[Type]; + }; +}; +export declare type StepConfigWelcome = StepConfigMap['welcome']; +export declare type StepConfigUserConsent = StepConfigMap['userConsent']; +export declare type StepConfigActiveVideo = StepConfigMap['activeVideo']; +export declare type StepConfigCrossDeviceSessionIntro = StepConfigMap['crossDeviceSessionIntro']; +export declare type StepConfigDocument = StepConfigMap['document']; +export declare type StepConfigPoa = StepConfigMap['poa']; +export declare type StepConfigFace = StepConfigMap['face']; +export declare type StepConfigComplete = StepConfigMap['complete']; +export declare type StepConfigData = StepConfigMap['data']; +export declare type StepConfigRetry = StepConfigMap['retry']; +export declare type PublicStepConfig = + | StepConfigWelcome + | StepConfigDocument + | StepConfigPoa + | StepConfigFace + | StepConfigComplete + | StepConfigActiveVideo + | StepConfigCrossDeviceSessionIntro + | StepConfigData + | StepConfigRetry; +export declare type PrivateStepConfig = { + skip?: boolean; +}; +export declare type StepConfig = (PublicStepConfig | StepConfigUserConsent) & PrivateStepConfig; +export declare type UICustomizationOptions = { + colorBackgroundSurfaceModal?: string; + colorBorderSurfaceModal?: string; + borderWidthSurfaceModal?: string; + borderStyleSurfaceModal?: string; + borderRadiusSurfaceModal?: string; + fontFamilyTitle?: string; + fontSizeTitle?: string; + fontWeightTitle?: number; + colorContentTitle?: string; + fontFamilySubtitle?: string; + fontSizeSubtitle?: string; + fontWeightSubtitle?: number; + colorContentSubtitle?: string; + fontFamilyBody?: string; + fontSizeBody?: string; + fontWeightBody?: number; + colorContentBody?: string; + colorContentButtonPrimaryText?: string; + colorBackgroundButtonPrimary?: string; + colorBackgroundButtonPrimaryHover?: string; + colorBackgroundButtonPrimaryActive?: string; + colorBorderButtonPrimary?: string; + colorBorderButtonPrimaryHover?: string; + colorBorderButtonPrimaryActive?: string; + colorContentButtonSecondaryText?: string; + colorBackgroundButtonSecondary?: string; + colorBackgroundButtonSecondaryHover?: string; + colorBackgroundButtonSecondaryActive?: string; + colorBorderButtonSecondary?: string; + colorBorderButtonSecondaryHover?: string; + colorBorderButtonSecondaryActive?: string; + borderRadiusButton?: string; + buttonGroupStacked?: boolean; + colorBackgroundSelector?: string; + colorContentDocTypeButton?: string; + colorBackgroundDocTypeButton?: string; + colorBackgroundDocTypeButtonHover?: string; + colorBackgroundDocTypeButtonActive?: string; + colorBorderDocTypeButton?: string; + colorBorderDocTypeButtonHover?: string; + colorBorderDocTypeButtonActive?: string; + colorBackgroundIcon?: string; + colorIcon?: string; + colorInputOutline?: string; + colorBorderLinkUnderline?: string; + colorContentLinkTextHover?: string; + colorBackgroundLinkHover?: string; + colorBackgroundLinkActive?: string; + colorContentAlertInfo?: string; + colorBackgroundAlertInfo?: string; + colorBackgroundAlertInfoLinkHover?: string; + colorBackgroundAlertInfoLinkActive?: string; + colorContentAlertError?: string; + colorBackgroundAlertError?: string; + colorBackgroundAlertErrorLinkHover?: string; + colorBackgroundAlertErrorLinkActive?: string; + colorBackgroundInfoPill?: string; + colorContentInfoPill?: string; + colorBackgroundButtonIconHover?: string; + colorBackgroundButtonIconActive?: string; + colorBackgroundButtonCameraHover?: string; + colorBackgroundButtonCameraActive?: string; + colorBackgroundQRCode?: string; +}; +export interface NormalisedSdkOptions extends Omit { + steps: StepConfig[]; +} +export declare type DocumentSides = 'front' | 'back'; +export declare type documentSelectionType = { + config: unknown; + document_type: string; + id: string; + issuing_country: string; +}; +export declare type FatalErrorTypes = 'expired_token' | 'expired_trial' | 'exception' | 'sdk_version_insufficient'; +export declare type ImageQualityValidationTypes = + | 'detect_original_document_present' + | 'detect_barcode' + | 'detect_document' + | 'detect_cutoff' + | 'detect_glare' + | 'detect_blur'; +export declare type UploadFileResponse = { + id: string; + created_at: string; + file_name: string; + file_type: string; + file_size: number; + href: string; + download_href: string; +}; +export declare type ImageQuality = { + breakdown?: { + original_document_present?: { + has_original_document_present: boolean; + has_error: boolean; + error: unknown; + reason: 'photo_of_screen' | 'screenshot' | 'document_on_printed_paper' | 'scan'; + }; + }; +}; +export declare type ImageQualityWarnings = Partial< + Record< + ImageQualityValidationTypes, + { + valid: boolean; + } + > +> & { + image_quality?: ImageQuality; +}; +export declare type DocumentImageResponse = { + applicant_id: string; + type: DocumentTypes | PoaTypes; + side: DocumentSides; + issuing_country: string | null | undefined; + sdk_warnings?: ImageQualityWarnings; +} & UploadFileResponse; +declare const CHALLENGE_RECITE = 'recite'; +declare const CHALLENGE_MOVEMENT = 'movement'; +export declare type ChallengePayload = + | { + type: typeof CHALLENGE_RECITE; + query: number[]; + } + | { + type: typeof CHALLENGE_MOVEMENT; + query: string; + }; +export declare type VideoChallengeLanguage = { + source: string; + language_code: SupportedLanguages; +}; +export declare type FaceVideoResponse = { + challenge: ChallengePayload[]; + languages: VideoChallengeLanguage[]; +} & UploadFileResponse; +export declare type EnterpriseCobranding = { + text: string; +}; +export declare type EnterpriseLogoCobranding = { + lightLogoSrc: string; + darkLogoSrc: string; +}; +export declare type EnterpriseCallbackResponse = { + continueWithOnfidoSubmission?: boolean; + onfidoSuccessResponse?: DocumentImageResponse | UploadFileResponse | FaceVideoResponse; +}; +export declare type EnterpriseFeatures = { + hideOnfidoLogo?: boolean; + cobrand?: EnterpriseCobranding; + logoCobrand?: EnterpriseLogoCobranding; + useCustomizedApiRequests?: boolean; + onSubmitDocument?: (data: FormData) => Promise; + onSubmitSelfie?: (data: FormData) => Promise; + onSubmitVideo?: (data: FormData) => Promise; +}; +export declare type LogLevels = 'debug' | 'info' | 'warning' | 'error' | 'fatal'; +export interface ApplyFilter { + doc_type?: string; +} +export interface BiometricsLiveness { + active?: BiometricsLivenessActive; + passive?: BiometricsLivenessPassive; +} +export interface BiometricsLivenessActive { + enabled?: boolean; + video_settings?: BiometricsLivenessActiveVideoSettings; +} +export interface BiometricsLivenessActiveVideoSettings { + framerate?: number; + bitrate?: number; + duration?: number; + focusLock?: boolean; + white_balanceLock?: boolean; + exposure_lock?: boolean; + codec?: string; + codec_profile?: number; +} +export interface BiometricsLivenessPassive { + enabled?: boolean; + video_settings?: BiometricsLivenessPassiveVideoSettings; +} +export interface BiometricsLivenessPassiveVideoSettings { + framerate?: number; + bitrate?: number; + duration?: number; + focus_lock?: boolean; + white_balance_lock?: boolean; + exposure_lock?: boolean; + codec?: string; +} +export interface DocumentCapture { + /** + * The number of additional image quality retries that should return an error if an image quality validation is detected. + * This means that if image quality validations are detected, the user will only see an error on the first [1 + max_total_retries] upload attempt. + * From the [1 + max_total_retries + 1] attempt, if image quality validations are detected, the user will see a warning and they use can choose to + * proceed regardless of the image quality warning. + */ + max_total_retries?: number; + torch_turn_on_timeMs?: number; + video_length_ms?: number; + video_bitrate?: number; + enable_auto_capture_doc_capture?: boolean; + enable_js_camera_doc_capture?: boolean; + auto_capture_enabled_documents?: Array<{ + country: string; + document_type: string; + }>; + auto_capture_timeout_ms?: number; + allow_disabling_cross_device?: boolean; + enable_native_camera_fallback?: boolean; +} +export interface FaceSelfieCapture { + sign_upload?: boolean; + enable_native_camera_fallback?: boolean; +} +export interface FaceVideoCapture { + sign_upload?: boolean; +} +export interface MotionCapture { + supported: boolean; + video_settings: { + framerate: number; + bitrate: number; + duration: number; + webm_mime_type_preference: string[]; + }; + sign_upload: boolean; +} +export interface ExperimentalFeatures { + enable_image_quality_service?: boolean; + enable_multi_frame_capture?: boolean; + motion_experiment?: { + enabled: boolean; + }; + performance_benchmark?: { + enabled: boolean; + threshold: number; + }; +} +export interface SdkFeatures { + enable_require_applicant_consents?: boolean; + disable_cross_device_sms?: boolean; + enable_in_house_analytics?: boolean; + enable_performance_analytics?: boolean; + enable_document_support_rules?: boolean; + logger?: { + enabled?: boolean; + levels?: LogLevels[]; + }; +} +export interface OnDeviceValidation { + max_total_retries?: number; + threshold?: number; + applies_to?: ApplyFilter[]; +} +export interface SdkConfigurationValidations { + on_device?: SdkConfigurationValidationsOnDevice; +} +export interface SdkConfigurationValidationsOnDevice { + blur?: OnDeviceValidation; +} +export interface PassiveSignals { + enabled: boolean; +} +export interface DeviceIntelligence { + passive_signals: PassiveSignals; +} +export declare type SdkConfiguration = { + validations?: SdkConfigurationValidations; + experimental_features?: ExperimentalFeatures; + document_capture?: DocumentCapture; + motion_capture?: MotionCapture; + biometrics_liveness?: BiometricsLiveness; + sdk_features?: SdkFeatures; + device_intelligence?: DeviceIntelligence; + face_selfie_capture?: FaceSelfieCapture; + face_video_capture?: FaceVideoCapture; +}; +export declare type DocumentResponse = { + id: string; + side: string; + type: DocumentTypes; + variant: RequestedVariant; +}; +export declare type DocumentVideoResponse = { + id: string; + media_uuids: string[]; + variant: 'video'; +}; +export declare type FaceResponse = { + id: string; + variant: RequestedVariant; +}; +export declare type SdkResponse = { + document_front?: DocumentResponse; + document_back?: DocumentResponse; + document_video?: DocumentVideoResponse; + face?: FaceResponse; + data?: any; + poa?: DocumentResponse; +}; +export declare type SdkError = { + type: FatalErrorTypes; + message: string; +}; +export declare type UserExitCode = 'USER_CONSENT_DENIED'; +export declare type ServerRegions = 'US' | 'EU' | 'CA'; +export interface FunctionalConfigurations { + disableAnalytics?: boolean; + disableAnalyticsCookies?: boolean; + mobileFlow?: boolean; + roomId?: string; + tearDown?: boolean; + useMemoryHistory?: boolean; + useWorkflow?: boolean; +} +export interface SdkOptions extends FunctionalConfigurations { + onComplete?: (data: SdkResponse) => void; + onError?: (error: SdkError) => void; + onUserExit?: (data: UserExitCode) => void; + onModalRequestClose?: () => void; + token?: string; + useModal?: boolean; + isModalOpen?: boolean; + shouldCloseOnOverlayClick?: boolean; + containerId?: string; + containerEl?: HTMLElement | null; + language?: SupportedLanguages | LocaleConfig; + region?: ServerRegions; + smsNumberCountryCode?: string; + userDetails?: { + smsNumber?: string; + }; + steps?: Array; + enterpriseFeatures?: EnterpriseFeatures; + customUI?: UICustomizationOptions | null; + autoFocusOnInitialScreenTitle?: boolean; + crossDeviceClientIntroProductName?: string; + crossDeviceClientIntroProductLogoSrc?: string; + _crossDeviceLinkMethods?: Array | null; + overrideSdkConfiguration?: Partial; + workflowRunId?: string; + isWebView?: boolean; + disableWelcomeScreen?: boolean; +} +export declare type SdkHandle = { + containerId?: string; + options: NormalisedSdkOptions; + setOptions(options: SdkOptions): void; + /** + * @deprecated - use safeTeardown instead + */ + tearDown(): void; + safeTearDown(): Promise; +}; +export declare type SdkInitMethod = (options: SdkOptions) => SdkHandle; +export declare const init: SdkInitMethod; + +export {}; diff --git a/packages/api/src/hooks/index.ts b/packages/api/src/hooks/index.ts index 43f05fb8d067..80b6647d7c82 100644 --- a/packages/api/src/hooks/index.ts +++ b/packages/api/src/hooks/index.ts @@ -42,8 +42,8 @@ export { default as useSettings } from './useSettings'; export { default as useSortedMT5Accounts } from './useSortedMT5Accounts'; export { default as useTradingAccountsList } from './useTradingAccountsList'; export { default as useTradingPlatformInvestorPasswordChange } from './useTradingPlatformInvestorPasswordChange'; -export { default as useTradingPlatformInvestorPasswordReset } from './useTradingPlatformInvestorPasswordReset'; export { default as useTradingPlatformPasswordChange } from './useTradingPlatformPasswordChange'; +export { default as useTradingPlatformInvestorPasswordReset } from './useTradingPlatformInvestorPasswordReset'; export { default as useTransactions } from './useTransactions'; export { default as useTransferBetweenAccounts } from './useTransferBetweenAccounts'; export { default as useVerifyEmail } from './useVerifyEmail'; diff --git a/packages/api/types.ts b/packages/api/types.ts index 1666816f4ebb..5f2a0dcfb309 100644 --- a/packages/api/types.ts +++ b/packages/api/types.ts @@ -1547,7 +1547,10 @@ type TPrivateSocketEndpoints = { * Must be `1` */ notification_event: 1; - args: { + /** + * Event arguments. + */ + args?: { /** * (Optional- for `poi_documents_uploaded` only) An array of onfido document ids intended to be included in the poi check. */ diff --git a/packages/core/src/sass/app/_common/components/account-common.scss b/packages/core/src/sass/app/_common/components/account-common.scss index 0060ac4a7df7..dc81c134b420 100644 --- a/packages/core/src/sass/app/_common/components/account-common.scss +++ b/packages/core/src/sass/app/_common/components/account-common.scss @@ -4,6 +4,10 @@ align-items: center; gap: 1.6rem; + &__hidden { + display: none; + } + &-view_wrapper { position: relative; min-width: 32.8rem; @@ -22,8 +26,18 @@ @include mobile { padding: 1.6rem; - .onfido-sdk-ui-PageTitle-titleSpan { - font-size: 2rem; + & .onfido-sdk-ui-PageTitle { + &-titleWrapper { + margin: 2.4rem 0 0; + } + + &-title { + font-size: 2rem; + text-align: center; + } + &-titleSpan { + font-size: 2rem; + } } } @@ -39,12 +53,19 @@ &__info-message { position: absolute; - top: 2.4rem; - left: 25%; + top: 1.6rem; + left: 50%; z-index: 1; + transform: translate(-50%); + max-width: max-content; + width: 100% !important; + @include mobile { - top: 1.6rem; - left: 3.1rem; + margin-top: 1.6rem; + } + + .dc-hint-box { + margin-inline: 0.8rem; } } diff --git a/packages/hooks/src/__tests__/useNotificationEvent.spec.tsx b/packages/hooks/src/__tests__/useNotificationEvent.spec.tsx new file mode 100644 index 000000000000..c3d944eef157 --- /dev/null +++ b/packages/hooks/src/__tests__/useNotificationEvent.spec.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useMutation } from '@deriv/api'; +import APIProvider from '@deriv/api/src/APIProvider'; +import useNotificationEvent from '../useNotificationEvent'; + +type TNotificationPayload = Parameters['send']>[0]; + +jest.mock('@deriv/api', () => ({ + ...jest.requireActual('@deriv/api'), + useMutation: jest.fn(), +})); + +describe('useNotificationEvent', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return the notification event', async () => { + (useMutation as jest.Mock).mockReturnValueOnce({ + data: { + notification_event: 1, + }, + mutate: jest.fn(), + }); + const payload: TNotificationPayload = { + category: 'authentication', + event: 'poi_documents_uploaded', + args: { + documents: ['123', 'abc'], + }, + }; + + const wrapper = ({ children }: { children: JSX.Element }) => {children}; + const { result, waitFor } = renderHook(() => useNotificationEvent(), { wrapper }); + + result.current.send(payload); + + await waitFor(() => result.current.isSuccess, { timeout: 10000 }); + + expect(result.current.notification_event).toEqual(1); + }); +}); diff --git a/packages/hooks/src/__tests__/useServiceToken.spec.tsx b/packages/hooks/src/__tests__/useServiceToken.spec.tsx new file mode 100644 index 000000000000..f79ee2091962 --- /dev/null +++ b/packages/hooks/src/__tests__/useServiceToken.spec.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useQuery } from '@deriv/api'; +import APIProvider from '@deriv/api/src/APIProvider'; +import useServiceToken from '../useServiceToken'; + +jest.mock('@deriv/api', () => ({ + ...jest.requireActual('@deriv/api'), + useQuery: jest.fn(), +})); + +type TServiceTokenPayload = Parameters[0]; + +describe('useServiceToken', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return the service token', async () => { + (useQuery as jest.Mock).mockReturnValueOnce({ + msg_type: 'service_token', + service_token: { + onfido: { + token: '', + }, + }, + }); + const payload: TServiceTokenPayload = { + country: 'co', + service: 'onfido', + }; + const wrapper = ({ children }: { children: JSX.Element }) => {children}; + const { result, waitFor } = renderHook(() => useServiceToken(payload), { wrapper }); + + await waitFor(() => result.current.isSuccess, { timeout: 10000 }); + + expect(result.current.service_token).toMatchObject({ + onfido: { + token: '', + }, + }); + }); + + it('should return the error message', async () => { + const error_message = { + code: 'ApplicantError', + message: 'Cannot create applicant', + }; + (useQuery as jest.Mock).mockReturnValueOnce({ + error: error_message, + }); + + const payload: TServiceTokenPayload = { + country: 'id', + service: 'onfido', + }; + + const wrapper = ({ children }: { children: JSX.Element }) => {children}; + + const { result, waitFor } = renderHook(() => useServiceToken(payload), { wrapper }); + + await waitFor(() => result.current.isSuccess, { timeout: 10000 }); + + expect(result.current.error).toMatchObject(error_message); + }); +}); diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 14022e2379e0..cf5914b39609 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -62,4 +62,6 @@ export { default as useStatesList } from './useStatesList'; export { default as useP2PConfig } from './useP2PConfig'; export { default as useIsClientHighRiskForMT5 } from './useIsClientHighRiskForMT5'; export { default as useCFDCanGetMoreMT5Accounts } from './useCFDCanGetMoreMT5Accounts'; +export { default as useNotificationEvent } from './useNotificationEvent'; +export { default as useServiceToken } from './useServiceToken'; export { default as useFileUploader } from './useFileUploader'; diff --git a/packages/hooks/src/useNotificationEvent.ts b/packages/hooks/src/useNotificationEvent.ts new file mode 100644 index 000000000000..99691b0ca740 --- /dev/null +++ b/packages/hooks/src/useNotificationEvent.ts @@ -0,0 +1,33 @@ +import React from 'react'; +import { useMutation, useInvalidateQuery } from '@deriv/api'; +import { TSocketRequestPayload } from '@deriv/api/types'; + +type TNotificationEventPayload = TSocketRequestPayload<'notification_event'>['payload']; + +/** + * Hook to send notification event to the server + * @name useNotificationEvent + * @returns response, mutation function and other properties from useRequest hook + */ +const useNotificationEvent = () => { + const invalidate = useInvalidateQuery(); + const { data, mutate, ...rest } = useMutation('notification_event', { + onSuccess: () => { + invalidate('notification_event'); + }, + }); + + /** + * Function to send notification event to the server + * @param payload - notification event payload + */ + const send = React.useCallback((payload: TNotificationEventPayload) => mutate({ payload }), [mutate]); + + return { + notification_event: data?.notification_event, + send, + ...rest, + }; +}; + +export default useNotificationEvent; diff --git a/packages/hooks/src/useServiceToken.ts b/packages/hooks/src/useServiceToken.ts new file mode 100644 index 000000000000..6ac723631e17 --- /dev/null +++ b/packages/hooks/src/useServiceToken.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@deriv/api'; +import { TSocketRequestPayload } from '@deriv/api/types'; + +type TServiceTokenPayload = TSocketRequestPayload<'service_token'>['payload']; + +/** + * Hook to get the service token for using 3rd party SDK + * @name useServiceToken + * @param payload to be sent while making the API call + * @returns response and its status + */ +const useServiceToken = (payload: TServiceTokenPayload) => { + const { data, ...rest } = useQuery('service_token', { + payload, + options: { retry: 3, enabled: Boolean(payload) }, + }); + + return { + service_token: data?.service_token, + ...rest, + }; +}; + +export default useServiceToken;