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);
+ });
+ });
});