diff --git a/src/ROUTES.ts b/src/ROUTES.ts index cebb22519f4e..5634cb6066d7 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -12,6 +12,7 @@ import type {IOURequestType} from './libs/actions/IOU'; import Log from './libs/Log'; import type {RootNavigatorParamList} from './libs/Navigation/types'; import type {ReimbursementAccountStepToOpen} from './libs/ReimbursementAccountUtils'; +import {getUrlWithParams} from './libs/Url'; import SCREENS from './SCREENS'; import type {Screen} from './SCREENS'; import type {ExitReason} from './types/form/ExitSurveyReasonForm'; @@ -345,7 +346,10 @@ const ROUTES = { // eslint-disable-next-line no-restricted-syntax -- Legacy route generation getUrlWithBackToParam(forwardTo ? `settings/profile/contact-methods/verify?forwardTo=${encodeURIComponent(forwardTo)}` : 'settings/profile/contact-methods/verify', backTo), }, - + SETTINGS_2FA_VERIFY_ACCOUNT: { + route: `settings/security/two-factor-auth/${VERIFY_ACCOUNT}`, + getRoute: (params: {backTo?: string; forwardTo?: string} = {}) => getUrlWithParams(`settings/security/two-factor-auth/${VERIFY_ACCOUNT}`, params), + }, SETTINGS_2FA_ROOT: { route: 'settings/security/two-factor-auth', getRoute: (backTo?: string, forwardTo?: string) => diff --git a/src/SCREENS.ts b/src/SCREENS.ts index df1c06c86a67..bf7a3d41bcf8 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -185,6 +185,7 @@ const SCREENS = { TWO_FACTOR_AUTH: { ROOT: 'Settings_TwoFactorAuth_Root', VERIFY: 'Settings_TwoFactorAuth_Verify', + VERIFY_ACCOUNT: 'Settings_TwoFactorAuth_VerifyAccount', SUCCESS: 'Settings_TwoFactorAuth_Success', DISABLED: 'Settings_TwoFactorAuth_Disabled', DISABLE: 'Settings_TwoFactorAuth_Disable', diff --git a/src/components/ConnectToXeroFlow/index.native.tsx b/src/components/ConnectToXeroFlow/index.native.tsx index 30c1bd1bbce1..6635aa0fb98b 100644 --- a/src/components/ConnectToXeroFlow/index.native.tsx +++ b/src/components/ConnectToXeroFlow/index.native.tsx @@ -24,6 +24,7 @@ function ConnectToXeroFlow({policyID}: ConnectToXeroFlowProps) { const authToken = session?.authToken ?? null; const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: false}); + const isUserValidated = account?.validated; const is2FAEnabled = account?.requiresTwoFactorAuth ?? false; const renderLoading = () => ; @@ -44,7 +45,20 @@ function ConnectToXeroFlow({policyID}: ConnectToXeroFlowProps) { { setIsRequire2FAModalOpen(false); - close(() => Navigation.navigate(ROUTES.SETTINGS_2FA_ROOT.getRoute(ROUTES.POLICY_ACCOUNTING.getRoute(policyID), getXeroSetupLink(policyID)))); + close(() => { + const backTo = ROUTES.POLICY_ACCOUNTING.getRoute(policyID); + const validatedUserForwardTo = getXeroSetupLink(policyID); + if (isUserValidated) { + Navigation.navigate(ROUTES.SETTINGS_2FA_ROOT.getRoute(backTo, validatedUserForwardTo)); + return; + } + Navigation.navigate( + ROUTES.SETTINGS_2FA_VERIFY_ACCOUNT.getRoute({ + backTo, + forwardTo: ROUTES.SETTINGS_2FA_ROOT.getRoute(backTo, validatedUserForwardTo), + }), + ); + }); }} onCancel={() => setIsRequire2FAModalOpen(false)} isVisible={isRequire2FAModalOpen} diff --git a/src/components/ConnectToXeroFlow/index.tsx b/src/components/ConnectToXeroFlow/index.tsx index 8abfc84c8ba1..061648267a84 100644 --- a/src/components/ConnectToXeroFlow/index.tsx +++ b/src/components/ConnectToXeroFlow/index.tsx @@ -17,6 +17,7 @@ function ConnectToXeroFlow({policyID}: ConnectToXeroFlowProps) { const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: false}); const is2FAEnabled = account?.requiresTwoFactorAuth; + const isUserValidated = account?.validated; const [isRequire2FAModalOpen, setIsRequire2FAModalOpen] = useState(false); @@ -34,7 +35,20 @@ function ConnectToXeroFlow({policyID}: ConnectToXeroFlowProps) { { setIsRequire2FAModalOpen(false); - close(() => Navigation.navigate(ROUTES.SETTINGS_2FA_ROOT.getRoute(ROUTES.POLICY_ACCOUNTING.getRoute(policyID), getXeroSetupLink(policyID)))); + close(() => { + const backTo = ROUTES.POLICY_ACCOUNTING.getRoute(policyID); + const validatedUserForwardTo = getXeroSetupLink(policyID); + if (isUserValidated) { + Navigation.navigate(ROUTES.SETTINGS_2FA_ROOT.getRoute(backTo, validatedUserForwardTo)); + return; + } + Navigation.navigate( + ROUTES.SETTINGS_2FA_VERIFY_ACCOUNT.getRoute({ + backTo, + forwardTo: ROUTES.SETTINGS_2FA_ROOT.getRoute(backTo, validatedUserForwardTo), + }), + ); + }); }} onCancel={() => { setIsRequire2FAModalOpen(false); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index d9b74656e0a7..3be57731c25b 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -740,6 +740,7 @@ const SettingsModalStackNavigator = createModalStackNavigator({ [SCREENS.TWO_FACTOR_AUTH.ROOT]: () => require('../../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default, [SCREENS.TWO_FACTOR_AUTH.VERIFY]: () => require('../../../../pages/settings/Security/TwoFactorAuth/VerifyPage').default, + [SCREENS.TWO_FACTOR_AUTH.VERIFY_ACCOUNT]: () => require('../../../../pages/settings/Security/TwoFactorAuth/VerifyAccountPage').default, [SCREENS.TWO_FACTOR_AUTH.DISABLED]: () => require('../../../../pages/settings/Security/TwoFactorAuth/DisabledPage').default, [SCREENS.TWO_FACTOR_AUTH.DISABLE]: () => require('../../../../pages/settings/Security/TwoFactorAuth/DisablePage').default, [SCREENS.TWO_FACTOR_AUTH.SUCCESS]: () => require('../../../../pages/settings/Security/TwoFactorAuth/SuccessPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index 0d5857426e88..2db2de009b96 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -43,6 +43,7 @@ const SETTINGS_TO_RHP: Partial['config'] = { }, [SCREENS.RIGHT_MODAL.TWO_FACTOR_AUTH]: { screens: { + [SCREENS.TWO_FACTOR_AUTH.VERIFY_ACCOUNT]: { + path: ROUTES.SETTINGS_2FA_VERIFY_ACCOUNT.route, + exact: true, + }, [SCREENS.TWO_FACTOR_AUTH.ROOT]: { path: ROUTES.SETTINGS_2FA_ROOT.route, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index db5650319d43..b27e2b37e2cb 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1148,6 +1148,10 @@ type TwoFactorAuthNavigatorParamList = { backTo?: Routes; forwardTo?: string; }; + [SCREENS.TWO_FACTOR_AUTH.VERIFY_ACCOUNT]: { + backTo?: Routes; + forwardTo?: Routes; + }; [SCREENS.TWO_FACTOR_AUTH.VERIFY]: { backTo?: Routes; forwardTo?: string; diff --git a/src/libs/Url.ts b/src/libs/Url.ts index d94815ad5922..9a416ff35c78 100644 --- a/src/libs/Url.ts +++ b/src/libs/Url.ts @@ -70,4 +70,27 @@ function getSearchParamFromUrl(currentUrl: string, param: string) { return currentUrl ? new URL(currentUrl).searchParams.get(param) : null; } -export {getSearchParamFromUrl, hasSameExpensifyOrigin, getPathFromURL, appendParam, hasURL, addLeadingForwardSlash, extractUrlDomain}; +type UrlWithParams = `${TBase}${'' | `?${string}` | `&${string}`}`; +type UrlParams = {backTo?: string; forwardTo?: string} & Record; +/** + * Generate a URL with properly encoded query parameters. + * + * @param baseUrl - The base URL. + * @param params - Object containing key-value pairs for query parameters. + * @returns A URL string with encoded query parameters. + */ +function getUrlWithParams(baseUrl: TBase, params: TParams): UrlWithParams { + const [path, existingQuery] = baseUrl.split('?', 2); + const searchParams = new URLSearchParams(existingQuery || ''); + + for (const [key, value] of Object.entries(params)) { + if (value) { + searchParams.set(key, String(value)); + } + } + + const queryString = searchParams.toString(); + return (queryString ? `${path}?${queryString}` : path) as UrlWithParams; +} + +export {getSearchParamFromUrl, hasSameExpensifyOrigin, getPathFromURL, appendParam, hasURL, addLeadingForwardSlash, extractUrlDomain, getUrlWithParams}; diff --git a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/Enable2FACard.tsx b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/Enable2FACard.tsx index e4153ca1baa5..7d00c45f05d9 100644 --- a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/Enable2FACard.tsx +++ b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/Enable2FACard.tsx @@ -23,14 +23,13 @@ function Enable2FACard({policyID}: Enable2FACardProps) {
{ - Navigation.navigate(ROUTES.SETTINGS_2FA_ROOT.getRoute(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute(policyID))); - }, + // Assuming user is validated here, validation is checked at the beginning of ConnectBank Flow + onPress: () => Navigation.navigate(ROUTES.SETTINGS_2FA_ROOT.getRoute(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute(policyID))), icon: Shield, shouldShowRightIcon: true, outerWrapperStyle: shouldUseNarrowLayout ? styles.mhn5 : styles.mhn8, diff --git a/src/pages/RequireTwoFactorAuthenticationPage.tsx b/src/pages/RequireTwoFactorAuthenticationPage.tsx index 164234d5da7c..d5b8bc475b89 100644 --- a/src/pages/RequireTwoFactorAuthenticationPage.tsx +++ b/src/pages/RequireTwoFactorAuthenticationPage.tsx @@ -1,19 +1,34 @@ -import React from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; import Icon from '@components/Icon'; import {Encryption} from '@components/Icon/Illustrations'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type Account from '@src/types/onyx/Account'; + +const accountValidatedSelector = (account: OnyxEntry) => account?.validated; function RequireTwoFactorAuthenticationPage() { const styles = useThemeStyles(); const {translate} = useLocalize(); + const [isUserValidated = false] = useOnyx(ONYXKEYS.ACCOUNT, {selector: accountValidatedSelector, canBeMissing: true}); + + const handleOnPress = useCallback(() => { + if (isUserValidated) { + Navigation.navigate(ROUTES.SETTINGS_2FA_ROOT.getRoute(ROUTES.REQUIRE_TWO_FACTOR_AUTH)); + return; + } + Navigation.navigate(ROUTES.SETTINGS_2FA_VERIFY_ACCOUNT.getRoute({backTo: ROUTES.REQUIRE_TWO_FACTOR_AUTH, forwardTo: ROUTES.SETTINGS_2FA_ROOT.getRoute()})); + }, [isUserValidated]); return ( @@ -34,7 +49,7 @@ function RequireTwoFactorAuthenticationPage() { large success pressOnEnter - onPress={() => Navigation.navigate(ROUTES.SETTINGS_2FA_ROOT.getRoute(ROUTES.REQUIRE_TWO_FACTOR_AUTH))} + onPress={handleOnPress} text={translate('twoFactorAuth.enableTwoFactorAuth')} /> diff --git a/src/pages/settings/Profile/Contacts/VerifyAccountPage.tsx b/src/pages/settings/Profile/Contacts/VerifyAccountPage.tsx index 0f4538a28436..aa8b2c4f5822 100644 --- a/src/pages/settings/Profile/Contacts/VerifyAccountPage.tsx +++ b/src/pages/settings/Profile/Contacts/VerifyAccountPage.tsx @@ -19,5 +19,6 @@ function VerifyAccountPage({route}: VerifyAccountPageProps) { /> ); } +VerifyAccountPage.displayName = 'VerifyAccountPage'; export default VerifyAccountPage; diff --git a/src/pages/settings/Security/AddDelegate/VerifyAccountPage.tsx b/src/pages/settings/Security/AddDelegate/VerifyAccountPage.tsx index 909fee97f078..8b8b30442780 100644 --- a/src/pages/settings/Security/AddDelegate/VerifyAccountPage.tsx +++ b/src/pages/settings/Security/AddDelegate/VerifyAccountPage.tsx @@ -10,5 +10,6 @@ function VerifyAccountPage() { /> ); } +VerifyAccountPage.displayName = 'VerifyAccountPage'; export default VerifyAccountPage; diff --git a/src/pages/settings/Security/SecuritySettingsPage.tsx b/src/pages/settings/Security/SecuritySettingsPage.tsx index 02356cacac9e..6c0dff39ca8f 100644 --- a/src/pages/settings/Security/SecuritySettingsPage.tsx +++ b/src/pages/settings/Security/SecuritySettingsPage.tsx @@ -134,6 +134,10 @@ function SecuritySettingsPage() { showLockedAccountModal(); return; } + if (!isUserValidated) { + Navigation.navigate(ROUTES.SETTINGS_2FA_VERIFY_ACCOUNT.getRoute()); + return; + } Navigation.navigate(ROUTES.SETTINGS_2FA_ROOT.getRoute()); }, }, @@ -203,6 +207,7 @@ function SecuritySettingsPage() { }, [ isAccountLocked, isDelegateAccessRestricted, + isUserValidated, showDelegateNoAccessModal, showLockedAccountModal, privateSubscription?.type, diff --git a/src/pages/settings/Security/TwoFactorAuth/CopyCodesPage.tsx b/src/pages/settings/Security/TwoFactorAuth/CopyCodesPage.tsx index 6eec3d06eec2..04dc42b020f7 100644 --- a/src/pages/settings/Security/TwoFactorAuth/CopyCodesPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/CopyCodesPage.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; @@ -9,8 +9,6 @@ import PressableWithDelayToggle from '@components/Pressable/PressableWithDelayTo import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; import Text from '@components/Text'; -import ValidateCodeActionModal from '@components/ValidateCodeActionModal'; -import useBeforeRemove from '@hooks/useBeforeRemove'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -18,16 +16,13 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {READ_COMMANDS} from '@libs/API/types'; import Clipboard from '@libs/Clipboard'; -import {getEarliestErrorField, getLatestErrorField} from '@libs/ErrorUtils'; import localFileDownload from '@libs/localFileDownload'; import Navigation from '@libs/Navigation/Navigation'; import {toggleTwoFactorAuth} from '@userActions/Session'; import {quitAndNavigateBack, setCodesAreCopied} from '@userActions/TwoFactorAuthActions'; -import {clearContactMethodErrors, requestValidateCodeAction, validateSecondaryLogin} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {TwoFactorAuthPageProps} from './TwoFactorAuthPage'; import TwoFactorAuthWrapper from './TwoFactorAuthWrapper'; @@ -35,25 +30,21 @@ import TwoFactorAuthWrapper from './TwoFactorAuthWrapper'; function CopyCodesPage({route}: TwoFactorAuthPageProps) { const theme = useTheme(); const styles = useThemeStyles(); - const {translate, formatPhoneNumber} = useLocalize(); + const {translate} = useLocalize(); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use correct style // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isExtraSmallScreenWidth, isSmallScreenWidth} = useResponsiveLayout(); const [error, setError] = useState(''); const [account, accountMetadata] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); - const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST, {canBeMissing: true}); const isUserValidated = account?.validated ?? false; - const contactMethod = account?.primaryLogin ?? ''; - - const loginData = useMemo(() => loginList?.[contactMethod], [loginList, contactMethod]); - const validateLoginError = getEarliestErrorField(loginData, 'validateLogin'); - - const [isValidateModalVisible, setIsValidateModalVisible] = useState(!isUserValidated); useEffect(() => { - setIsValidateModalVisible(!isUserValidated); + if (!isUserValidated) { + Navigation.navigate(ROUTES.SETTINGS_2FA_VERIFY_ACCOUNT.getRoute()); + return; + } // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (isLoadingOnyxValue(accountMetadata) || account?.requiresTwoFactorAuth || account?.recoveryCodes || !isUserValidated) { return; @@ -62,8 +53,6 @@ function CopyCodesPage({route}: TwoFactorAuthPageProps) { // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- We want to run this when component mounts }, [isUserValidated, accountMetadata.status]); - useBeforeRemove(() => setIsValidateModalVisible(false)); - return ( - requestValidateCodeAction()} - handleSubmitForm={(validateCode) => validateSecondaryLogin(loginList, contactMethod, validateCode, formatPhoneNumber, true)} - validateError={!isEmptyObject(validateLoginError) ? validateLoginError : getLatestErrorField(loginData, 'validateCodeSent')} - clearError={() => clearContactMethodErrors(contactMethod, !isEmptyObject(validateLoginError) ? 'validateLogin' : 'validateCodeSent')} - onClose={() => { - setIsValidateModalVisible(false); - quitAndNavigateBack(); - }} - /> ); } diff --git a/src/pages/settings/Security/TwoFactorAuth/VerifyAccountPage.tsx b/src/pages/settings/Security/TwoFactorAuth/VerifyAccountPage.tsx new file mode 100644 index 000000000000..dbcad21ac49e --- /dev/null +++ b/src/pages/settings/Security/TwoFactorAuth/VerifyAccountPage.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {TwoFactorAuthNavigatorParamList} from '@libs/Navigation/types'; +import VerifyAccountPageBase from '@pages/settings/VerifyAccountPageBase'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type VerifyAccountPageProps = PlatformStackScreenProps; + +function VerifyAccountPage({route}: VerifyAccountPageProps) { + return ( + + ); +} +VerifyAccountPage.displayName = 'VerifyAccountPage'; +export default VerifyAccountPage; diff --git a/tests/unit/UrlTest.ts b/tests/unit/UrlTest.ts index 8c1fafaafc34..9920b178ce8f 100644 --- a/tests/unit/UrlTest.ts +++ b/tests/unit/UrlTest.ts @@ -100,4 +100,16 @@ describe('Url', () => { }); }); }); + describe('getUrlWithParams', () => { + it.each([ + ['adds params to URL without existing query', '/search', {q: 'hello world', page: 2}, '/search?q=hello+world&page=2'], + ['merges with existing query params', '/search?q=old', {page: 2, q: 'new'}, '/search?q=new&page=2'], + ['works with absolute URL', 'https://example.com/items?sort=asc', {page: 5}, 'https://example.com/items?sort=asc&page=5'], + ['encodes special characters', '/search', {q: 'hello & world'}, '/search?q=hello+%26+world'], + ['returns same URL if no params', '/search', {}, '/search'], + ['returns same URL if params are undefined', '/search', {q: undefined}, '/search'], + ])('%s', (_, baseUrl, params, expected) => { + expect(Url.getUrlWithParams(baseUrl, params)).toBe(expected); + }); + }); });