diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 8f660e0b11023..7425227ce5803 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -413,13 +413,12 @@ const ROUTES = { getRoute: (backTo?: string) => getUrlWithBackToParam('settings/profile/contact-methods/new', backTo), }, SETTINGS_NEW_CONTACT_METHOD_CONFIRM_MAGIC_CODE: { - route: 'settings/profile/contact-methods/new/:newContactMethod/confirm-magic-code', - getRoute: (newContactMethod: string, backTo?: string) => { - const encodedMethod = encodeURIComponent(newContactMethod); + route: 'settings/profile/contact-methods/new/confirm-magic-code', + getRoute: (backTo?: string) => { // TODO this backTo comes from drilling it through settings screens // should be removed once https://github.com/Expensify/App/pull/72219 is resolved // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - return getUrlWithBackToParam(`settings/profile/contact-methods/new/${encodedMethod}/confirm-magic-code`, backTo); + return getUrlWithBackToParam(`settings/profile/contact-methods/new/confirm-magic-code`, backTo); }, }, SETTINGS_CONTACT_METHOD_VERIFY_ACCOUNT: { diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 32cb0c0c48a3f..5d66326edf3ba 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -247,6 +247,10 @@ function FormProvider({ touchedInputs.current[inputID] = true; } + if (hasServerError) { + return; + } + // Validate form and return early if any errors are found if (!isEmptyObject(onValidate(trimmedStringValues))) { return; @@ -258,7 +262,7 @@ function FormProvider({ } KeyboardUtils.dismiss().then(() => onSubmit(trimmedStringValues)); - }, [enabledWhenOffline, formState?.isLoading, inputValues, isLoading, network?.isOffline, onSubmit, onValidate, shouldTrimValues]), + }, [enabledWhenOffline, formState?.isLoading, inputValues, isLoading, network?.isOffline, onSubmit, onValidate, shouldTrimValues, hasServerError]), 1000, {leading: true, trailing: false}, ); diff --git a/src/libs/API/parameters/VerifyAddSecondaryLoginCodeParams.ts b/src/libs/API/parameters/VerifyAddSecondaryLoginCodeParams.ts new file mode 100644 index 0000000000000..7660f403a9c1c --- /dev/null +++ b/src/libs/API/parameters/VerifyAddSecondaryLoginCodeParams.ts @@ -0,0 +1,3 @@ +type VerifyAddSecondaryLoginCodeParams = {validateCode: string}; + +export default VerifyAddSecondaryLoginCodeParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index bece054a02378..596887a33083e 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -161,6 +161,7 @@ export type {default as OpenWorkspaceInvitePageParams} from './OpenWorkspaceInvi export type {default as OpenWorkspaceMembersPageParams} from './OpenWorkspaceMembersPageParams'; export type {default as OpenPolicyCategoriesPageParams} from './OpenPolicyCategoriesPageParams'; export type {default as OpenPolicyTagsPageParams} from './OpenPolicyTagsPageParams'; +export type {default as VerifyAddSecondaryLoginCodeParams} from './VerifyAddSecondaryLoginCodeParams'; export type {default as OpenDraftWorkspaceRequestParams} from './OpenDraftWorkspaceRequestParams'; export type {default as OpenDraftPerDiemExpenseParams} from './OpenDraftPerDiemExpenseParams'; export type {default as CreateWorkspaceFromIOUPaymentParams} from './CreateWorkspaceFromIOUPaymentParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 3eb92f0892e47..c6991b60016f2 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -92,6 +92,7 @@ const WRITE_COMMANDS = { UPDATE_PERSONAL_DETAILS_FOR_WALLET: 'UpdatePersonalDetailsForWallet', VERIFY_IDENTITY: 'VerifyIdentity', ACCEPT_WALLET_TERMS: 'AcceptWalletTerms', + VERIFY_ADD_SECONDARY_LOGIN_CODE: 'VerifyAddSecondaryLoginCode', ANSWER_QUESTIONS_FOR_WALLET: 'AnswerQuestionsForWallet', REQUEST_ACCOUNT_VALIDATION_LINK: 'RequestAccountValidationLink', REQUEST_NEW_VALIDATE_CODE: 'RequestNewValidateCode', @@ -573,6 +574,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_DISPLAY_NAME]: Parameters.UpdateDisplayNameParams; [WRITE_COMMANDS.UPDATE_LEGAL_NAME]: Parameters.UpdateLegalNameParams; [WRITE_COMMANDS.UPDATE_DATE_OF_BIRTH]: Parameters.UpdateDateOfBirthParams; + [WRITE_COMMANDS.VERIFY_ADD_SECONDARY_LOGIN_CODE]: Parameters.VerifyAddSecondaryLoginCodeParams; [WRITE_COMMANDS.UPDATE_PHONE_NUMBER]: Parameters.UpdatePhoneNumberParams; [WRITE_COMMANDS.UPDATE_POLICY_ADDRESS]: Parameters.UpdatePolicyAddressParams; [WRITE_COMMANDS.UPDATE_HOME_ADDRESS]: Parameters.UpdateHomeAddressParams; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 140fc942f576c..ad8d5eb74836d 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -24,6 +24,7 @@ import type { UpdateStatusParams, UpdateThemeParams, ValidateSecondaryLoginParams, + VerifyAddSecondaryLoginCodeParams, } from '@libs/API/parameters'; import type LockAccountParams from '@libs/API/parameters/LockAccountParams'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; @@ -50,6 +51,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {AppReview, BlockedFromConcierge, CustomStatusDraft, LoginList, Policy} from '@src/types/onyx'; import type Login from '@src/types/onyx/Login'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; import type {OnyxServerUpdate, OnyxUpdatesFromServer} from '@src/types/onyx/OnyxUpdatesFromServer'; import type OnyxPersonalDetails from '@src/types/onyx/PersonalDetails'; import type {Status} from '@src/types/onyx/PersonalDetails'; @@ -267,12 +269,19 @@ function deleteContactMethod(contactMethod: string, loginList: Record>((acc, method) => { + acc[method] = null; + return acc; + }, {}); + + Onyx.merge(ONYXKEYS.LOGIN_LIST, loginsToClear); } /** @@ -337,21 +346,11 @@ function clearPendingContactActionErrors() { }); } -/** - * When user adds a new contact method, they need to verify the magic code first - * So we add the temporary contact method to Onyx to use it later, after user verified magic code. - */ -function addPendingContactMethod(contactMethod: string) { - Onyx.set(ONYXKEYS.PENDING_CONTACT_ACTION, { - contactMethod, - }); -} - /** * Adds a secondary login to a user's account */ function addNewContactMethod(contactMethod: string, validateCode = '') { - const optimisticData: Array> = [ + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.LOGIN_LIST, @@ -370,27 +369,36 @@ function addNewContactMethod(contactMethod: string, validateCode = '') { key: ONYXKEYS.ACCOUNT, value: {isLoading: true}, }, - ]; - const successData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PENDING_CONTACT_ACTION, value: { - contactMethod: null, - validateCodeSent: null, - actionVerified: true, + contactMethod, + isLoading: true, errorFields: { actionVerified: null, }, }, }, + ]; + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PENDING_CONTACT_ACTION, + value: { + actionVerified: true, + isLoading: false, + isVerifiedValidateActionCode: false, + validateActionCode: null, + }, + }, { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, value: {isLoading: false}, }, ]; - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -401,6 +409,21 @@ function addNewContactMethod(contactMethod: string, validateCode = '') { key: ONYXKEYS.VALIDATE_ACTION_CODE, value: {validateCodeSent: null}, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PENDING_CONTACT_ACTION, + value: { + isLoading: false, + actionVerified: false, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.LOGIN_LIST, + value: { + [contactMethod]: null, + }, + }, ]; const parameters: AddNewContactMethodParams = {partnerUserID: contactMethod, validateCode}; @@ -1551,8 +1574,74 @@ function respondToProactiveAppReview(response: 'positive' | 'negative' | 'skip', API.write(WRITE_COMMANDS.RESPOND_TO_PROACTIVE_APP_REVIEW, params, {optimisticData, successData, failureData}); } +/** + * Verify the validation code for adding a secondary login within the contact method flow. + * + * This handles the complete flow for verifying a secondary login: + * 1. Verifies the validation code entered by the user + * 2. On success, stores the validate code to allow adding the new email + * 3. On failure, updates the state to reflect the failed verification + * + * @param validateCode - The validation code entered by the user + */ +function verifyAddSecondaryLoginCode(validateCode: string) { + resetValidateActionCodeSent(); + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PENDING_CONTACT_ACTION, + value: { + validateActionCode: validateCode, + isLoading: true, + errorFields: { + validateActionCode: null, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PENDING_CONTACT_ACTION, + value: { + isVerifiedValidateActionCode: true, + isLoading: false, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PENDING_CONTACT_ACTION, + value: { + isVerifiedValidateActionCode: false, + isLoading: false, + }, + }, + ]; + + const parameters: VerifyAddSecondaryLoginCodeParams = {validateCode}; + + API.write(WRITE_COMMANDS.VERIFY_ADD_SECONDARY_LOGIN_CODE, parameters, {optimisticData, successData, failureData}); +} + +function setServerErrorsOnForm(errors: Errors) { + Onyx.set(ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM, { + errors, + }); +} + +function updateIsVerifiedValidateActionCode(isVerifiedValidateActionCode: boolean) { + Onyx.merge(ONYXKEYS.PENDING_CONTACT_ACTION, { + isVerifiedValidateActionCode, + }); +} + export { closeAccount, + setServerErrorsOnForm, dismissReferralBanner, dismissASAPSubmitExplanation, resendValidateCode, @@ -1585,11 +1674,12 @@ export { clearUnvalidatedNewContactMethodAction, clearPendingContactActionErrors, requestValidateCodeAction, - addPendingContactMethod, clearValidateCodeActionError, setIsDebugModeEnabled, resetValidateActionCodeSent, lockAccount, requestUnlockAccount, respondToProactiveAppReview, + verifyAddSecondaryLoginCode, + updateIsVerifiedValidateActionCode, }; diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx index e294f48def91b..cda0ff2c5eb1d 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx @@ -324,7 +324,7 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) { errors={getLatestErrorField(loginData, 'addedLogin')} errorRowStyles={[themeStyles.mh5, themeStyles.mv3]} onDismiss={() => { - clearContactMethod(contactMethod); + clearContactMethod([contactMethod]); clearUnvalidatedNewContactMethodAction(); Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo)); }} diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx index b077d3e12ad43..0c1b18732b8a1 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx +++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx @@ -49,11 +49,12 @@ function ContactMethodsPage({route}: ContactMethodsPageProps) { } if (!isUserValidated) { - Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHOD_VERIFY_ACCOUNT.getRoute(Navigation.getActiveRoute(), ROUTES.SETTINGS_NEW_CONTACT_METHOD.getRoute(navigateBackTo))); + Navigation.navigate( + ROUTES.SETTINGS_CONTACT_METHOD_VERIFY_ACCOUNT.getRoute(Navigation.getActiveRoute(), ROUTES.SETTINGS_NEW_CONTACT_METHOD_CONFIRM_MAGIC_CODE.getRoute(navigateBackTo)), + ); return; } - - Navigation.navigate(ROUTES.SETTINGS_NEW_CONTACT_METHOD.getRoute(navigateBackTo)); + Navigation.navigate(ROUTES.SETTINGS_NEW_CONTACT_METHOD_CONFIRM_MAGIC_CODE.getRoute(navigateBackTo)); }, [navigateBackTo, isActingAsDelegate, showDelegateNoAccessModal, isAccountLocked, isUserValidated, showLockedAccountModal]); return ( diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodConfirmMagicCodePage.tsx b/src/pages/settings/Profile/Contacts/NewContactMethodConfirmMagicCodePage.tsx index d5ba5686edf7e..1080f635726da 100644 --- a/src/pages/settings/Profile/Contacts/NewContactMethodConfirmMagicCodePage.tsx +++ b/src/pages/settings/Profile/Contacts/NewContactMethodConfirmMagicCodePage.tsx @@ -1,19 +1,16 @@ -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import ValidateCodeActionContent from '@components/ValidateCodeActionModal/ValidateCodeActionContent'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePrevious from '@hooks/usePrevious'; -import {addNewContactMethod as addNewContactMethodUser, clearContactMethod, clearUnvalidatedNewContactMethodAction, requestValidateCodeAction} from '@libs/actions/User'; +import {clearPendingContactActionErrors, requestValidateCodeAction, verifyAddSecondaryLoginCode} from '@libs/actions/User'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {getContactMethod} from '@libs/UserUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import getDecodedContactMethodFromUriParam from './utils'; type NewContactMethodConfirmMagicCodePageProps = PlatformStackScreenProps; @@ -23,29 +20,15 @@ function NewContactMethodConfirmMagicCodePage({route}: NewContactMethodConfirmMa const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: false}); const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false}); const contactMethod = getContactMethod(account?.primaryLogin, session?.email); - const newContactMethod = useMemo(() => getDecodedContactMethodFromUriParam(route.params.newContactMethod), [route.params.newContactMethod]); - const [pendingContactAction] = useOnyx(ONYXKEYS.PENDING_CONTACT_ACTION, {canBeMissing: false}); - const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST, {canBeMissing: true}); - - const prevPendingContactAction = usePrevious(pendingContactAction); - const loginData = loginList?.[pendingContactAction?.contactMethod ?? newContactMethod]; - const validateLoginError = getLatestErrorField(loginData, 'addedLogin'); - - const addNewContactMethod = useCallback( - (magicCode: string) => { - addNewContactMethodUser(addSMSDomainIfPhoneNumber(newContactMethod), magicCode); - }, - [newContactMethod], - ); + const validateCodeError = getLatestErrorField(pendingContactAction, 'addedLogin'); useEffect(() => { - if (!pendingContactAction?.actionVerified) { + if (!pendingContactAction?.isVerifiedValidateActionCode) { return; } - clearUnvalidatedNewContactMethodAction(); - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.getRoute(addSMSDomainIfPhoneNumber(newContactMethod), navigateBackTo, true)); - }, [navigateBackTo, newContactMethod, pendingContactAction?.actionVerified, prevPendingContactAction?.contactMethod]); + Navigation.navigate(ROUTES.SETTINGS_NEW_CONTACT_METHOD.getRoute(navigateBackTo)); + }, [navigateBackTo, pendingContactAction?.isVerifiedValidateActionCode]); return ( requestValidateCodeAction()} descriptionPrimary={translate('contacts.enterMagicCode', {contactMethod})} validateCodeActionErrorField="addedLogin" - validateError={validateLoginError} - handleSubmitForm={addNewContactMethod} + validateError={validateCodeError} + handleSubmitForm={verifyAddSecondaryLoginCode} clearError={() => { - if (!pendingContactAction?.contactMethod) { - return; - } - clearContactMethod(newContactMethod); + clearPendingContactActionErrors(); }} onClose={() => { - if (!pendingContactAction?.contactMethod) { - return; - } - clearContactMethod(newContactMethod); - clearUnvalidatedNewContactMethodAction(); - Navigation.goBack(ROUTES.SETTINGS_NEW_CONTACT_METHOD.getRoute(navigateBackTo)); + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(navigateBackTo)); }} + isLoading={pendingContactAction?.isLoading} /> ); } diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx index 0ea7de5961f08..3ffe303806b45 100644 --- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx +++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx @@ -1,5 +1,5 @@ import {Str} from 'expensify-common'; -import React, {useCallback, useRef} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import FormProvider from '@components/Form/FormProvider'; @@ -13,18 +13,20 @@ import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {addErrorMessage} from '@libs/ErrorUtils'; +import {addErrorMessage, getLatestErrorField} from '@libs/ErrorUtils'; import {getPhoneLogin, validateNumber} from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {addPendingContactMethod, resetValidateActionCodeSent} from '@userActions/User'; +import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; +import {addNewContactMethod, clearContactMethod, clearUnvalidatedNewContactMethodAction, setServerErrorsOnForm, updateIsVerifiedValidateActionCode} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/NewContactMethodForm'; import type {Errors} from '@src/types/onyx/OnyxCommon'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; type NewContactMethodPageProps = PlatformStackScreenProps; @@ -34,19 +36,47 @@ function NewContactMethodPage({route}: NewContactMethodPageProps) { const loginInputRef = useRef(null); const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST, {canBeMissing: true}); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); - + const [pendingContactAction] = useOnyx(ONYXKEYS.PENDING_CONTACT_ACTION, {canBeMissing: true}); + const [validateActionCode] = useOnyx(ONYXKEYS.VALIDATE_ACTION_CODE, {canBeMissing: true}); const navigateBackTo = route?.params?.backTo; + const loginData = pendingContactAction?.contactMethod ? loginList?.[pendingContactAction?.contactMethod] : undefined; + const validateLoginError = getLatestErrorField(loginData, 'addedLogin'); + const validateActionCodeError = getLatestErrorField(validateActionCode, 'addedLogin'); - const handleValidateMagicCode = useCallback( + useEffect(() => { + updateIsVerifiedValidateActionCode(false); + }, []); + useEffect(() => { + let error = validateLoginError; + if (isEmptyObject(error)) { + error = validateActionCodeError; + } + setServerErrorsOnForm(error); + }, [validateLoginError, validateActionCodeError]); + useEffect(() => { + return () => { + if (!loginList) { + return; + } + const removedLogin: string[] = []; + for (const login of Object.keys(loginList)) { + const error = getLatestErrorField(loginList?.[login], 'addedLogin'); + if (!isEmptyObject(error)) { + removedLogin.push(login); + } + } + clearContactMethod(removedLogin); + }; + }, [loginList]); + + const handleAddSecondaryLogin = useCallback( (values: FormOnyxValues) => { const phoneLogin = getPhoneLogin(values.phoneOrEmail, countryCode); const validateIfNumber = validateNumber(phoneLogin); const submitDetail = (validateIfNumber || values.phoneOrEmail).trim().toLowerCase(); - resetValidateActionCodeSent(); - addPendingContactMethod(submitDetail); - Navigation.navigate(ROUTES.SETTINGS_NEW_CONTACT_METHOD_CONFIRM_MAGIC_CODE.getRoute(submitDetail, navigateBackTo)); + addNewContactMethod(submitDetail, pendingContactAction?.validateActionCode ?? ''); }, - [navigateBackTo, countryCode], + [countryCode, pendingContactAction?.validateActionCode], ); const validate = useCallback( @@ -83,6 +113,14 @@ function NewContactMethodPage({route}: NewContactMethodPageProps) { Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(navigateBackTo)); }, [navigateBackTo]); + useEffect(() => { + if (!pendingContactAction?.actionVerified || !pendingContactAction?.contactMethod) { + return; + } + Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.getRoute(addSMSDomainIfPhoneNumber(pendingContactAction?.contactMethod), navigateBackTo, true)); + clearUnvalidatedNewContactMethodAction(); + }, [pendingContactAction?.actionVerified, pendingContactAction?.contactMethod, navigateBackTo]); + return ( loginInputRef.current?.focus()} @@ -99,10 +137,10 @@ function NewContactMethodPage({route}: NewContactMethodPageProps) { {translate('common.pleaseEnterEmailOrPhoneNumber')} diff --git a/src/types/onyx/PendingContactAction.ts b/src/types/onyx/PendingContactAction.ts index c2c75c407c4d5..3a3394463f074 100644 --- a/src/types/onyx/PendingContactAction.ts +++ b/src/types/onyx/PendingContactAction.ts @@ -14,6 +14,15 @@ type ContactAction = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether the action is validated */ actionVerified?: boolean; + + /** Validation action code for adding secondary login */ + validateActionCode?: string; + + /** Whether the action is verified */ + isVerifiedValidateActionCode?: boolean; + + /** Whether the action is loading */ + isLoading?: boolean; }, 'actionVerified' >; diff --git a/tests/actions/UserTest.ts b/tests/actions/UserTest.ts new file mode 100644 index 0000000000000..2070ee8a3fcbf --- /dev/null +++ b/tests/actions/UserTest.ts @@ -0,0 +1,581 @@ +/* eslint-disable no-restricted-syntax */ +import Onyx from 'react-native-onyx'; +import type {OnyxMergeInput} from 'react-native-onyx'; +import * as API from '@libs/API'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import type {OnyxKey} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import * as UserActions from '../../src/libs/actions/User'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.mock('@libs/API'); +const mockAPI = API as jest.Mocked; + +describe('actions/User', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + return Onyx.clear().then(waitForBatchedUpdates); + }); + + describe('clearContactMethod', () => { + it('should return early when contactMethods array is empty', async () => { + // Given an empty array + const contactMethods: string[] = []; + + // When clearContactMethod is called + UserActions.clearContactMethod(contactMethods); + await waitForBatchedUpdates(); + + // Then LOGIN_LIST should remain unchanged (null/undefined) + const loginList = await new Promise | null>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.LOGIN_LIST, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value ?? null); + }, + }); + }); + + expect(loginList).toBeNull(); + }); + + it('should clear a single contact method from LOGIN_LIST', async () => { + // Given a login list with a contact method + const contactMethod = 'test@example.com'; + const initialLoginList = { + [contactMethod]: { + partnerUserID: contactMethod, + validatedDate: '2024-01-01', + }, + }; + + await Onyx.merge(ONYXKEYS.LOGIN_LIST, initialLoginList); + await waitForBatchedUpdates(); + + // When clearContactMethod is called with that contact method + UserActions.clearContactMethod([contactMethod]); + await waitForBatchedUpdates(); + + // Then the contact method should be set to null in LOGIN_LIST + const loginList = await new Promise | null>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.LOGIN_LIST, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value ?? null); + }, + }); + }); + + expect(loginList).toEqual({}); + }); + + it('should clear multiple contact methods from LOGIN_LIST', async () => { + // Given a login list with multiple contact methods + const contactMethod1 = 'test1@example.com'; + const contactMethod2 = 'test2@example.com'; + const contactMethod3 = 'test3@example.com'; + const initialLoginList = { + [contactMethod1]: { + partnerUserID: contactMethod1, + validatedDate: '2024-01-01', + }, + [contactMethod2]: { + partnerUserID: contactMethod2, + validatedDate: '2024-01-02', + }, + [contactMethod3]: { + partnerUserID: contactMethod3, + validatedDate: '2024-01-03', + }, + }; + + await Onyx.merge(ONYXKEYS.LOGIN_LIST, initialLoginList); + await waitForBatchedUpdates(); + + // When clearContactMethod is called with multiple contact methods + UserActions.clearContactMethod([contactMethod1, contactMethod3]); + await waitForBatchedUpdates(); + + // Then the specified contact methods should be set to null, while others remain unchanged + const loginList = await new Promise | null>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.LOGIN_LIST, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value ?? null); + }, + }); + }); + + expect(loginList).toEqual({ + [contactMethod2]: { + partnerUserID: contactMethod2, + validatedDate: '2024-01-02', + }, + }); + }); + + it('should handle clearing contact methods that do not exist in LOGIN_LIST', async () => { + // Given a login list with some contact methods + const existingContactMethod = 'existing@example.com'; + const nonExistentContactMethod = 'nonexistent@example.com'; + const initialLoginList = { + [existingContactMethod]: { + partnerUserID: existingContactMethod, + validatedDate: '2024-01-01', + }, + }; + + await Onyx.merge(ONYXKEYS.LOGIN_LIST, initialLoginList); + await waitForBatchedUpdates(); + + // When clearContactMethod is called with both existing and non-existent contact methods + UserActions.clearContactMethod([existingContactMethod, nonExistentContactMethod]); + await waitForBatchedUpdates(); + + // Then both should be set to null in LOGIN_LIST + const loginList = await new Promise | null>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.LOGIN_LIST, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value ?? null); + }, + }); + }); + + expect(loginList).toEqual({}); + }); + }); + + describe('verifyAddSecondaryLoginCode', () => { + it('should call API.write with correct parameters and reset validateCodeSent', async () => { + // Given a validate code + const validateCode = '123456'; + + // Set initial state for VALIDATE_ACTION_CODE + await Onyx.merge(ONYXKEYS.VALIDATE_ACTION_CODE, { + validateCodeSent: true, + }); + await waitForBatchedUpdates(); + + // When verifyAddSecondaryLoginCode is called + UserActions.verifyAddSecondaryLoginCode(validateCode); + await waitForBatchedUpdates(); + + // Then API.write should be called with correct parameters + expect(mockAPI.write).toHaveBeenCalledWith( + WRITE_COMMANDS.VERIFY_ADD_SECONDARY_LOGIN_CODE, + {validateCode}, + expect.objectContaining({ + optimisticData: expect.any(Array) as Array<{key: string; value: unknown}>, + successData: expect.any(Array) as Array<{key: string; value: unknown}>, + failureData: expect.any(Array) as Array<{key: string; value: unknown}>, + }), + ); + + // Verify validateCodeSent is reset to false + const validateActionCode = await new Promise<{validateCodeSent?: boolean} | null>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.VALIDATE_ACTION_CODE, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value ?? null); + }, + }); + }); + + expect(validateActionCode?.validateCodeSent).toBe(false); + }); + + it('should apply optimisticData correctly', async () => { + // Given a validate code + const validateCode = '123456'; + + // When verifyAddSecondaryLoginCode is called + UserActions.verifyAddSecondaryLoginCode(validateCode); + await waitForBatchedUpdates(); + + // Then verify the optimisticData structure + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const calls = (mockAPI.write as jest.Mock).mock.calls; + const [, , onyxData] = calls.at(0) as [unknown, unknown, {optimisticData?: Array<{key: string; value: unknown}>}]; + const optimisticData = onyxData.optimisticData ?? []; + + expect(optimisticData).toHaveLength(1); + expect(optimisticData.at(0)).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PENDING_CONTACT_ACTION, + value: { + validateActionCode: validateCode, + isLoading: true, + errorFields: { + validateActionCode: null, + }, + }, + }); + }); + + it('should have correct successData structure', async () => { + // Given a validate code + const validateCode = '123456'; + + // When verifyAddSecondaryLoginCode is called + UserActions.verifyAddSecondaryLoginCode(validateCode); + await waitForBatchedUpdates(); + + // Then verify the successData structure + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const calls = (mockAPI.write as jest.Mock).mock.calls; + const [, , onyxData] = calls.at(0) as [unknown, unknown, {successData?: Array<{key: string; value: unknown}>}]; + const successData = onyxData.successData ?? []; + + expect(successData).toHaveLength(1); + expect(successData.at(0)).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PENDING_CONTACT_ACTION, + value: { + isVerifiedValidateActionCode: true, + isLoading: false, + }, + }); + }); + + it('should have correct failureData structure', async () => { + // Given a validate code + const validateCode = '123456'; + + // When verifyAddSecondaryLoginCode is called + UserActions.verifyAddSecondaryLoginCode(validateCode); + await waitForBatchedUpdates(); + + // Then verify the failureData structure + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const calls = (mockAPI.write as jest.Mock).mock.calls; + const [, , onyxData] = calls.at(0) as [unknown, unknown, {failureData?: Array<{key: string; value: unknown}>}]; + const failureData = onyxData.failureData ?? []; + + expect(failureData).toHaveLength(1); + expect(failureData.at(0)).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PENDING_CONTACT_ACTION, + value: { + isVerifiedValidateActionCode: false, + isLoading: false, + }, + }); + }); + + it('should apply optimisticData to Onyx when API.write applies it', async () => { + // Given a validate code and mock API.write that applies optimisticData + const validateCode = '123456'; + + // Mock API.write to apply optimisticData + // eslint-disable-next-line rulesdir/no-multiple-api-calls + (mockAPI.write as jest.Mock).mockImplementation( + ( + command: unknown, + params: unknown, + options?: { + optimisticData?: Array<{onyxMethod: typeof Onyx.METHOD.MERGE; key: string; value: unknown}>; + }, + ) => { + if (options?.optimisticData) { + for (const update of options.optimisticData) { + if (update.onyxMethod === Onyx.METHOD.MERGE) { + Onyx.merge(update.key as OnyxKey, update.value as OnyxMergeInput); + } + } + } + return Promise.resolve(); + }, + ); + + // When verifyAddSecondaryLoginCode is called + UserActions.verifyAddSecondaryLoginCode(validateCode); + await waitForBatchedUpdates(); + + // Then PENDING_CONTACT_ACTION should be updated with optimistic data + const pendingContactAction = await new Promise | null>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PENDING_CONTACT_ACTION, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value ?? null); + }, + }); + }); + + expect(pendingContactAction).toEqual({ + validateActionCode: validateCode, + isLoading: true, + errorFields: {}, + }); + }); + }); + + describe('addNewContactMethod', () => { + it('should call API.write with correct parameters when validateCode is provided', async () => { + // Given a contact method and validate code + const contactMethod = 'test@example.com'; + const validateCode = '123456'; + + // When addNewContactMethod is called + UserActions.addNewContactMethod(contactMethod, validateCode); + await waitForBatchedUpdates(); + + // Then API.write should be called with correct parameters + expect(mockAPI.write).toHaveBeenCalledWith( + WRITE_COMMANDS.ADD_NEW_CONTACT_METHOD, + {partnerUserID: contactMethod, validateCode}, + expect.objectContaining({ + optimisticData: expect.any(Array) as Array<{key: string; value: unknown}>, + successData: expect.any(Array) as Array<{key: string; value: unknown}>, + failureData: expect.any(Array) as Array<{key: string; value: unknown}>, + }), + ); + }); + + it('should call API.write with empty validateCode when validateCode is not provided', async () => { + // Given a contact method without validate code + const contactMethod = 'test@example.com'; + + // When addNewContactMethod is called without validateCode + UserActions.addNewContactMethod(contactMethod); + await waitForBatchedUpdates(); + + // Then API.write should be called with empty validateCode + expect(mockAPI.write).toHaveBeenCalledWith( + WRITE_COMMANDS.ADD_NEW_CONTACT_METHOD, + {partnerUserID: contactMethod, validateCode: ''}, + expect.objectContaining({ + optimisticData: expect.any(Array) as Array<{key: string; value: unknown}>, + successData: expect.any(Array) as Array<{key: string; value: unknown}>, + failureData: expect.any(Array) as Array<{key: string; value: unknown}>, + }), + ); + }); + + it('should have correct optimisticData structure', async () => { + // Given a contact method + const contactMethod = 'test@example.com'; + + // When addNewContactMethod is called + UserActions.addNewContactMethod(contactMethod); + await waitForBatchedUpdates(); + + // Then verify the optimisticData structure + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const calls = (mockAPI.write as jest.Mock).mock.calls; + const [, , onyxData] = calls.at(0) as [unknown, unknown, {optimisticData?: Array<{key: string; value: unknown}>}]; + const optimisticData = onyxData.optimisticData ?? []; + + expect(optimisticData).toHaveLength(3); + + // Verify LOGIN_LIST update + const loginListUpdate = optimisticData.find((update) => update.key === ONYXKEYS.LOGIN_LIST); + expect(loginListUpdate).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.LOGIN_LIST, + value: { + [contactMethod]: { + partnerUserID: contactMethod, + validatedDate: '', + errorFields: { + addedLogin: null, + }, + }, + }, + }); + + // Verify ACCOUNT update + const accountUpdate = optimisticData.find((update) => update.key === ONYXKEYS.ACCOUNT); + expect(accountUpdate).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: {isLoading: true}, + }); + + // Verify PENDING_CONTACT_ACTION update + const pendingContactActionUpdate = optimisticData.find((update) => update.key === ONYXKEYS.PENDING_CONTACT_ACTION); + expect(pendingContactActionUpdate).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PENDING_CONTACT_ACTION, + value: { + contactMethod, + isLoading: true, + errorFields: { + actionVerified: null, + }, + }, + }); + }); + + it('should have correct successData structure', async () => { + // Given a contact method + const contactMethod = 'test@example.com'; + + // When addNewContactMethod is called + UserActions.addNewContactMethod(contactMethod); + await waitForBatchedUpdates(); + + // Then verify the successData structure + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const calls = (mockAPI.write as jest.Mock).mock.calls; + const [, , onyxData] = calls.at(0) as [unknown, unknown, {successData?: Array<{key: string; value: unknown}>}]; + const successData = onyxData.successData ?? []; + + expect(successData).toHaveLength(2); + + // Verify PENDING_CONTACT_ACTION success update + const pendingContactActionUpdate = successData.find((update) => update.key === ONYXKEYS.PENDING_CONTACT_ACTION); + expect(pendingContactActionUpdate).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PENDING_CONTACT_ACTION, + value: { + actionVerified: true, + isLoading: false, + isVerifiedValidateActionCode: false, + validateActionCode: null, + }, + }); + + // Verify ACCOUNT success update + const accountUpdate = successData.find((update) => update.key === ONYXKEYS.ACCOUNT); + expect(accountUpdate).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: {isLoading: false}, + }); + }); + + it('should have correct failureData structure', async () => { + // Given a contact method + const contactMethod = 'test@example.com'; + + // When addNewContactMethod is called + UserActions.addNewContactMethod(contactMethod); + await waitForBatchedUpdates(); + + // Then verify the failureData structure + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const calls = (mockAPI.write as jest.Mock).mock.calls; + const [, , onyxData] = calls.at(0) as [unknown, unknown, {failureData?: Array<{key: string; value: unknown}>}]; + const failureData = onyxData.failureData ?? []; + + expect(failureData).toHaveLength(4); + + // Verify ACCOUNT failure update + const accountUpdate = failureData.find((update) => update.key === ONYXKEYS.ACCOUNT); + expect(accountUpdate).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: {isLoading: false}, + }); + + // Verify VALIDATE_ACTION_CODE failure update + const validateActionCodeUpdate = failureData.find((update) => update.key === ONYXKEYS.VALIDATE_ACTION_CODE); + expect(validateActionCodeUpdate).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.VALIDATE_ACTION_CODE, + value: {validateCodeSent: null}, + }); + + // Verify PENDING_CONTACT_ACTION failure update + const pendingContactActionUpdate = failureData.find((update) => update.key === ONYXKEYS.PENDING_CONTACT_ACTION); + expect(pendingContactActionUpdate).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PENDING_CONTACT_ACTION, + value: { + isLoading: false, + actionVerified: false, + }, + }); + }); + + it('should apply optimisticData to Onyx when API.write applies it', async () => { + // Given a contact method and mock API.write that applies optimisticData + const contactMethod = 'test@example.com'; + + // Mock API.write to apply optimisticData + // eslint-disable-next-line rulesdir/no-multiple-api-calls + (mockAPI.write as jest.Mock).mockImplementation( + ( + command: unknown, + params: unknown, + options?: { + optimisticData?: Array<{onyxMethod: typeof Onyx.METHOD.MERGE; key: string; value: unknown}>; + }, + ) => { + if (options?.optimisticData) { + for (const update of options.optimisticData) { + if (update.onyxMethod === Onyx.METHOD.MERGE) { + Onyx.merge(update.key as OnyxKey, update.value as OnyxMergeInput); + } + } + } + return Promise.resolve(); + }, + ); + + // When addNewContactMethod is called + UserActions.addNewContactMethod(contactMethod); + await waitForBatchedUpdates(); + + // Then LOGIN_LIST should be updated with the new contact method + const loginList = await new Promise | null>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.LOGIN_LIST, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value ?? null); + }, + }); + }); + + expect(loginList?.[contactMethod]).toEqual({ + partnerUserID: contactMethod, + validatedDate: '', + errorFields: {}, + }); + + // Then ACCOUNT should have isLoading: true + const account = await new Promise<{isLoading?: boolean} | null>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value ?? null); + }, + }); + }); + + expect(account?.isLoading).toBe(true); + + // Then PENDING_CONTACT_ACTION should be updated + const pendingContactAction = await new Promise | null>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PENDING_CONTACT_ACTION, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value ?? null); + }, + }); + }); + + expect(pendingContactAction).toEqual({ + contactMethod, + isLoading: true, + errorFields: {}, + }); + }); + }); +});