diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv
index 1ccdabe3fd42..08b1c237aa5e 100644
--- a/config/eslint/eslint.seatbelt.tsv
+++ b/config/eslint/eslint.seatbelt.tsv
@@ -452,7 +452,6 @@
"../../src/pages/EnablePayments/FeesAndTerms/substeps/TermsStep.tsx" "react-hooks/set-state-in-effect" 1
"../../src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx" "@typescript-eslint/no-deprecated/useSubStep" 1
"../../src/pages/MissingPersonalDetails/subPages/Address.tsx" "react-hooks/refs" 4
-"../../src/pages/MultifactorAuthentication/BiometricsTestPage.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1
"../../src/pages/MultifactorAuthentication/RevokePage.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1
"../../src/pages/MultifactorAuthentication/ValidateCodePage.tsx" "react-hooks/set-state-in-effect" 1
"../../src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx" "react-hooks/preserve-manual-memoization" 2
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 1ca94ad44f93..6010fe5b38ef 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -6575,6 +6575,7 @@ const CONST = {
NAVIGATION: {
CUSTOM_HISTORY_ENTRY_SIDE_PANEL: 'CUSTOM_HISTORY-SIDE_PANEL',
+ CUSTOM_HISTORY_ENTRY_MFA_MODAL_NAVIGATOR: 'CUSTOM_HISTORY-MFA_MODAL_NAVIGATOR',
// Fake history entry used to keep browser Back behavior correct after revealing a route under an RHP.
// addRootHistoryRouterExtension owns when this is added, carried forward, and removed.
CUSTOM_HISTORY_ENTRY_REVEAL_PADDING: 'CUSTOM_HISTORY-REVEAL_PADDING',
@@ -6598,6 +6599,7 @@ const CONST = {
PUSH_PARAMS: 'PUSH_PARAMS',
REPLACE_PARAMS: 'REPLACE_PARAMS',
TOGGLE_SIDE_PANEL_WITH_HISTORY: 'TOGGLE_SIDE_PANEL_WITH_HISTORY',
+ TOGGLE_MFA_MODAL_NAVIGATOR_WITH_HISTORY: 'TOGGLE_MFA_MODAL_NAVIGATOR_WITH_HISTORY',
},
},
TIME_PERIOD: {
@@ -9973,6 +9975,9 @@ const CONST = {
SAFE_AREA: {
DISMISS_KEYBOARD_LANDSCAPE_MODE: 'SafeArea-DismissKeyboardLandscapeMode',
},
+ MFA_OVERLAY: {
+ BACKDROP: 'MfaOverlay-Backdrop',
+ },
DOMAIN: {
GROUPS: {
CREATE_GROUP_BUTTON: 'DomainGroups-CreateGroupButton',
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index f4701d09c231..a369ef5dbb53 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -6,7 +6,6 @@
*/
import type {TupleToUnion, ValueOf} from 'type-fest';
import type {UpperCaseCharacters} from 'type-fest/source/internal';
-import type {MultifactorAuthenticationPromptType} from './components/MultifactorAuthentication/config/types';
import type {SearchFilterKey, SearchQueryString, UserFriendlyKey} from './components/Search/types';
import type CONST from './CONST';
import type {IOUAction, IOURequestType, IOUType, OdometerImageType} from './CONST';
@@ -4078,17 +4077,6 @@ const ROUTES = {
getRoute: (domainAccountID: number, accountID: number) => `domain/${domainAccountID}/members/${accountID}/move` as const,
},
- MULTIFACTOR_AUTHENTICATION_MAGIC_CODE: `multifactor-authentication/magic-code`,
- MULTIFACTOR_AUTHENTICATION_BIOMETRICS_TEST: 'multifactor-authentication/scenario/biometrics-test',
-
- MULTIFACTOR_AUTHENTICATION_OUTCOME_SUCCESS: 'multifactor-authentication/outcome/success',
- MULTIFACTOR_AUTHENTICATION_OUTCOME_FAILURE: 'multifactor-authentication/outcome/failure',
-
- MULTIFACTOR_AUTHENTICATION_PROMPT: {
- route: `multifactor-authentication/prompt/:promptType`,
- getRoute: (promptType: MultifactorAuthenticationPromptType) => `multifactor-authentication/prompt/${promptType}` as const,
- },
- MULTIFACTOR_AUTHENTICATION_NOT_FOUND: 'multifactor-authentication/not-found',
MULTIFACTOR_AUTHENTICATION_REVOKE: 'multifactor-authentication/revoke',
DOMAIN_GROUPS: {
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index e05fdd2c9a2f..663f7ac8aacf 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -1076,11 +1076,9 @@ const SCREENS = {
},
MULTIFACTOR_AUTHENTICATION: {
MAGIC_CODE: 'Multifactor_Authentication_Magic_Code',
- BIOMETRICS_TEST: 'Multifactor_Authentication_Biometrics_Test',
OUTCOME_SUCCESS: 'Multifactor_Authentication_Outcome_Success',
OUTCOME_FAILURE: 'Multifactor_Authentication_Outcome_Failure',
PROMPT: 'Multifactor_Authentication_Prompt',
- NOT_FOUND: 'Multifactor_Authentication_Not_Found',
REVOKE: 'Multifactor_Authentication_Revoke',
AUTHORIZE_TRANSACTION: 'Multifactor_Authentication_Authorize_Transaction',
},
diff --git a/src/components/BiometricsTestToolRow.tsx b/src/components/BiometricsTestToolRow.tsx
new file mode 100644
index 000000000000..cd0b5432bcc3
--- /dev/null
+++ b/src/components/BiometricsTestToolRow.tsx
@@ -0,0 +1,59 @@
+import React, {useState} from 'react';
+import {View} from 'react-native';
+import useBiometricRegistrationStatus, {REGISTRATION_STATUS} from '@hooks/useBiometricRegistrationStatus';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {revokeMultifactorAuthenticationCredentials} from '@libs/actions/MultifactorAuthentication';
+import CONST from '@src/CONST';
+import Button from './Button';
+import {useMultifactorAuthentication} from './MultifactorAuthentication/Context';
+import TestToolRow from './TestToolRow';
+
+function BiometricsTestToolRow() {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {executeScenario} = useMultifactorAuthentication();
+ const {localCredentialID, isCurrentDeviceRegistered, otherDeviceCount, registrationStatus} = useBiometricRegistrationStatus();
+ const {isOffline} = useNetwork();
+ const [isMFARevokeLoading, setIsMFARevokeLoading] = useState(false);
+
+ const statusTextMap = {
+ [REGISTRATION_STATUS.NEVER_REGISTERED]: translate('multifactorAuthentication.biometricsTest.statusNeverRegistered'),
+ [REGISTRATION_STATUS.NOT_REGISTERED]: translate('multifactorAuthentication.biometricsTest.statusNotRegistered'),
+ [REGISTRATION_STATUS.REGISTERED_OTHER_DEVICE]: translate('multifactorAuthentication.biometricsTest.statusRegisteredOtherDevice', {count: otherDeviceCount}),
+ [REGISTRATION_STATUS.REGISTERED_THIS_DEVICE]: translate('multifactorAuthentication.biometricsTest.statusRegisteredThisDevice'),
+ };
+ const biometricsTitle = translate('multifactorAuthentication.biometricsTest.troubleshootBiometricsStatus', {status: statusTextMap[registrationStatus]});
+
+ return (
+
+
+
+
+ );
+}
+
+BiometricsTestToolRow.displayName = 'BiometricsTestToolRow';
+
+export default BiometricsTestToolRow;
diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationActionsContext.ts b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationActionsContext.ts
new file mode 100644
index 000000000000..3658d6f9d283
--- /dev/null
+++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationActionsContext.ts
@@ -0,0 +1,17 @@
+import {createContext, useContext} from 'react';
+import type {MultifactorAuthenticationActionsContextType} from './types';
+
+const MultifactorAuthenticationActionsContext = createContext(undefined);
+
+function useMultifactorAuthenticationActions(): MultifactorAuthenticationActionsContextType {
+ const context = useContext(MultifactorAuthenticationActionsContext);
+
+ if (!context) {
+ throw new Error('useMultifactorAuthenticationActions must be used within a MultifactorAuthenticationStateProvider');
+ }
+
+ return context;
+}
+
+export default MultifactorAuthenticationActionsContext;
+export {useMultifactorAuthenticationActions};
diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders.tsx
new file mode 100644
index 000000000000..8f766d2af500
--- /dev/null
+++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders.tsx
@@ -0,0 +1,36 @@
+import React, {useMemo, useReducer} from 'react';
+import type {ReactNode} from 'react';
+import MultifactorAuthenticationActionsContext from './MultifactorAuthenticationActionsContext';
+import {MultifactorAuthenticationContextProvider} from './MultifactorAuthenticationMainContext';
+import MultifactorAuthenticationStateContext from './MultifactorAuthenticationStateContext';
+import {DEFAULT_STATE, stateReducer} from './stateReducer';
+
+type MultifactorAuthenticationProviderProps = {
+ children: ReactNode;
+};
+
+function MultifactorAuthenticationStateProvider({children}: MultifactorAuthenticationProviderProps) {
+ const [state, dispatch] = useReducer(stateReducer, DEFAULT_STATE);
+ const actions = useMemo(() => ({dispatch}), [dispatch]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+MultifactorAuthenticationStateProvider.displayName = 'MultifactorAuthenticationStateProvider';
+
+function MultifactorAuthenticationContextProviders({children}: MultifactorAuthenticationProviderProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+MultifactorAuthenticationContextProviders.displayName = 'MultifactorAuthenticationContextProviders';
+
+export default MultifactorAuthenticationContextProviders;
+export {DEFAULT_STATE, MultifactorAuthenticationStateProvider};
diff --git a/src/components/MultifactorAuthentication/Context/Main.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx
similarity index 86%
rename from src/components/MultifactorAuthentication/Context/Main.tsx
rename to src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx
index 3a4bc654288c..38effe4a154b 100644
--- a/src/components/MultifactorAuthentication/Context/Main.tsx
+++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx
@@ -5,10 +5,12 @@ import type {OnyxEntry} from 'react-native-onyx';
import type {AuthorizeResult, RegisterResult} from '@components/MultifactorAuthentication/biometrics/shared/types';
import useBiometrics from '@components/MultifactorAuthentication/biometrics/useBiometrics';
import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioParams} from '@components/MultifactorAuthentication/config/types';
+import {navigate as mfaNavigate} from '@components/MultifactorAuthentication/mfaNavigation';
import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs';
import trackMFAFlowOutcome from '@components/MultifactorAuthentication/observability/trackMFAFlowOutcome';
import type {CredentialsState} from '@components/MultifactorAuthentication/observability/trackMFAFlowOutcome';
import trackMFAFlowStart from '@components/MultifactorAuthentication/observability/trackMFAFlowStart';
+import useSyncMfaModalNavigatorWithHistory from '@components/MultifactorAuthentication/useSyncMfaModalNavigatorWithHistory';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useNetwork from '@hooks/useNetwork';
import {requestValidateCodeAction} from '@libs/actions/User';
@@ -17,13 +19,14 @@ import getPlatform from '@libs/getPlatform';
import {isHttpSuccess} from '@libs/MultifactorAuthentication/shared/helpers';
import {createLocalMFAError, createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult';
import type {MultifactorAuthenticationCallbackInput} from '@libs/MultifactorAuthentication/shared/types';
-import Navigation from '@navigation/Navigation';
+import Navigation from '@libs/Navigation/Navigation';
import {clearLocalMFAPublicKeyList, getDeviceBiometricsOnyxKey, requestAuthorizationChallenge, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication';
import {processRegistration, processScenarioAction} from '@userActions/MultifactorAuthentication/processing';
import CONST from '@src/CONST';
-import ROUTES from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
import type {DeviceBiometrics} from '@src/types/onyx';
-import {useMultifactorAuthenticationActions, useMultifactorAuthenticationState} from './State';
+import {useMultifactorAuthenticationActions} from './MultifactorAuthenticationActionsContext';
+import {useMultifactorAuthenticationState} from './MultifactorAuthenticationStateContext';
let deviceBiometricsState: OnyxEntry;
@@ -33,8 +36,18 @@ type MultifactorAuthenticationContextValue = {
/** Execute a multifactor authentication scenario */
executeScenario: (scenario: T, params?: ExecuteScenarioParams) => Promise;
- /** Cancel the current authentication flow and navigate to failure outcome */
- cancel: () => Promise;
+ /**
+ * Centralized back-press entry. Decides — based on current MFA state and the
+ * route shown by the modal navigator — whether to close the modal directly
+ * or surface the cancel-confirmation modal.
+ */
+ requestCancel: () => void;
+
+ /** Dismiss the cancel-confirmation modal without cancelling the flow. */
+ hideCancelConfirm: () => void;
+
+ /** Confirm cancellation — hides the modal and runs cancel(). */
+ confirmCancel: () => void;
};
const MultifactorAuthenticationContext = createContext(undefined);
@@ -128,19 +141,19 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
endState,
});
- // If the callback returns SKIP_OUTCOME_SCREEN, the callback handles navigation itself
+ dispatch({type: 'SET_FLOW_COMPLETE', payload: true});
+
+ // If the callback returns SKIP_OUTCOME_SCREEN, the callback handles navigation itself.
+ // Close the modal so the overlay plays its exit animation and then resets.
if (callbackResponse === CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SKIP_OUTCOME_SCREEN) {
- dispatch({type: 'SET_FLOW_COMPLETE', payload: true});
+ dispatch({type: 'CLOSE_MODAL'});
return;
}
- if (isSuccessful) {
- Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME_SUCCESS, {forceReplace: true});
- } else {
- Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME_FAILURE, {forceReplace: true});
- }
-
- dispatch({type: 'SET_FLOW_COMPLETE', payload: true});
+ // Wait out any callback-initiated transition (e.g. RHP goBack) before pushing the outcome screen.
+ // Android raced the two animations and the RHP slide-out leaked through the outgoing screen.
+ const outcomeScreen = isSuccessful ? SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_SUCCESS : SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_FAILURE;
+ Navigation.runAfterTransition(() => mfaNavigate(outcomeScreen));
},
[captureCredentialsState, dispatch, state],
);
@@ -221,7 +234,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
if (!validateCode) {
addMFABreadcrumb('Validate code requested');
requestValidateCodeAction();
- Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_MAGIC_CODE, {forceReplace: true});
+ mfaNavigate(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE);
return;
}
@@ -244,7 +257,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
// Check if a soft prompt is needed
if (!softPromptApproved) {
addMFABreadcrumb('Soft prompt shown', {context: 'registration'});
- Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(promptType), {forceReplace: true});
+ mfaNavigate(SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT, {promptType});
return;
}
@@ -282,15 +295,13 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
// prompt first.
if (!deviceBiometricsState?.hasAcceptedSoftPrompt && !softPromptApproved) {
addMFABreadcrumb('Soft prompt shown', {context: 'authorization-reinstall'});
- Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(promptType), {forceReplace: true});
+ mfaNavigate(SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT, {promptType});
return;
}
// 4. Authorize the user if that has not already been done
if (!isAuthorizationComplete) {
- if (!Navigation.isActiveRoute(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(promptType))) {
- Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(promptType), {forceReplace: true});
- }
+ mfaNavigate(SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT, {promptType});
// Request authorization challenge if not already fetched
if (!authorizationChallenge) {
@@ -443,6 +454,15 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
*/
const executeScenario = useCallback(
async (scenario: T, params?: ExecuteScenarioParams): Promise => {
+ // Rapid double-tap on the trigger can fire executeScenario twice. The reducer's INIT
+ // case is the authoritative guard (it processes dispatches sequentially against the
+ // latest state and drops the duplicate). This check exists only as a perf short-circuit
+ // so the second tap doesn't pay for an extra captureCredentialsState() native call
+ // and Sentry breadcrumb on the happy path.
+ if (state.scenario) {
+ return;
+ }
+
startStateRef.current = await captureCredentialsState();
const breadcrumbData = {
@@ -468,21 +488,18 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
},
});
},
- [captureCredentialsState, dispatch, isOffline, platform],
+ [captureCredentialsState, dispatch, isOffline, platform, state.scenario],
);
/**
- * Cancel the current authentication flow.
- * When the scenario provides onCancel, awaits it to get the reason and sets the error accordingly.
- * Otherwise, sets an error state with LOCAL_ERRORS.CANCELED. In both cases, the error triggers
- * process() which calls handleCallback and navigates to the failure outcome.
+ * Cancel the current authentication flow. Dispatches SET_ERROR which drives process() to handleCallback
+ * and the failure outcome. When the scenario or network is unavailable, process() short-circuits, so we
+ * close the modal directly instead to avoid getting stuck.
*/
const cancel = useCallback(async () => {
- // When the app is reopened (e.g. page refresh on web), the MFA context resets to its default state
- // and scenario becomes undefined. Without a scenario, the state machine in process() won't run,
- // so dispatching SET_ERROR would have no effect. In this case we dismiss the modal directly.
- if (!state.scenario) {
- Navigation.dismissModal();
+ if (!state.scenario || isOffline) {
+ addMFABreadcrumb('Flow cancelled - closing directly', {hasScenario: !!state.scenario, isOffline}, 'warning');
+ dispatch({type: 'CLOSE_MODAL'});
return;
}
@@ -498,14 +515,37 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
type: 'SET_ERROR',
payload: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, 'User cancelled the MFA flow'),
});
- }, [dispatch, state.scenario, state.payload]);
+ }, [dispatch, isOffline, state.scenario, state.payload]);
+
+ const requestCancel = () => {
+ if (state.isFlowComplete) {
+ dispatch({type: 'CLOSE_MODAL'});
+ return;
+ }
+ if (!state.scenario || isOffline) {
+ cancel();
+ return;
+ }
+ dispatch({type: 'SET_CANCEL_CONFIRM_VISIBLE', payload: true});
+ };
+
+ const hideCancelConfirm = () => dispatch({type: 'SET_CANCEL_CONFIRM_VISIBLE', payload: false});
+
+ const confirmCancel = () => {
+ dispatch({type: 'SET_CANCEL_CONFIRM_VISIBLE', payload: false});
+ cancel();
+ };
+
+ useSyncMfaModalNavigatorWithHistory(state.isModalOpen, requestCancel);
const contextValue: MultifactorAuthenticationContextValue = useMemo(
() => ({
executeScenario,
- cancel,
+ requestCancel,
+ hideCancelConfirm,
+ confirmCancel,
}),
- [cancel, executeScenario],
+ [executeScenario, requestCancel, hideCancelConfirm, confirmCancel],
);
return {children};
diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationStateContext.ts b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationStateContext.ts
new file mode 100644
index 000000000000..23a7e5f523e0
--- /dev/null
+++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationStateContext.ts
@@ -0,0 +1,17 @@
+import {createContext, useContext} from 'react';
+import type {MultifactorAuthenticationState, MultifactorAuthenticationStateContextType} from './types';
+
+const MultifactorAuthenticationStateContext = createContext(undefined);
+
+function useMultifactorAuthenticationState(): MultifactorAuthenticationState {
+ const context = useContext(MultifactorAuthenticationStateContext);
+
+ if (!context) {
+ throw new Error('useMultifactorAuthenticationState must be used within a MultifactorAuthenticationStateProvider');
+ }
+
+ return context;
+}
+
+export default MultifactorAuthenticationStateContext;
+export {useMultifactorAuthenticationState};
diff --git a/src/components/MultifactorAuthentication/Context/Provider.tsx b/src/components/MultifactorAuthentication/Context/Provider.tsx
deleted file mode 100644
index 81a1824297c1..000000000000
--- a/src/components/MultifactorAuthentication/Context/Provider.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import React from 'react';
-import type {ReactNode} from 'react';
-import ComposeProviders from '@components/ComposeProviders';
-import {MultifactorAuthenticationContextProvider} from './Main';
-import MultifactorAuthenticationStateProvider from './State';
-
-type MultifactorAuthenticationProviderProps = {
- children: ReactNode;
-};
-
-function MultifactorAuthenticationContextProviders({children}: MultifactorAuthenticationProviderProps) {
- return {children};
-}
-
-MultifactorAuthenticationContextProviders.displayName = 'MultifactorAuthenticationContextProviders';
-
-export default MultifactorAuthenticationContextProviders;
diff --git a/src/components/MultifactorAuthentication/Context/State.tsx b/src/components/MultifactorAuthentication/Context/State.tsx
deleted file mode 100644
index ae44377f956b..000000000000
--- a/src/components/MultifactorAuthentication/Context/State.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import React, {createContext, useContext, useMemo, useReducer} from 'react';
-import type {ReactNode} from 'react';
-import {DEFAULT_STATE, stateReducer} from './stateReducer';
-import type {MultifactorAuthenticationActionsContextType, MultifactorAuthenticationState, MultifactorAuthenticationStateContextType} from './types';
-
-const MultifactorAuthenticationStateContext = createContext(undefined);
-const MultifactorAuthenticationActionsContext = createContext(undefined);
-
-type MultifactorAuthenticationStateProviderProps = {
- children: ReactNode;
-};
-
-/**
- * Provider component that manages the global multifactor authentication state.
- * Uses a reducer pattern to handle complex state transitions and provides
- * separate state and actions contexts to all consuming components.
- * Must be placed high in the component tree to wrap all MFA-related screens.
- *
- * @param props - Component props
- * @param props.children - Child components that will have access to MFA state
- * @returns The provider component wrapping children
- */
-function MultifactorAuthenticationStateProvider({children}: MultifactorAuthenticationStateProviderProps) {
- const [state, dispatch] = useReducer(stateReducer, DEFAULT_STATE);
- const actions = useMemo(() => ({dispatch}), [dispatch]);
-
- return (
-
- {children}
-
- );
-}
-
-/**
- * Hook to access the multifactor authentication state.
- * Must be called within a MultifactorAuthenticationStateProvider tree.
- *
- * @returns The current MultifactorAuthenticationState
- * @throws {Error} If used outside of MultifactorAuthenticationStateProvider
- */
-function useMultifactorAuthenticationState(): MultifactorAuthenticationState {
- const context = useContext(MultifactorAuthenticationStateContext);
-
- if (!context) {
- throw new Error('useMultifactorAuthenticationState must be used within a MultifactorAuthenticationStateProvider');
- }
-
- return context;
-}
-
-/**
- * Hook to access the multifactor authentication actions (dispatch).
- * Must be called within a MultifactorAuthenticationStateProvider tree.
- *
- * @returns Object with dispatch function to update state
- * @throws {Error} If used outside of MultifactorAuthenticationStateProvider
- *
- * @example
- * const { dispatch } = useMultifactorAuthenticationActions();
- * dispatch({ type: 'SET_VALIDATE_CODE', payload: '123456' });
- */
-function useMultifactorAuthenticationActions(): MultifactorAuthenticationActionsContextType {
- const context = useContext(MultifactorAuthenticationActionsContext);
-
- if (!context) {
- throw new Error('useMultifactorAuthenticationActions must be used within a MultifactorAuthenticationStateProvider');
- }
-
- return context;
-}
-
-MultifactorAuthenticationStateProvider.displayName = 'MultifactorAuthenticationStateProvider';
-
-export default MultifactorAuthenticationStateProvider;
-export {DEFAULT_STATE, useMultifactorAuthenticationState, useMultifactorAuthenticationActions};
diff --git a/src/components/MultifactorAuthentication/Context/index.ts b/src/components/MultifactorAuthentication/Context/index.ts
index 313ed3b32714..ae89b7e382eb 100644
--- a/src/components/MultifactorAuthentication/Context/index.ts
+++ b/src/components/MultifactorAuthentication/Context/index.ts
@@ -1,6 +1,5 @@
-export {default as MultifactorAuthenticationContextProviders} from './Provider';
-export {useMultifactorAuthentication} from './Main';
-
-export {useMultifactorAuthenticationState, useMultifactorAuthenticationActions} from './State';
-
+export {default as MultifactorAuthenticationContextProviders} from './MultifactorAuthenticationComposedContextProviders';
+export {useMultifactorAuthentication} from './MultifactorAuthenticationMainContext';
+export {useMultifactorAuthenticationActions} from './MultifactorAuthenticationActionsContext';
+export {useMultifactorAuthenticationState} from './MultifactorAuthenticationStateContext';
export {default as usePromptContent} from './usePromptContent';
diff --git a/src/components/MultifactorAuthentication/Context/stateReducer.ts b/src/components/MultifactorAuthentication/Context/stateReducer.ts
index 0799926b4585..4b2e8f77867c 100644
--- a/src/components/MultifactorAuthentication/Context/stateReducer.ts
+++ b/src/components/MultifactorAuthentication/Context/stateReducer.ts
@@ -4,6 +4,7 @@ import CONST from '@src/CONST';
import type {Action, MultifactorAuthenticationState} from './types';
const DEFAULT_STATE: MultifactorAuthenticationState = {
+ isModalOpen: false,
error: undefined,
continuableError: undefined,
validateCode: undefined,
@@ -18,6 +19,7 @@ const DEFAULT_STATE: MultifactorAuthenticationState = {
isFlowComplete: false,
authenticationMethod: undefined,
scenarioResponse: undefined,
+ isCancelConfirmVisible: false,
};
/**
@@ -64,26 +66,44 @@ function stateReducer(state: MultifactorAuthenticationState, action: Action): Mu
case 'SET_AUTHORIZATION_COMPLETE':
return {...state, isAuthorizationComplete: action.payload};
case 'SET_FLOW_COMPLETE':
- return {...state, isFlowComplete: action.payload};
+ // Clear cancel-confirm so it doesn't linger over the outcome screen.
+ return {...state, isFlowComplete: action.payload, isCancelConfirmVisible: action.payload ? false : state.isCancelConfirmVisible};
case 'SET_AUTHENTICATION_METHOD':
return {...state, authenticationMethod: action.payload};
case 'SET_SCENARIO_RESPONSE':
return {...state, scenarioResponse: action.payload};
+ case 'SET_CANCEL_CONFIRM_VISIBLE':
+ return {...state, isCancelConfirmVisible: action.payload};
case 'INIT': {
+ // Race guard: drop duplicate INIT — reducer sees latest state, catches stale-closure dispatches.
+ if (state.scenario) {
+ return state;
+ }
// We can safely make this assertion because the params type is already type-guarded in both the executeScenario and the actions themselves.
+ // Each scenario config satisfies MultifactorAuthenticationScenarioConfig at definition; the union prevents direct assertion.
const scenario = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[action.payload.scenario] as MultifactorAuthenticationScenarioConfig;
return {
...DEFAULT_STATE,
+ isModalOpen: true,
scenarioName: action.payload.scenario,
scenario,
payload: action.payload.payload,
};
}
+ case 'CLOSE_MODAL':
+ // Also clear isCancelConfirmVisible. CLOSE_MODAL can close the navigator without the
+ // flow ever completing (e.g. cancel() short-circuits to CLOSE_MODAL when offline), so
+ // SET_FLOW_COMPLETE's clear path doesn't run and the cancel-confirm dialog would
+ // otherwise linger over the closing navigator.
+ return {...state, isModalOpen: false, isCancelConfirmVisible: false};
case 'RESET':
return DEFAULT_STATE;
case 'REREGISTER':
+ // Re-registration restarts the flow in-place: keep the modal mounted so the navigator does not
+ // play the close animation (which would unmount mid-flow and strand the user via RESET).
return {
...DEFAULT_STATE,
+ isModalOpen: true,
scenarioName: state.scenarioName,
scenario: state.scenario,
payload: state.payload,
diff --git a/src/components/MultifactorAuthentication/Context/types.ts b/src/components/MultifactorAuthentication/Context/types.ts
index aa0fe5e7808a..768275f07926 100644
--- a/src/components/MultifactorAuthentication/Context/types.ts
+++ b/src/components/MultifactorAuthentication/Context/types.ts
@@ -9,6 +9,10 @@ import type {MFAError} from '@libs/MultifactorAuthentication/shared/MFAResult';
import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types';
type MultifactorAuthenticationState = {
+ /** Whether the MFA modal overlay is open. Separate from scenario lifecycle
+ * so the close animation can play before state is fully reset. */
+ isModalOpen: boolean;
+
/** Current error state - stops the flow and navigates to failure outcome */
error: MFAError | undefined;
@@ -50,6 +54,9 @@ type MultifactorAuthenticationState = {
/** Response from the scenario API call, stored for callback invocation at outcome navigation */
scenarioResponse: MultifactorAuthenticationScenarioResponse | undefined;
+
+ /** Whether the cancel-confirmation modal triggered by a back press is currently visible */
+ isCancelConfirmVisible: boolean;
};
type InitPayload = {
@@ -71,8 +78,10 @@ type Action =
| {type: 'SET_FLOW_COMPLETE'; payload: boolean}
| {type: 'SET_AUTHENTICATION_METHOD'; payload: AuthTypeInfo | undefined}
| {type: 'SET_SCENARIO_RESPONSE'; payload: MultifactorAuthenticationScenarioResponse | undefined}
+ | {type: 'SET_CANCEL_CONFIRM_VISIBLE'; payload: boolean}
| {type: 'INIT'; payload: InitPayload}
| {type: 'REREGISTER'}
+ | {type: 'CLOSE_MODAL'}
| {type: 'RESET'};
/** Context value for state - the current MFA state */
diff --git a/src/components/MultifactorAuthentication/Context/usePromptContent.ts b/src/components/MultifactorAuthentication/Context/usePromptContent.ts
index 4d327719a8ae..3447b82b8292 100644
--- a/src/components/MultifactorAuthentication/Context/usePromptContent.ts
+++ b/src/components/MultifactorAuthentication/Context/usePromptContent.ts
@@ -8,7 +8,7 @@ import useOnyx from '@hooks/useOnyx';
import {getDeviceBiometricsOnyxKey} from '@libs/actions/MultifactorAuthentication';
import type {TranslationPaths} from '@src/languages/types';
import type IconAsset from '@src/types/utils/IconAsset';
-import {useMultifactorAuthenticationState} from './State';
+import {useMultifactorAuthenticationState} from './MultifactorAuthenticationStateContext';
type PromptContent = {
illustration: DotLottieAnimation | IconAsset;
diff --git a/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx b/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx
index e9fa67837d18..d299fbce9584 100644
--- a/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx
+++ b/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import {useMultifactorAuthenticationState} from '@components/MultifactorAuthentication/Context';
+import {useMultifactorAuthenticationState} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationStateContext';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
diff --git a/src/components/MultifactorAuthentication/components/OutcomeScreen/FailureScreen/FailureScreenBase.tsx b/src/components/MultifactorAuthentication/components/OutcomeScreen/FailureScreen/FailureScreenBase.tsx
index 653a7369b9f7..d703c74d879a 100644
--- a/src/components/MultifactorAuthentication/components/OutcomeScreen/FailureScreen/FailureScreenBase.tsx
+++ b/src/components/MultifactorAuthentication/components/OutcomeScreen/FailureScreen/FailureScreenBase.tsx
@@ -15,9 +15,19 @@ type FailureScreenBaseProps = {
title: TranslationPaths;
subtitle?: TranslationPaths;
customSubtitle?: React.ReactElement;
+ onClose?: () => void;
};
-function FailureScreenBase({headerTitle = 'multifactorAuthentication.verificationFailed', illustration, iconWidth, iconHeight, title, subtitle, customSubtitle}: FailureScreenBaseProps) {
+function FailureScreenBase({
+ headerTitle = 'multifactorAuthentication.verificationFailed',
+ illustration,
+ iconWidth,
+ iconHeight,
+ title,
+ subtitle,
+ customSubtitle,
+ onClose,
+}: FailureScreenBaseProps) {
const {translate} = useLocalize();
return (
@@ -30,6 +40,7 @@ function FailureScreenBase({headerTitle = 'multifactorAuthentication.verificatio
subtitle={subtitle ? translate(subtitle) : undefined}
customSubtitle={customSubtitle}
padding={spacing.p0}
+ onClose={onClose}
/>
);
}
diff --git a/src/components/MultifactorAuthentication/components/OutcomeScreen/OutcomeScreenBase.tsx b/src/components/MultifactorAuthentication/components/OutcomeScreen/OutcomeScreenBase.tsx
index 18ea159dcba1..60b19410652c 100644
--- a/src/components/MultifactorAuthentication/components/OutcomeScreen/OutcomeScreenBase.tsx
+++ b/src/components/MultifactorAuthentication/components/OutcomeScreen/OutcomeScreenBase.tsx
@@ -6,6 +6,7 @@ import Button from '@components/Button';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import {loadIllustration} from '@components/Icon/IllustrationLoader';
import type {IllustrationName} from '@components/Icon/IllustrationLoader';
+import {useMultifactorAuthenticationActions} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationActionsContext';
import RenderHTML from '@components/RenderHTML';
import ScreenWrapper from '@components/ScreenWrapper';
import ScrollView from '@components/ScrollView';
@@ -13,7 +14,6 @@ import Text from '@components/Text';
import {useMemoizedLazyAsset} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import Navigation from '@libs/Navigation/Navigation';
import Parser from '@libs/Parser';
type OutcomeScreenBaseProps = {
@@ -25,6 +25,18 @@ type OutcomeScreenBaseProps = {
subtitle?: string;
customSubtitle?: React.ReactElement;
padding?: ViewStyle;
+ /**
+ * Override the back/confirm button handler. Defaults to dispatching CLOSE_MODAL,
+ * which is correct when the screen is hosted inside the MFA modal navigator.
+ *
+ * Hosts that render the outcome inline inside the RHP (outside the MFA navigator)
+ * must supply their own dismiss callback (e.g. `Navigation.closeRHPFlow()`).
+ *
+ * Examples in this repo:
+ * - `src/pages/iou/AuthorizeTransactionPage.tsx` — deny outcome rendered inside RHP.
+ * - `src/pages/settings/Wallet/ExpensifyCardPage/ChangePINAtATMPage.tsx` — standalone RHP screen.
+ */
+ onClose?: () => void;
titleStyle?: StyleProp;
};
@@ -46,14 +58,17 @@ function HTMLSubtitle({htmlString = '', style}: {htmlString?: string; style?: Vi
);
}
-function OutcomeScreenBase({headerTitle, illustration, iconWidth, iconHeight, title, subtitle, customSubtitle, padding, titleStyle}: OutcomeScreenBaseProps) {
+function OutcomeScreenBase({headerTitle, illustration, iconWidth, iconHeight, title, subtitle, customSubtitle, padding, onClose: onCloseOverride, titleStyle}: OutcomeScreenBaseProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
const {asset: icon} = useMemoizedLazyAsset(() => loadIllustration(illustration));
+ const {dispatch} = useMultifactorAuthenticationActions();
- const onClose = () => {
- Navigation.closeRHPFlow();
- };
+ const onClose =
+ onCloseOverride ??
+ (() => {
+ dispatch({type: 'CLOSE_MODAL'});
+ });
const CustomSubtitle = customSubtitle ?? (
void;
titleStyle?: StyleProp;
};
-function SuccessScreenBase({headerTitle, illustration, iconWidth, iconHeight, title, subtitle, customSubtitle, titleStyle}: SuccessScreenBaseProps) {
+function SuccessScreenBase({headerTitle, illustration, iconWidth, iconHeight, title, subtitle, customSubtitle, onClose, titleStyle}: SuccessScreenBaseProps) {
const {translate} = useLocalize();
return (
@@ -32,6 +33,7 @@ function SuccessScreenBase({headerTitle, illustration, iconWidth, iconHeight, ti
subtitle={subtitle ? translate(subtitle) : undefined}
customSubtitle={customSubtitle}
padding={spacing.p2}
+ onClose={onClose}
titleStyle={titleStyle}
/>
);
diff --git a/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx b/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx
index 599b05ad6f92..3242874dc66d 100644
--- a/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx
+++ b/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx
@@ -18,7 +18,6 @@ import {createLocalMFAError, createMFAErrorFromApiResponse} from '@libs/Multifac
import variables from '@styles/variables';
import {authorizeTransaction, denyTransaction, fireAndForgetDenyTransaction} from '@userActions/MultifactorAuthentication';
import CONST from '@src/CONST';
-import SCREENS from '@src/SCREENS';
type Payload = {
transactionID: string;
@@ -116,7 +115,6 @@ export default {
return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SHOW_OUTCOME_SCREEN;
},
- screen: SCREENS.MULTIFACTOR_AUTHENTICATION.AUTHORIZE_TRANSACTION,
successScreen: ,
defaultClientFailureScreen: ,
defaultServerFailureScreen: ,
diff --git a/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx b/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx
index 1b6dc20b85b9..9396bdf6fe0b 100644
--- a/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx
+++ b/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx
@@ -4,12 +4,10 @@ import {DefaultSuccessScreen} from '@components/MultifactorAuthentication/compon
import type {MultifactorAuthenticationScenarioCustomConfig} from '@components/MultifactorAuthentication/config/types';
import {troubleshootMultifactorAuthentication} from '@userActions/MultifactorAuthentication';
import CONST from '@src/CONST';
-import SCREENS from '@src/SCREENS';
export default {
allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS],
action: troubleshootMultifactorAuthentication,
- screen: SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST,
pure: true,
successScreen: } />,
} as const satisfies MultifactorAuthenticationScenarioCustomConfig;
diff --git a/src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx b/src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx
index cdb4ac01e31d..bf3e2e519e3d 100644
--- a/src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx
+++ b/src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx
@@ -5,6 +5,7 @@ import {DefaultClientFailureScreen, DefaultServerFailureScreen} from '@component
import type {MultifactorAuthenticationScenarioCustomConfig} from '@components/MultifactorAuthentication/config/types';
import {useMultifactorAuthenticationState} from '@components/MultifactorAuthentication/Context';
import {changePINForCard} from '@libs/actions/MultifactorAuthentication';
+import Navigation from '@libs/Navigation/Navigation';
// eslint-disable-next-line no-restricted-imports
import spacing from '@styles/utils/spacing';
import variables from '@styles/variables';
@@ -78,6 +79,12 @@ ChangePINOutcomeSuccessScreen.displayName = 'ChangePINOutcomeSuccessScreen';
export default {
allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS],
action: changePINForCard,
+ callback: async () => {
+ // ChangePINPage is in the RHP and the outcome screen renders in the sibling MFA modal navigator.
+ // Pop it first so closing the modal returns to card details instead of the stale set-PIN screen.
+ Navigation.goBack();
+ return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SHOW_OUTCOME_SCREEN;
+ },
successScreen: ,
defaultClientFailureScreen: ,
defaultServerFailureScreen: ,
diff --git a/src/components/MultifactorAuthentication/config/scenarios/RevealCardDetails.tsx b/src/components/MultifactorAuthentication/config/scenarios/RevealCardDetails.tsx
index 72d281e420ab..62ec26529845 100644
--- a/src/components/MultifactorAuthentication/config/scenarios/RevealCardDetails.tsx
+++ b/src/components/MultifactorAuthentication/config/scenarios/RevealCardDetails.tsx
@@ -7,10 +7,8 @@ import type {
MultifactorAuthenticationScenarioCustomConfig,
} from '@components/MultifactorAuthentication/config/types';
import {revealCardDetailsWithSCA} from '@libs/actions/MultifactorAuthentication';
-import Navigation from '@libs/Navigation/Navigation';
import {setRevealedVirtualCardDetails} from '@libs/RevealedCardSecretsStore';
import CONST from '@src/CONST';
-import ROUTES from '@src/ROUTES';
/**
* Payload type for the REVEAL_CARD_DETAILS scenario.
@@ -61,8 +59,6 @@ export default {
const expiration = typeof callbackInput.body?.expiration === 'string' ? callbackInput.body.expiration : '';
const cvv = typeof callbackInput.body?.cvv === 'string' ? callbackInput.body.cvv : '';
setRevealedVirtualCardDetails(payload.cardID, {pan, expiration, cvv});
- Navigation.closeRHPFlow();
- Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAIN_CARD.getRoute(String(payload.cardID)));
return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SKIP_OUTCOME_SCREEN;
}
diff --git a/src/components/MultifactorAuthentication/config/scenarios/RevealPIN.tsx b/src/components/MultifactorAuthentication/config/scenarios/RevealPIN.tsx
index d76369f62029..a4a0c3ad4127 100644
--- a/src/components/MultifactorAuthentication/config/scenarios/RevealPIN.tsx
+++ b/src/components/MultifactorAuthentication/config/scenarios/RevealPIN.tsx
@@ -7,10 +7,8 @@ import type {
MultifactorAuthenticationScenarioCustomConfig,
} from '@components/MultifactorAuthentication/config/types';
import {revealPINForCard} from '@libs/actions/MultifactorAuthentication';
-import Navigation from '@libs/Navigation/Navigation';
import {setRevealedPhysicalCardPin} from '@libs/RevealedCardSecretsStore';
import CONST from '@src/CONST';
-import ROUTES from '@src/ROUTES';
/**
* Payload type for the REVEAL_PIN scenario.
@@ -58,8 +56,6 @@ export default {
if (isSuccessful && isRevealPINPayload(payload)) {
const pin = typeof callbackInput.body?.pin === 'string' ? callbackInput.body.pin : '';
setRevealedPhysicalCardPin(payload.cardID, pin);
- Navigation.closeRHPFlow();
- Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAIN_CARD.getRoute(String(payload.cardID)));
return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SKIP_OUTCOME_SCREEN;
}
diff --git a/src/components/MultifactorAuthentication/config/types.ts b/src/components/MultifactorAuthentication/config/types.ts
index 7cb9ea23c90e..07f8ad41e7e0 100644
--- a/src/components/MultifactorAuthentication/config/types.ts
+++ b/src/components/MultifactorAuthentication/config/types.ts
@@ -14,7 +14,6 @@ import type {
} from '@libs/MultifactorAuthentication/shared/types';
import type CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
-import type SCREENS from '@src/SCREENS';
import type IconAsset from '@src/types/utils/IconAsset';
import type {MULTIFACTOR_AUTHENTICATION_PROMPT_UI, MultifactorAuthenticationScenarioPayload} from './index';
@@ -74,11 +73,6 @@ type MultifactorAuthenticationScenarioResponse = {
body?: Record;
};
-/**
- * Multifactor authentication screen identifiers.
- */
-type MultifactorAuthenticationScreen = ValueOf;
-
/**
* Pure function type for scenario actions that return HTTP response and reason.
*/
@@ -92,7 +86,6 @@ type MultifactorAuthenticationScenarioPureMethod = EmptyObject> = {
action: MultifactorAuthenticationScenarioPureMethod;
allowedAuthenticationMethods: Array>;
- screen?: MultifactorAuthenticationScreen;
/**
* Whether the scenario does not require any additional parameters except for the native biometrics data.
diff --git a/src/components/MultifactorAuthentication/mfaNavigation.ts b/src/components/MultifactorAuthentication/mfaNavigation.ts
new file mode 100644
index 000000000000..d204808552d1
--- /dev/null
+++ b/src/components/MultifactorAuthentication/mfaNavigation.ts
@@ -0,0 +1,77 @@
+import {createNavigationContainerRef, StackActions} from '@react-navigation/native';
+import type {MultifactorAuthenticationModalNavigatorParamList} from '@libs/Navigation/types';
+
+/**
+ * Internal placeholder used only as a mount-time buffer inside this module.
+ *
+ * Intentionally NOT exported on `MultifactorAuthenticationModalNavigatorParamList`
+ * and NOT added to `SCREENS.ts`: doing so would leak an implementation detail
+ * and let external callers `navigate('MFA_Initial')`, which we don't want.
+ */
+const MFA_INITIAL_SCREEN = 'MFA_Initial' as const;
+
+type MultifactorAuthenticationModalNavigatorInternalParamList = MultifactorAuthenticationModalNavigatorParamList & Record;
+
+const mfaNavigationRef = createNavigationContainerRef();
+
+let pendingNavigation: {screen: string; params?: Record} | undefined;
+
+/**
+ * True once the placeholder MFA_INITIAL_SCREEN has laid out at least once.
+ *
+ * On iOS native-stack, INITIAL.onLayout fires synchronously on mount — before
+ * `process()` reaches `navigate()` — so the buffer is empty when
+ * `handleInitialScreenLayout` runs and the buffered push never fires. With
+ * this flag, `navigate()` pushes directly when INITIAL is already laid out.
+ */
+let hasInitialLaidOut = false;
+
+function navigate(
+ screen: T,
+ ...args: MultifactorAuthenticationModalNavigatorParamList[T] extends undefined ? [] : [MultifactorAuthenticationModalNavigatorParamList[T]]
+) {
+ const params: Record | undefined = args[0];
+
+ // Navigator mounts only while the overlay is visible. Buffer the request
+ // until the placeholder MFA_INITIAL_SCREEN lays out and triggers the push.
+ if (!mfaNavigationRef.isReady()) {
+ pendingNavigation = {screen, params};
+ return;
+ }
+
+ const currentRoute = mfaNavigationRef.getCurrentRoute();
+ if (currentRoute?.name === screen) {
+ return;
+ }
+
+ if (currentRoute?.name === MFA_INITIAL_SCREEN) {
+ if (hasInitialLaidOut) {
+ mfaNavigationRef.dispatch(StackActions.push(screen, params));
+ return;
+ }
+ pendingNavigation = {screen, params};
+ return;
+ }
+
+ mfaNavigationRef.dispatch(StackActions.replace(screen, params));
+}
+
+function handleInitialScreenLayout() {
+ hasInitialLaidOut = true;
+ if (!pendingNavigation || !mfaNavigationRef.isReady()) {
+ return;
+ }
+
+ const {screen, params} = pendingNavigation;
+ pendingNavigation = undefined;
+
+ mfaNavigationRef.dispatch(StackActions.push(screen, params));
+}
+
+function resetMfaNavigation() {
+ pendingNavigation = undefined;
+ hasInitialLaidOut = false;
+}
+
+export {MFA_INITIAL_SCREEN, mfaNavigationRef, navigate, handleInitialScreenLayout, resetMfaNavigation};
+export type {MultifactorAuthenticationModalNavigatorInternalParamList};
diff --git a/src/components/MultifactorAuthentication/useSyncMfaModalNavigatorWithHistory/index.ts b/src/components/MultifactorAuthentication/useSyncMfaModalNavigatorWithHistory/index.ts
new file mode 100644
index 000000000000..139d0dc6f521
--- /dev/null
+++ b/src/components/MultifactorAuthentication/useSyncMfaModalNavigatorWithHistory/index.ts
@@ -0,0 +1,92 @@
+import {useEffect} from 'react';
+import {BackHandler} from 'react-native';
+import getPlatform from '@libs/getPlatform';
+import {cancelPendingMfaMarkerReattach, isMfaMarkerStripInProgress, toggleMfaMarker} from '@libs/Navigation/helpers/mfaModalMarkerPreservation';
+import Navigation from '@libs/Navigation/Navigation';
+import navigationRef from '@libs/Navigation/navigationRef';
+import CONST from '@src/CONST';
+
+function dispatchToggle(isVisible: boolean) {
+ Navigation.isNavigationReady().then(() => toggleMfaMarker(isVisible));
+}
+
+function getHistory(): readonly unknown[] {
+ if (!navigationRef.isReady()) {
+ return [];
+ }
+ return navigationRef.getRootState()?.history ?? [];
+}
+
+/**
+ * Wires browser/Android back into the MFA modal navigator via a synthetic
+ * `CUSTOM_HISTORY_ENTRY_MFA_MODAL_NAVIGATOR` marker on the root history (same
+ * pattern as `useSyncSidePanelWithHistory`).
+ *
+ * Back press does NOT close the modal directly — it re-pins the marker (URL
+ * stays put) and delegates to `requestCancel`, which decides whether to show
+ * the cancel-confirmation modal or close the flow outright based on the
+ * current MFA state.
+ *
+ * Marker lifecycle and back-press subscriptions are split across two effects
+ * so re-subscribing on `requestCancel` change does not toggle the history
+ * marker off/on.
+ *
+ * @param isModalOpen Whether the MFA modal is currently mounted.
+ * @param requestCancel Called on every back press.
+ */
+function useSyncMfaModalNavigatorWithHistory(isModalOpen: boolean, requestCancel: () => void): void {
+ // Push the history marker while the modal is open; pop it on close. Tied to isModalOpen only so re-renders don't churn it.
+ useEffect(() => {
+ if (!isModalOpen) {
+ return;
+ }
+ dispatchToggle(true);
+ return () => {
+ // Cancel pending re-attach so it can't re-inject the marker after close.
+ cancelPendingMfaMarkerReattach();
+ dispatchToggle(false);
+ };
+ }, [isModalOpen]);
+
+ // Subscribe hardware/browser back to requestCancel. Re-subscribes when requestCancel changes — cheap, leaves the marker untouched.
+ useEffect(() => {
+ if (!isModalOpen) {
+ return;
+ }
+
+ // BackHandler is Android-only: react-native-web stubs `addEventListener` with a `console.error`, and iOS has no hardware back.
+ const backSubscription =
+ getPlatform() === CONST.PLATFORM.ANDROID
+ ? BackHandler.addEventListener('hardwareBackPress', () => {
+ requestCancel();
+ return true;
+ })
+ : null;
+
+ let previousHistory = getHistory();
+ const unsubscribe = navigationRef.addListener('state', () => {
+ const currentHistory = getHistory();
+ // Compare presence anywhere in history, not at the top, so a sibling marker pushed
+ // on top of MFA (e.g. side panel) does not spuriously fire requestCancel.
+ const wasInHistory = previousHistory.includes(CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_MFA_MODAL_NAVIGATOR);
+ const isInHistory = currentHistory.includes(CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_MFA_MODAL_NAVIGATOR);
+ previousHistory = currentHistory;
+
+ if (wasInHistory && !isInHistory) {
+ // Skip when goBack stripped the marker — it re-attaches after the pop.
+ if (isMfaMarkerStripInProgress()) {
+ return;
+ }
+ dispatchToggle(true);
+ requestCancel();
+ }
+ });
+
+ return () => {
+ backSubscription?.remove();
+ unsubscribe();
+ };
+ }, [isModalOpen, requestCancel]);
+}
+
+export default useSyncMfaModalNavigatorWithHistory;
diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx
index ffebc26007ff..d57003eaf426 100644
--- a/src/components/TestToolMenu.tsx
+++ b/src/components/TestToolMenu.tsx
@@ -1,22 +1,17 @@
-import React, {useState} from 'react';
-import {Platform, View} from 'react-native';
-import useBiometricRegistrationStatus, {REGISTRATION_STATUS} from '@hooks/useBiometricRegistrationStatus';
+import React from 'react';
+import {Platform} from 'react-native';
import useIsAuthenticated from '@hooks/useIsAuthenticated';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import {useSidebarOrderedReportsActions} from '@hooks/useSidebarOrderedReports';
-import useSingleExecution from '@hooks/useSingleExecution';
import useThemeStyles from '@hooks/useThemeStyles';
-import useWaitForNavigation from '@hooks/useWaitForNavigation';
-import {revokeMultifactorAuthenticationCredentials} from '@libs/actions/MultifactorAuthentication';
import {isUsingStagingApi} from '@libs/ApiUtils';
-import Navigation from '@libs/Navigation/Navigation';
import {setShouldFailAllRequests, setShouldForceOffline, setShouldSimulatePoorConnection} from '@userActions/Network';
import {expireSessionWithDelay, invalidateAuthToken, invalidateCredentials} from '@userActions/Session';
import {setIsDebugModeEnabled, setShouldShowBranchNameInTitle, setShouldUseStagingServer} from '@userActions/User';
import CONFIG from '@src/CONFIG';
import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
+import BiometricsTestToolRow from './BiometricsTestToolRow';
import Button from './Button';
import SoftKillTestToolRow from './SoftKillTestToolRow';
import Switch from './Switch';
@@ -33,33 +28,10 @@ function TestToolMenu() {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {clearLHNCache} = useSidebarOrderedReportsActions();
- const [isMFARevokeLoading, setIsMFARevokeLoading] = useState(false);
- const {localCredentialID, isCurrentDeviceRegistered, otherDeviceCount, registrationStatus} = useBiometricRegistrationStatus();
-
- const {singleExecution} = useSingleExecution();
- const waitForNavigate = useWaitForNavigation();
-
- /**
- * The wrapper is needed to prevent rapid double‑taps on native from triggering multiple navigations.
- * Context: https://github.com/Expensify/App/pull/79475#discussion_r2708230681
- */
- const navigateToBiometricsTestPage = singleExecution(
- waitForNavigate(() => {
- Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_BIOMETRICS_TEST);
- }),
- );
// Check if the user is authenticated to show options that require authentication
const isAuthenticated = useIsAuthenticated();
- const statusTextMap = {
- [REGISTRATION_STATUS.NEVER_REGISTERED]: translate('multifactorAuthentication.biometricsTest.statusNeverRegistered'),
- [REGISTRATION_STATUS.NOT_REGISTERED]: translate('multifactorAuthentication.biometricsTest.statusNotRegistered'),
- [REGISTRATION_STATUS.REGISTERED_OTHER_DEVICE]: translate('multifactorAuthentication.biometricsTest.statusRegisteredOtherDevice', {count: otherDeviceCount}),
- [REGISTRATION_STATUS.REGISTERED_THIS_DEVICE]: translate('multifactorAuthentication.biometricsTest.statusRegisteredThisDevice'),
- };
- const biometricsTitle = translate('multifactorAuthentication.biometricsTest.troubleshootBiometricsStatus', {status: statusTextMap[registrationStatus]});
-
return (
<>
- {/* Allows testing the biometric multifactor authentication flow */}
-
-
- navigateToBiometricsTestPage()}
- />
- {isCurrentDeviceRegistered && !!localCredentialID && (
- {
- setIsMFARevokeLoading(true);
- await revokeMultifactorAuthenticationCredentials({onlyKeyID: localCredentialID});
- setIsMFARevokeLoading(false);
- }}
- />
- )}
-
-
+ {/* Allows testing and revoking biometric multifactor authentication */}
+
>
)}
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index 91efe6d46387..4f975c07ea0a 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -9,6 +9,7 @@ import GPSTripStateChecker from '@components/GPSTripStateChecker';
import {KeyboardDismissibleFlatListContextProvider} from '@components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext';
import KYCWallContextProvider from '@components/KYCWall/KYCWallContext';
import LockedAccountModalProvider from '@components/LockedAccountModalProvider';
+import {MultifactorAuthenticationContextProviders} from '@components/MultifactorAuthentication/Context';
import OpenAppFailureModal from '@components/OpenAppFailureModal';
import OptionsListContextProvider from '@components/OptionListContextProvider';
import PriorityModeController from '@components/PriorityModeController';
@@ -58,6 +59,7 @@ import {ShareModalStackNavigator} from './ModalStackNavigators';
import ExplanationModalNavigator from './Navigators/ExplanationModalNavigator';
import FeatureTrainingModalNavigator from './Navigators/FeatureTrainingModalNavigator';
import MigratedUserWelcomeModalNavigator from './Navigators/MigratedUserWelcomeModalNavigator';
+import MultifactorAuthenticationModalNavigator from './Navigators/MultifactorAuthenticationModalNavigator';
import OnboardingModalNavigator from './Navigators/OnboardingModalNavigator';
import TestDriveModalNavigator from './Navigators/TestDriveModalNavigator';
import TestToolsModalNavigator from './Navigators/TestToolsModalNavigator';
@@ -178,6 +180,7 @@ function AuthScreens() {
SearchContextProvider,
LockedAccountModalProvider,
DelegateNoAccessModalProvider,
+ MultifactorAuthenticationContextProviders,
]}
>
@@ -377,6 +380,7 @@ function AuthScreens() {
/>
+
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index 804b6c97024a..7d8b587d23c9 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -92,27 +92,9 @@ const OPTIONS_PER_SCREEN: Partial
[SCREENS.TRAVEL.WORKSPACE_ADDRESS]: {
animationTypeForReplace: 'push',
},
- [SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE]: {
- animationTypeForReplace: 'push',
- },
- [SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST]: {
- animationTypeForReplace: 'push',
- },
- [SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_SUCCESS]: {
- animationTypeForReplace: 'push',
- },
- [SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_FAILURE]: {
- animationTypeForReplace: 'push',
- },
- [SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT]: {
- animationTypeForReplace: 'push',
- },
[SCREENS.MULTIFACTOR_AUTHENTICATION.REVOKE]: {
animationTypeForReplace: 'push',
},
- [SCREENS.MULTIFACTOR_AUTHENTICATION.NOT_FOUND]: {
- animationTypeForReplace: 'push',
- },
[SCREENS.WORKSPACE.DYNAMIC_CATEGORIES_IMPORTED]: {
animationTypeForReplace: 'push',
},
@@ -1287,14 +1269,8 @@ const WorkspacesDomainModalStackNavigator = createModalStackNavigator({
- [SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE]: () => require('../../../../pages/MultifactorAuthentication/ValidateCodePage').default,
- [SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST]: () => require('../../../../pages/MultifactorAuthentication/BiometricsTestPage').default,
- [SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_SUCCESS]: () => require('@pages/MultifactorAuthentication/OutcomePage').default,
- [SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_FAILURE]: () => require('@pages/MultifactorAuthentication/OutcomePage').default,
- [SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT]: () => require('../../../../pages/MultifactorAuthentication/PromptPage').default,
- [SCREENS.MULTIFACTOR_AUTHENTICATION.REVOKE]: () => require('@pages/MultifactorAuthentication/RevokePage').default,
[SCREENS.MULTIFACTOR_AUTHENTICATION.AUTHORIZE_TRANSACTION]: () => require('../../../../pages/MultifactorAuthentication/AuthorizeTransactionPage').default,
- [SCREENS.MULTIFACTOR_AUTHENTICATION.NOT_FOUND]: () => require('../../../../pages/ErrorPage/NotFoundPage').default,
+ [SCREENS.MULTIFACTOR_AUTHENTICATION.REVOKE]: () => require('@pages/MultifactorAuthentication/RevokePage').default,
});
export {
diff --git a/src/libs/Navigation/AppNavigator/Navigators/MultifactorAuthenticationModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/MultifactorAuthenticationModalNavigator.tsx
new file mode 100644
index 000000000000..f84e3e6d48c6
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/Navigators/MultifactorAuthenticationModalNavigator.tsx
@@ -0,0 +1,228 @@
+import {BaseNavigationContainer, NavigationIndependentTree} from '@react-navigation/core';
+import type {StackCardInterpolationProps} from '@react-navigation/stack';
+import React, {useEffect, useState} from 'react';
+import {StyleSheet, View} from 'react-native';
+import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
+import {DefaultCancelConfirmModal} from '@components/MultifactorAuthentication/components/Modals';
+import {useMultifactorAuthentication, useMultifactorAuthenticationActions, useMultifactorAuthenticationState} from '@components/MultifactorAuthentication/Context';
+import type {MultifactorAuthenticationModalNavigatorInternalParamList} from '@components/MultifactorAuthentication/mfaNavigation';
+import {handleInitialScreenLayout, MFA_INITIAL_SCREEN, mfaNavigationRef, resetMfaNavigation} from '@components/MultifactorAuthentication/mfaNavigation';
+import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
+import useLocalize from '@hooks/useLocalize';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSidePanelActions from '@hooks/useSidePanelActions';
+import useSidePanelState from '@hooks/useSidePanelState';
+import useTheme from '@hooks/useTheme';
+import useThemePreference from '@hooks/useThemePreference';
+import useThemeStyles from '@hooks/useThemeStyles';
+import getNavigationBaseTheme from '@libs/Navigation/getNavigationBaseTheme';
+import Navigation from '@libs/Navigation/Navigation';
+import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
+import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation';
+import Presentation from '@libs/Navigation/PlatformStackNavigation/navigationOptions/presentation';
+import useModalCardStyleInterpolator from '@navigation/AppNavigator/useModalCardStyleInterpolator';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import SCREENS from '@src/SCREENS';
+import type ReactComponentModule from '@src/types/utils/ReactComponentModule';
+
+type Phase = 'open' | 'closing' | 'closed';
+
+const Stack = createPlatformStackNavigator();
+
+const loadValidateCodePage = () => require('../../../../pages/MultifactorAuthentication/ValidateCodePage').default;
+const loadOutcomePage = () => require('../../../../pages/MultifactorAuthentication/OutcomePage').default;
+const loadPromptPage = () => require('../../../../pages/MultifactorAuthentication/PromptPage').default;
+
+// Placeholder rendered as the initial route. onLayout triggers the deferred
+// push so the card-style interpolator has a measured width for the slide.
+function TransparentScreen() {
+ return (
+
+ );
+}
+
+TransparentScreen.displayName = 'TransparentScreen';
+
+/**
+ * Closes the SidePanel on activation and latches `true` only after the close transition ends.
+ * Prevents the Stack from mounting while the parent View width is still racing.
+ */
+function useAwaitSidePanelClose(shouldMount: boolean): boolean {
+ const {shouldHideSidePanel, isSidePanelTransitionEnded} = useSidePanelState();
+ const {closeSidePanel} = useSidePanelActions();
+ const [isSidePanelClosed, setIsSidePanelClosed] = useState(false);
+
+ useEffect(() => {
+ if (!shouldMount) {
+ return;
+ }
+ closeSidePanel();
+ // closeSidePanel ref is unstable; excluding it prevents the effect from firing twice per activation.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [shouldMount]);
+
+ if (shouldMount && !isSidePanelClosed && shouldHideSidePanel && isSidePanelTransitionEnded) {
+ setIsSidePanelClosed(true);
+ } else if (!shouldMount && isSidePanelClosed) {
+ setIsSidePanelClosed(false);
+ }
+
+ return isSidePanelClosed;
+}
+
+function MultifactorAuthenticationModalNavigator() {
+ const {isCancelConfirmVisible, isModalOpen, scenario} = useMultifactorAuthenticationState();
+ const {requestCancel, hideCancelConfirm, confirmCancel} = useMultifactorAuthentication();
+ const {dispatch} = useMultifactorAuthenticationActions();
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const theme = useTheme();
+ const themePreference = useThemePreference();
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const [phase, setPhase] = useState(isModalOpen ? 'open' : 'closed');
+ const backdropProgress = useSharedValue(0);
+ const modalCardStyleInterpolator = useModalCardStyleInterpolator();
+ const CancelConfirmModal = scenario?.modals.cancelConfirmation ?? DefaultCancelConfirmModal;
+
+ const isStackReadyToMount = useAwaitSidePanelClose(phase !== 'closed');
+
+ // 'closing' outlives isModalOpen=false so the slide-out animation can play.
+ if (isModalOpen && phase !== 'open') {
+ setPhase('open');
+ } else if (!isModalOpen && phase === 'open') {
+ setPhase('closing');
+ }
+
+ const navigationThemeBase = getNavigationBaseTheme(themePreference);
+ const navigationTheme = {
+ ...navigationThemeBase,
+ colors: {
+ ...navigationThemeBase.colors,
+ background: shouldUseNarrowLayout ? theme.appBG : 'transparent',
+ },
+ };
+
+ useEffect(() => {
+ if (phase === 'open') {
+ backdropProgress.set(withTiming(1, {duration: CONST.ANIMATED_TRANSITION}));
+ return;
+ }
+ if (phase !== 'closing') {
+ return;
+ }
+ if (mfaNavigationRef.isReady() && mfaNavigationRef.canGoBack()) {
+ mfaNavigationRef.goBack();
+ }
+ backdropProgress.set(withTiming(0, {duration: CONST.ANIMATED_TRANSITION}));
+ const handle = Navigation.runAfterUpcomingTransition(() => {
+ resetMfaNavigation();
+ setPhase('closed');
+ dispatch({type: 'RESET'});
+ });
+ return () => handle.cancel();
+ }, [phase, backdropProgress, dispatch]);
+
+ const backdropAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: backdropProgress.get() * variables.overlayOpacity,
+ }));
+
+ if (phase === 'closed') {
+ return null;
+ }
+
+ return (
+
+ {!shouldUseNarrowLayout && (
+
+
+
+ )}
+
+ {isStackReadyToMount && (
+
+
+ modalCardStyleInterpolator({props, enter: {kind: 'slide-from-width'}}),
+ },
+ }}
+ >
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+MultifactorAuthenticationModalNavigator.displayName = 'MultifactorAuthenticationModalNavigator';
+
+export default MultifactorAuthenticationModalNavigator;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
index 81851f3303f4..39b2db1ef9dc 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
@@ -5,7 +5,6 @@ import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {Animated, DeviceEventEmitter, InteractionManager} from 'react-native';
import {DialogLabelProvider} from '@components/DialogLabelContext';
import NoDropZone from '@components/DragAndDrop/NoDropZone';
-import {MultifactorAuthenticationContextProviders} from '@components/MultifactorAuthentication/Context';
import {
animatedWideRHPWidth,
expandedRHPProgress,
@@ -207,275 +206,273 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) {
return (
-
-
- {!shouldUseNarrowLayout && (
-
- )}
- {/* This one is to limit the outer Animated.View and allow the background to be pressable */}
- {/* Without it, the transparent half of the narrow format RHP card would cover the pressable part of the overlay */}
-
-
-
-
- {
- InteractionManager.runAfterInteractions(() => clearTwoFactorAuthData(true));
- },
- }}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- const options = modalStackScreenOptions(props);
- return {...options, animation: animationEnabledOnSearchReport ? Animations.SLIDE_FROM_RIGHT : Animations.NONE};
- }}
- />
- {
- const options = modalStackScreenOptions(props);
- return {...options, animation: isSmallScreenWidth ? Animations.SLIDE_FROM_RIGHT : Animations.NONE};
- }}
- />
- {
- const options = modalStackScreenOptions(props);
- return {...options, animation: isSmallScreenWidth ? Animations.SLIDE_FROM_RIGHT : Animations.NONE};
- }}
- />
-
-
-
-
-
-
- {/* The third and second overlays are displayed here to cover RHP screens wider than the currently focused screen. */}
- {/* Clicking on these overlays redirects you to the RHP screen below them. */}
- {/* The width of these overlays is equal to the width of the screen minus the width of the currently focused RHP screen (positionRightValue) */}
- {!shouldUseNarrowLayout && }
- {!shouldUseNarrowLayout && shouldRenderTertiaryOverlay && (
-
- )}
-
-
+
+ {!shouldUseNarrowLayout && (
+
+ )}
+ {/* This one is to limit the outer Animated.View and allow the background to be pressable */}
+ {/* Without it, the transparent half of the narrow format RHP card would cover the pressable part of the overlay */}
+
+
+
+
+ {
+ InteractionManager.runAfterInteractions(() => clearTwoFactorAuthData(true));
+ },
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ const options = modalStackScreenOptions(props);
+ return {...options, animation: animationEnabledOnSearchReport ? Animations.SLIDE_FROM_RIGHT : Animations.NONE};
+ }}
+ />
+ {
+ const options = modalStackScreenOptions(props);
+ return {...options, animation: isSmallScreenWidth ? Animations.SLIDE_FROM_RIGHT : Animations.NONE};
+ }}
+ />
+ {
+ const options = modalStackScreenOptions(props);
+ return {...options, animation: isSmallScreenWidth ? Animations.SLIDE_FROM_RIGHT : Animations.NONE};
+ }}
+ />
+
+
+
+
+
+
+ {/* The third and second overlays are displayed here to cover RHP screens wider than the currently focused screen. */}
+ {/* Clicking on these overlays redirects you to the RHP screen below them. */}
+ {/* The width of these overlays is equal to the width of the screen minus the width of the currently focused RHP screen (positionRightValue) */}
+ {!shouldUseNarrowLayout && }
+ {!shouldUseNarrowLayout && shouldRenderTertiaryOverlay && (
+
+ )}
+
);
}
diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts
index 8f8b75fe3907..d9ee7b39fee4 100644
--- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts
+++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts
@@ -17,6 +17,7 @@ import type {
RemoveFullscreenUnderRHPActionType,
ReplaceActionType,
ReplaceFullscreenUnderRHPActionType,
+ ToggleMfaModalNavigatorWithHistoryActionType,
ToggleSidePanelWithHistoryActionType,
} from './types';
@@ -564,6 +565,30 @@ function handleToggleSidePanelWithHistoryAction(state: StackNavigationState, action: ToggleMfaModalNavigatorWithHistoryActionType) {
+ if (!state?.history) {
+ return state;
+ }
+
+ if (action.payload.isVisible && state.history.at(-1) !== CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_MFA_MODAL_NAVIGATOR) {
+ return {...state, history: [...state.history, CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_MFA_MODAL_NAVIGATOR]};
+ }
+
+ if (!action.payload.isVisible) {
+ return {...state, history: state.history.filter((entry) => entry !== CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_MFA_MODAL_NAVIGATOR)};
+ }
+
+ return state;
+}
+
export {
handleDismissModalAction,
handleNavigatingToModalFromModal,
@@ -575,6 +600,7 @@ export {
handleReplaceReportsSplitNavigatorAction,
screensWithEnteringAnimation,
handleToggleSidePanelWithHistoryAction,
+ handleToggleMfaModalNavigatorWithHistoryAction,
getPreInsertedOriginalTabRoute,
clearPreInsertedOriginalTabRoute,
// Exported for unit-test access; not used outside of testing.
diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts
index 11ddd5b6fe80..305f04f3379f 100644
--- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts
+++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts
@@ -18,6 +18,7 @@ import {
handleRemoveFullscreenUnderRHP,
handleReplaceFullscreenUnderRHP,
handleReplaceReportsSplitNavigatorAction,
+ handleToggleMfaModalNavigatorWithHistoryAction,
handleToggleSidePanelWithHistoryAction,
} from './GetStateForActionHandlers';
import syncBrowserHistory from './syncBrowserHistory';
@@ -32,6 +33,7 @@ import type {
ReplaceFullscreenUnderRHPActionType,
RootStackNavigatorAction,
RootStackNavigatorRouterOptions,
+ ToggleMfaModalNavigatorWithHistoryActionType,
ToggleSidePanelWithHistoryActionType,
} from './types';
@@ -67,6 +69,10 @@ function isToggleSidePanelWithHistoryAction(action: RootStackNavigatorAction): a
return action.type === CONST.NAVIGATION.ACTION_TYPE.TOGGLE_SIDE_PANEL_WITH_HISTORY;
}
+function isToggleMfaModalNavigatorWithHistoryAction(action: RootStackNavigatorAction): action is ToggleMfaModalNavigatorWithHistoryActionType {
+ return action.type === CONST.NAVIGATION.ACTION_TYPE.TOGGLE_MFA_MODAL_NAVIGATOR_WITH_HISTORY;
+}
+
function isPreloadAction(action: RootStackNavigatorAction): action is PreloadActionType {
return action.type === CONST.NAVIGATION.ACTION_TYPE.PRELOAD;
}
@@ -146,6 +152,10 @@ function RootStackRouter(options: RootStackNavigatorRouterOptions) {
return handleToggleSidePanelWithHistoryAction(state, action);
}
+ if (isToggleMfaModalNavigatorWithHistoryAction(action)) {
+ return handleToggleMfaModalNavigatorWithHistoryAction(state, action);
+ }
+
if (isOpenWorkspaceSplitAction(action)) {
return handleOpenWorkspaceSplitAction(state, action, configOptions, stackRouter);
}
diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts
index 85d777121f32..ed1cd4702745 100644
--- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts
+++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts
@@ -10,6 +10,12 @@ type RootStackNavigatorActionType =
isVisible: boolean;
};
}
+ | {
+ type: typeof CONST.NAVIGATION.ACTION_TYPE.TOGGLE_MFA_MODAL_NAVIGATOR_WITH_HISTORY;
+ payload: {
+ isVisible: boolean;
+ };
+ }
| {
type: typeof CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL;
}
@@ -58,6 +64,10 @@ type ToggleSidePanelWithHistoryActionType = RootStackNavigatorActionType & {
type: typeof CONST.NAVIGATION.ACTION_TYPE.TOGGLE_SIDE_PANEL_WITH_HISTORY;
};
+type ToggleMfaModalNavigatorWithHistoryActionType = RootStackNavigatorActionType & {
+ type: typeof CONST.NAVIGATION.ACTION_TYPE.TOGGLE_MFA_MODAL_NAVIGATOR_WITH_HISTORY;
+};
+
type PreloadActionType = RootStackNavigatorAction & {type: typeof CONST.NAVIGATION.ACTION_TYPE.PRELOAD};
type PushActionType = StackActionType & {type: typeof CONST.NAVIGATION.ACTION_TYPE.PUSH};
@@ -94,4 +104,5 @@ export type {
RootStackNavigatorAction,
RootStackNavigatorRouterOptions,
ToggleSidePanelWithHistoryActionType,
+ ToggleMfaModalNavigatorWithHistoryActionType,
};
diff --git a/src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension.ts b/src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension.ts
index 4b34589ad849..04b51e5c8843 100644
--- a/src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension.ts
+++ b/src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension.ts
@@ -15,7 +15,26 @@ import {
import type {PendingReveal, RootHistoryState} from './addRootHistoryRouterExtensionUtils';
import {enhanceStateWithHistory} from './utils';
-/** Manages root `state.history` for side-panel + reveal flows; per-branch rationale inline. */
+const CUSTOM_HISTORY_MARKERS: ReadonlySet = new Set([CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_SIDE_PANEL, CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_MFA_MODAL_NAVIGATOR]);
+
+/**
+ * Walks a history array from the end and collects the contiguous run of known
+ * custom-history markers (e.g. `[...routes, SIDE_PANEL, MFA_MODAL_NAVIGATOR]` →
+ * `[SIDE_PANEL, MFA_MODAL_NAVIGATOR]`).
+ *
+ * `enhanceStateWithHistory` regenerates `history` purely from `routes` and drops
+ * any non-route entries; this helper lets the rehydration step re-append the
+ * markers that were on top before rebuild.
+ */
+function extractTrailingCustomMarkers(history: readonly unknown[] | undefined): string[] {
+ if (!history?.length) {
+ return [];
+ }
+ const cutoff = history.findLastIndex((entry) => typeof entry !== 'string' || !CUSTOM_HISTORY_MARKERS.has(entry));
+ return history.slice(cutoff + 1).filter((entry): entry is string => typeof entry === 'string');
+}
+
+/** Manages root `state.history` for side-panel, MFA modal navigator, and reveal flows; per-branch rationale inline. */
function addRootHistoryRouterExtension(
originalRouter: PlatformStackRouterFactory,
) {
@@ -35,10 +54,10 @@ function addRootHistoryRouterExtension 0) {
+ stateWithInitialHistory.history = [...stateWithInitialHistory.history, ...trailingMarkers];
}
return stateWithInitialHistory;
diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts
index 63e8fc8ecb43..d82b1a5257f4 100644
--- a/src/libs/Navigation/Navigation.ts
+++ b/src/libs/Navigation/Navigation.ts
@@ -40,6 +40,7 @@ import isSideModalNavigator from './helpers/isSideModalNavigator';
import linkTo from './helpers/linkTo';
import getMinimalAction from './helpers/linkTo/getMinimalAction';
import type {LinkToOptions} from './helpers/linkTo/types';
+import {popAndRealignMfaMarker} from './helpers/mfaModalMarkerPreservation';
import replaceWithSplitNavigator from './helpers/replaceWithSplitNavigator';
import setNavigationActionToMicrotaskQueue from './helpers/setNavigationActionToMicrotaskQueue';
import {linkingConfig} from './linkingConfig';
@@ -517,17 +518,24 @@ function goBack(backToRoute?: Route, options?: GoBackOptions) {
const runImmediately = !options?.waitForTransition;
TransitionTracker.runAfterTransitions({
callback: () => {
- if (backToRoute) {
- goUp(backToRoute, options);
- } else if (shouldPopToSidebar) {
- popToSidebar();
- } else if (!navigationRef.current?.canGoBack()) {
+ if (!backToRoute && !shouldPopToSidebar && !navigationRef.current?.canGoBack()) {
Log.hmmm('[Navigation] Unable to go back');
return;
- } else {
- navigationRef.current?.goBack();
}
+ popAndRealignMfaMarker(
+ () => {
+ if (backToRoute) {
+ goUp(backToRoute, options);
+ } else if (shouldPopToSidebar) {
+ popToSidebar();
+ } else {
+ navigationRef.current?.goBack();
+ }
+ },
+ (callback) => TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}),
+ );
+
if (options?.afterTransition) {
TransitionTracker.runAfterTransitions({callback: options.afterTransition, waitForUpcomingTransition: true});
}
@@ -682,6 +690,25 @@ function isNavigationReady(): Promise {
return navigationIsReadyPromise;
}
+/**
+ * Runs the callback after any active navigation transition completes. If no transitions are
+ * active, the callback fires synchronously. Use this when you need to defer work behind an
+ * in-flight transition but the work is not itself a Navigation call (e.g. pushing on an
+ * independent navigator like the MFA modal).
+ */
+function runAfterTransition(callback: () => void) {
+ return TransitionTracker.runAfterTransitions({callback});
+}
+
+/**
+ * Like {@link runAfterTransition} but waits for the next transition to start before queuing the
+ * callback (with {@link CONST.MAX_TRANSITION_START_WAIT_MS} safety net). Use after dispatching a
+ * navigation action whose transition has not yet started.
+ */
+function runAfterUpcomingTransition(callback: () => void) {
+ return TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true});
+}
+
function setIsNavigationReady() {
goToPendingRoute();
resolveNavigationIsReadyPromise();
@@ -1201,6 +1228,8 @@ export default {
getActiveRouteWithoutParams,
getReportRHPActiveRoute,
goBack,
+ runAfterTransition,
+ runAfterUpcomingTransition,
isNavigationReady,
setIsNavigationReady,
getTopmostReportId,
diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx
index 34eed088e942..d87fe2f3645d 100644
--- a/src/libs/Navigation/NavigationRoot.tsx
+++ b/src/libs/Navigation/NavigationRoot.tsx
@@ -1,5 +1,5 @@
import type {NavigationState} from '@react-navigation/native';
-import {DarkTheme, DefaultTheme, findFocusedRoute, NavigationContainer} from '@react-navigation/native';
+import {findFocusedRoute, NavigationContainer} from '@react-navigation/native';
import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding';
import * as Sentry from '@sentry/react-native';
import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react';
@@ -15,7 +15,6 @@ import Log from '@libs/Log';
import {setupNavigationFocusReturn, teardownNavigationFocusReturn} from '@libs/NavigationFocusReturn';
import shouldOpenLastVisitedPath from '@libs/shouldOpenLastVisitedPath';
import {getPathFromURL} from '@libs/Url';
-import {getBaseTheme} from '@styles/theme/utils';
import {updateLastVisitedPath} from '@userActions/App';
import {updateOnboardingLastVisitedPath} from '@userActions/Welcome';
import CONST from '@src/CONST';
@@ -27,6 +26,7 @@ import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import AppNavigator from './AppNavigator';
import {cleanPreservedNavigatorStates} from './AppNavigator/createSplitNavigator/usePreserveNavigatorState';
+import getNavigationBaseTheme from './getNavigationBaseTheme';
import getActiveTabName from './helpers/getActiveTabName';
import getAdaptedStateFromPath from './helpers/getAdaptedStateFromPath';
import getPathFromState from './helpers/getPathFromState';
@@ -159,7 +159,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
// https://reactnavigation.org/docs/themes
const navigationTheme = useMemo(() => {
- const defaultNavigationTheme = getBaseTheme(themePreference) === CONST.THEME.DARK ? DarkTheme : DefaultTheme;
+ const defaultNavigationTheme = getNavigationBaseTheme(themePreference);
return {
...defaultNavigationTheme,
diff --git a/src/libs/Navigation/getNavigationBaseTheme.ts b/src/libs/Navigation/getNavigationBaseTheme.ts
new file mode 100644
index 000000000000..751f8e329c8e
--- /dev/null
+++ b/src/libs/Navigation/getNavigationBaseTheme.ts
@@ -0,0 +1,22 @@
+import {DarkTheme, DefaultTheme} from '@react-navigation/native';
+import type {ThemePreferenceWithoutSystem} from '@styles/theme/types';
+import {getBaseTheme} from '@styles/theme/utils';
+import CONST from '@src/CONST';
+
+/**
+ * Resolves a user's theme preference to the matching react-navigation base theme.
+ *
+ * `CONST.THEME` covers more than just `dark` / `light` — high-contrast and system
+ * variants exist as well. A naive `themePreference === CONST.THEME.DARK` check
+ * mis-buckets every non-DARK variant (e.g. `dark-contrast`) into the light base.
+ * Callers must go through `getBaseTheme()` so contrast and system variants
+ * resolve to their underlying DARK/LIGHT base first.
+ *
+ * Use this helper anywhere a `NavigationContainer` / `BaseNavigationContainer`
+ * needs a `theme` prop so the choice stays consistent across every navigator.
+ */
+function getNavigationBaseTheme(themePreference: ThemePreferenceWithoutSystem): typeof DarkTheme | typeof DefaultTheme {
+ return getBaseTheme(themePreference) === CONST.THEME.DARK ? DarkTheme : DefaultTheme;
+}
+
+export default getNavigationBaseTheme;
diff --git a/src/libs/Navigation/helpers/mfaModalMarkerPreservation.ts b/src/libs/Navigation/helpers/mfaModalMarkerPreservation.ts
new file mode 100644
index 000000000000..ab60f25c06f8
--- /dev/null
+++ b/src/libs/Navigation/helpers/mfaModalMarkerPreservation.ts
@@ -0,0 +1,65 @@
+import navigationRef from '@libs/Navigation/navigationRef';
+import CONST from '@src/CONST';
+
+/**
+ * Keeps the MFA modal marker's synthetic browser entry aligned with
+ * `state.history` across `Navigation.goBack`.
+ *
+ * Example: state.history = [A, B, MFA], goBack pops B.
+ * Without bracket: useLinking issues `history.go(-2)` and lands the browser
+ * on A's old snapshot — the MFA marker entry is gone and a later
+ * browser-back skips past the post-pop screen.
+ * With bracket: strip → pop → re-attach. Each toggle drives useLinking to
+ * push/replace a fresh browser entry mapped to the post-pop path.
+ *
+ * Browser-back mid-bracket needs no special handling: re-attach lands the
+ * marker on whatever route useLinking navigated to, listener path stays intact.
+ */
+
+const MFA_MARKER = CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_MFA_MODAL_NAVIGATOR;
+
+let stripInProgress = false;
+
+function isMfaMarkerStripInProgress(): boolean {
+ return stripInProgress;
+}
+
+function toggleMfaMarker(isVisible: boolean): void {
+ navigationRef.dispatch({
+ type: CONST.NAVIGATION.ACTION_TYPE.TOGGLE_MFA_MODAL_NAVIGATOR_WITH_HISTORY,
+ payload: {isVisible},
+ });
+}
+
+/** Invalidates a pending re-attach so its callback no-ops. Used on modal close. */
+function cancelPendingMfaMarkerReattach(): void {
+ stripInProgress = false;
+}
+
+/**
+ * Strips the marker, runs `pop`, then schedules the re-attach via `scheduleReattach`.
+ * Caller owns the transition-aware scheduler (kept here as a parameter so the helper
+ * doesn't reach into the internal TransitionTracker primitive).
+ */
+function popAndRealignMfaMarker(pop: () => void, scheduleReattach: (callback: () => void) => void): void {
+ if (navigationRef.getRootState()?.history?.at(-1) !== MFA_MARKER) {
+ pop();
+ return;
+ }
+
+ stripInProgress = true;
+ toggleMfaMarker(false);
+ try {
+ pop();
+ } finally {
+ scheduleReattach(() => {
+ if (!stripInProgress) {
+ return;
+ }
+ stripInProgress = false;
+ toggleMfaMarker(true);
+ });
+ }
+}
+
+export {cancelPendingMfaMarkerReattach, isMfaMarkerStripInProgress, popAndRealignMfaMarker, toggleMfaMarker};
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index bf1bf12595fe..8d5c1ff2a16b 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -2134,14 +2134,8 @@ const config: LinkingOptions['config'] = {
},
[SCREENS.RIGHT_MODAL.MULTIFACTOR_AUTHENTICATION]: {
screens: {
- [SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE]: ROUTES.MULTIFACTOR_AUTHENTICATION_MAGIC_CODE,
- [SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST]: ROUTES.MULTIFACTOR_AUTHENTICATION_BIOMETRICS_TEST,
- [SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_SUCCESS]: ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME_SUCCESS,
- [SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_FAILURE]: ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME_FAILURE,
- [SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT]: ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.route,
- [SCREENS.MULTIFACTOR_AUTHENTICATION.NOT_FOUND]: ROUTES.MULTIFACTOR_AUTHENTICATION_NOT_FOUND,
- [SCREENS.MULTIFACTOR_AUTHENTICATION.REVOKE]: ROUTES.MULTIFACTOR_AUTHENTICATION_REVOKE,
[SCREENS.MULTIFACTOR_AUTHENTICATION.AUTHORIZE_TRANSACTION]: ROUTES.MULTIFACTOR_AUTHENTICATION_AUTHORIZE_TRANSACTION.route,
+ [SCREENS.MULTIFACTOR_AUTHENTICATION.REVOKE]: ROUTES.MULTIFACTOR_AUTHENTICATION_REVOKE,
},
},
},
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 5901b99bfc67..aa68e3a2c0ee 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -3250,16 +3250,19 @@ type TestToolsModalModalNavigatorParamList = {
};
type MultifactorAuthenticationParamList = {
+ [SCREENS.MULTIFACTOR_AUTHENTICATION.AUTHORIZE_TRANSACTION]: {
+ transactionID: string;
+ };
+ [SCREENS.MULTIFACTOR_AUTHENTICATION.REVOKE]: undefined;
+};
+
+type MultifactorAuthenticationModalNavigatorParamList = {
[SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE]: undefined;
- [SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST]: undefined;
[SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_SUCCESS]: undefined;
[SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_FAILURE]: undefined;
[SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT]: {
promptType: MultifactorAuthenticationPromptType;
};
- [SCREENS.MULTIFACTOR_AUTHENTICATION.AUTHORIZE_TRANSACTION]: {
- transactionID: string;
- };
};
type RootNavigatorParamList = PublicScreensParamList & AuthScreensParamList & SearchFullscreenNavigatorParamList;
@@ -3375,4 +3378,5 @@ export type {
DomainScreenName,
SearchColumnsParamList,
MultifactorAuthenticationParamList,
+ MultifactorAuthenticationModalNavigatorParamList,
};
diff --git a/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx b/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx
index bd839f728299..70cec9e25619 100644
--- a/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx
+++ b/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx
@@ -96,12 +96,20 @@ function MultifactorAuthenticationScenarioAuthorizeTransactionPage({route}: Mult
setIsDenyingTransaction(true);
denyTransaction({transactionID}).then(({reason, httpStatusCode, message}) => {
addBreadcrumb('Deny completed', {transactionID, reason, httpStatusCode, message});
+ // The deny outcome is rendered inside this RHP page (not inside the MFA modal navigator),
+ // so the outcome screen's default CLOSE_MODAL dispatch has no effect here. Override its
+ // close handler to dismiss the RHP instead.
+ const closeRHP = () => {
+ allowNavigatingAwayRef.current = true;
+ Navigation.closeRHPFlow();
+ };
if (reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.FLOW_OUTCOMES.TRANSACTION_DENIED) {
- setDenyOutcomeScreen();
+ setDenyOutcomeScreen();
return;
}
- const failureScreen = reason ? authorizeTransactionConfig.failureScreens[reason] : undefined;
- setDenyOutcomeScreen(failureScreen ?? );
+ const failureElement = reason ? authorizeTransactionConfig.failureScreens[reason] : undefined;
+ const failureWithCloseOverride = failureElement ? React.cloneElement(failureElement, {onClose: closeRHP}) : ;
+ setDenyOutcomeScreen(failureWithCloseOverride);
});
};
diff --git a/src/pages/MultifactorAuthentication/BiometricsTestPage.tsx b/src/pages/MultifactorAuthentication/BiometricsTestPage.tsx
deleted file mode 100644
index a11d070a2014..000000000000
--- a/src/pages/MultifactorAuthentication/BiometricsTestPage.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import React, {useEffect} from 'react';
-// eslint-disable-next-line no-restricted-imports
-import {InteractionManager} from 'react-native';
-import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
-import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import {useMultifactorAuthentication} from '@components/MultifactorAuthentication/Context';
-import ScreenWrapper from '@components/ScreenWrapper';
-import useLocalize from '@hooks/useLocalize';
-import useNetwork from '@hooks/useNetwork';
-import Navigation from '@navigation/Navigation';
-import CONST from '@src/CONST';
-
-function MultifactorAuthenticationBiometricsTestPage() {
- const {executeScenario} = useMultifactorAuthentication();
- const {isOffline} = useNetwork();
- const {translate} = useLocalize();
-
- useEffect(() => {
- if (isOffline) {
- return;
- }
-
- // The reason for using it, despite it being deprecated: https://github.com/Expensify/App/pull/79473#discussion_r2745847379
- InteractionManager.runAfterInteractions(() => executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST));
-
- // This should only fire once - on mount, or if the user switches from offline to online.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isOffline]);
-
- return (
-
- {/*
- The back button needs to be displayed when the user is offline so they can exit the offline page,
- and not get stuck there. If they are online, they will simply be redirected to the next flow page.
- */}
- {isOffline && (
-
- )}
-
-
-
-
- );
-}
-
-MultifactorAuthenticationBiometricsTestPage.displayName = 'MultifactorAuthenticationBiometricsTestPage';
-
-export default MultifactorAuthenticationBiometricsTestPage;
diff --git a/src/pages/MultifactorAuthentication/PromptPage.tsx b/src/pages/MultifactorAuthentication/PromptPage.tsx
index 4a74a29ca6ad..329e27432e81 100644
--- a/src/pages/MultifactorAuthentication/PromptPage.tsx
+++ b/src/pages/MultifactorAuthentication/PromptPage.tsx
@@ -1,85 +1,59 @@
-import React, {useState} from 'react';
+import React from 'react';
import {View} from 'react-native';
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
import Button from '@components/Button';
import FixedFooter from '@components/FixedFooter';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import LoadingIndicator from '@components/LoadingIndicator';
-import {DefaultCancelConfirmModal} from '@components/MultifactorAuthentication/components/Modals';
-import {useMultifactorAuthentication, useMultifactorAuthenticationActions, useMultifactorAuthenticationState, usePromptContent} from '@components/MultifactorAuthentication/Context';
+import {useMultifactorAuthentication, useMultifactorAuthenticationActions, usePromptContent} from '@components/MultifactorAuthentication/Context';
import MultifactorAuthenticationPromptContent from '@components/MultifactorAuthentication/PromptContent';
import ScreenWrapper from '@components/ScreenWrapper';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
-import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import {markHasAcceptedSoftPrompt} from '@libs/actions/MultifactorAuthentication';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
-import type {MultifactorAuthenticationParamList} from '@libs/Navigation/types';
-import Navigation from '@navigation/Navigation';
+import type {MultifactorAuthenticationModalNavigatorParamList} from '@libs/Navigation/types';
import variables from '@styles/variables';
import type SCREENS from '@src/SCREENS';
-type MultifactorAuthenticationPromptPageProps = PlatformStackScreenProps;
+type MultifactorAuthenticationPromptPageProps = PlatformStackScreenProps;
function MultifactorAuthenticationPromptPage({route}: MultifactorAuthenticationPromptPageProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
- const {cancel} = useMultifactorAuthentication();
- const state = useMultifactorAuthenticationState();
+ const {requestCancel} = useMultifactorAuthentication();
const {dispatch} = useMultifactorAuthenticationActions();
const {accountID} = useCurrentUserPersonalDetails();
- const {isOffline} = useNetwork();
const {illustration, title, subtitle, shouldDisplayConfirmButton} = usePromptContent(route.params.promptType);
- const [isCancelModalVisible, setCancelModalVisibility] = useState(false);
-
const onConfirm = () => {
markHasAcceptedSoftPrompt(accountID);
dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true});
};
- const showCancelModal = () => {
- if (isOffline) {
- Navigation.closeRHPFlow();
- } else {
- setCancelModalVisibility(true);
- }
- };
-
- const hideCancelModal = () => {
- setCancelModalVisibility(false);
- };
-
- const cancelFlow = () => {
- if (isCancelModalVisible) {
- hideCancelModal();
- }
- cancel();
- };
-
- const focusTrapConfirmModal = () => {
- setCancelModalVisibility(true);
+ // Outside-clicks and Escape route through the central cancel handler; return
+ // false to keep the focus trap intact while the confirm modal opens.
+ const interceptFocusTrapEscape = () => {
+ requestCancel();
return false;
};
- const CancelConfirmModal = state.scenario?.modals.cancelConfirmation ?? DefaultCancelConfirmModal;
-
return (
@@ -102,12 +76,6 @@ function MultifactorAuthenticationPromptPage({route}: MultifactorAuthenticationP
)}
-
-
);
diff --git a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx
index 4ade82eb3af7..55b02952184c 100644
--- a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx
+++ b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx
@@ -6,7 +6,6 @@ import FormHelpMessage from '@components/FormHelpMessage';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import MagicCodeInput from '@components/MagicCodeInput';
import type {MagicCodeInputHandle} from '@components/MagicCodeInput';
-import {DefaultCancelConfirmModal} from '@components/MultifactorAuthentication/components/Modals';
import {useMultifactorAuthentication, useMultifactorAuthenticationActions, useMultifactorAuthenticationState} from '@components/MultifactorAuthentication/Context';
import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs';
import MultifactorAuthenticationValidateCodeResendButton from '@components/MultifactorAuthentication/ValidateCodeResendButton';
@@ -22,7 +21,6 @@ import AccountUtils from '@libs/AccountUtils';
import {getLatestErrorField, getLatestErrorMessage} from '@libs/ErrorUtils';
import VALUES from '@libs/MultifactorAuthentication/VALUES';
import {isValidValidateCode} from '@libs/ValidationUtils';
-import Navigation from '@navigation/Navigation';
import {clearAccountMessages} from '@userActions/Session';
import {clearValidateCodeActionError, requestValidateCodeAction} from '@userActions/User';
import CONST from '@src/CONST';
@@ -49,12 +47,10 @@ function MultifactorAuthenticationValidateCodePage() {
const [inputCode, setInputCode] = useState('');
const [formError, setFormError] = useState({});
const [canShowError, setCanShowError] = useState(false);
- const {cancel} = useMultifactorAuthentication();
- const [isCancelModalVisible, setCancelModalVisibility] = useState(false);
+ const {requestCancel} = useMultifactorAuthentication();
- const state = useMultifactorAuthenticationState();
const {dispatch} = useMultifactorAuthenticationActions();
- const {continuableError} = state;
+ const {continuableError} = useMultifactorAuthenticationState();
// Refs
const inputRef = useRef(null);
@@ -121,11 +117,13 @@ function MultifactorAuthenticationValidateCodePage() {
clearAccountMessages();
}, [account?.errors]);
- // Reset formError when hasError changes
+ // Clear client-side formError when a backend error arrives so both don't show simultaneously.
+ // Clearing state (not just hiding) avoids stale errors if hasError later becomes false without user input.
useEffect(() => {
if (!hasError) {
return;
}
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- derived-state reset; formError is not in this effect's deps, so writing it cannot re-trigger the effect
setFormError({});
}, [hasError]);
@@ -200,46 +198,27 @@ function MultifactorAuthenticationValidateCodePage() {
dispatch({type: 'SET_VALIDATE_CODE', payload: inputCode});
};
- const showCancelModal = () => {
- if (isOffline) {
- Navigation.closeRHPFlow();
- } else {
- setCancelModalVisibility(true);
- }
- };
-
- const hideCancelModal = () => {
- setCancelModalVisibility(false);
- };
-
- const cancelFlow = () => {
- if (isCancelModalVisible) {
- hideCancelModal();
- }
- cancel();
- };
-
- const focusTrapConfirmModal = () => {
- setCancelModalVisibility(true);
+ // Outside-clicks and Escape route through the central cancel handler; return
+ // false to keep the focus trap intact while the confirm modal opens.
+ const interceptFocusTrapEscape = () => {
+ requestCancel();
return false;
};
- const CancelConfirmModal = state.scenario?.modals.cancelConfirmation ?? DefaultCancelConfirmModal;
-
return (
@@ -281,11 +260,6 @@ function MultifactorAuthenticationValidateCodePage() {
isDisabled={isOffline}
/>
-
);
diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/ChangePINAtATMPage.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/ChangePINAtATMPage.tsx
index 8af61bc4ae9a..c353c5f1d663 100644
--- a/src/pages/settings/Wallet/ExpensifyCardPage/ChangePINAtATMPage.tsx
+++ b/src/pages/settings/Wallet/ExpensifyCardPage/ChangePINAtATMPage.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import OutcomeScreenBase from '@components/MultifactorAuthentication/components/OutcomeScreen/OutcomeScreenBase';
import useLocalize from '@hooks/useLocalize';
+import Navigation from '@libs/Navigation/Navigation';
import variables from '@styles/variables';
function ChangePINAtATMPage() {
@@ -14,6 +15,7 @@ function ChangePINAtATMPage() {
iconHeight={variables.modalTopIconHeight}
title={translate('cardPage.changePinAtATM')}
subtitle={translate('cardPage.changePinAtATMDescription')}
+ onClose={() => Navigation.closeRHPFlow()}
/>
);
}
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 49f2806773f5..f27fa26ff420 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -5669,6 +5669,8 @@ const staticStyles = (theme: ThemeColors) =>
// We have to use 9998 here as sidePanel has to be displayed right under popovers which have z-index of 9999
sidePanelContainer: {zIndex: variables.sidePanelZIndex},
+ mfaModalNavigatorRoot: {zIndex: variables.modalBaseZIndex},
+
reportPreviewArrowButton: {
borderRadius: 50,
width: variables.w28,
diff --git a/tests/ui/TestToolMenuBiometricsTest.tsx b/tests/ui/TestToolMenuBiometricsTest.tsx
index ef75f4233a8f..c26e8063e1de 100644
--- a/tests/ui/TestToolMenuBiometricsTest.tsx
+++ b/tests/ui/TestToolMenuBiometricsTest.tsx
@@ -3,6 +3,7 @@ import {fireEvent, render, screen} from '@testing-library/react-native';
import React from 'react';
import TestToolMenu from '@components/TestToolMenu';
import MULTIFACTOR_AUTHENTICATION_VALUES from '@libs/MultifactorAuthentication/VALUES';
+import CONST from '@src/CONST';
const REGISTRATION_STATUS = MULTIFACTOR_AUTHENTICATION_VALUES.REGISTRATION_STATUS;
@@ -52,11 +53,6 @@ jest.mock('@hooks/useSidebarOrderedReports', () => ({
useSidebarOrderedReportsActions: () => ({clearLHNCache: jest.fn()}),
}));
-jest.mock('@hooks/useSingleExecution', () => ({
- __esModule: true,
- default: () => ({singleExecution: (fn: () => void) => fn}),
-}));
-
jest.mock('@hooks/useThemeStyles', () => ({
__esModule: true,
default: () =>
@@ -68,11 +64,6 @@ jest.mock('@hooks/useThemeStyles', () => ({
),
}));
-jest.mock('@hooks/useWaitForNavigation', () => ({
- __esModule: true,
- default: () => (fn: () => void) => fn,
-}));
-
const mockRevokeCredentials = jest.fn().mockResolvedValue({httpStatusCode: 200});
jest.mock('@libs/actions/MultifactorAuthentication', () => ({
revokeMultifactorAuthenticationCredentials: (...args: unknown[]): Promise<{httpStatusCode: number}> => mockRevokeCredentials(...args) as Promise<{httpStatusCode: number}>,
@@ -83,10 +74,15 @@ jest.mock('@libs/ApiUtils', () => ({
getCommandURL: () => 'https://test-api.expensify.com/api/Ping?',
}));
-jest.mock('@libs/Navigation/Navigation', () => ({
- navigate: jest.fn(),
- getActiveRouteWithoutParams: jest.fn(() => ''),
- isNavigationReady: jest.fn(() => Promise.resolve()),
+const mockExecuteScenario = jest.fn().mockResolvedValue(undefined);
+jest.mock('@components/MultifactorAuthentication/Context', () => ({
+ useMultifactorAuthentication: () => ({
+ executeScenario: mockExecuteScenario,
+ cancel: jest.fn(),
+ requestCancel: jest.fn(),
+ hideCancelConfirm: jest.fn(),
+ confirmCancel: jest.fn(),
+ }),
}));
jest.mock('@userActions/Network', () => ({
@@ -257,11 +253,14 @@ describe('TestToolMenu biometrics', () => {
expect(mockRevokeCredentials).toHaveBeenCalledWith({onlyKeyID: 'key-abc'});
});
- it('always shows the Test button', () => {
+ it('always shows the Test button and invokes executeScenario with BIOMETRICS_TEST when pressed', () => {
setBiometricStatus({registrationStatus: REGISTRATION_STATUS.NEVER_REGISTERED});
render();
- screen.getByText('multifactorAuthentication.biometricsTest.test');
+ const testButton = screen.getByText('multifactorAuthentication.biometricsTest.test');
+ fireEvent.press(testButton);
+
+ expect(mockExecuteScenario).toHaveBeenCalledWith(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST);
});
});
diff --git a/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts b/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts
index 2bb208ce6946..cabd00ce8fe6 100644
--- a/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts
+++ b/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts
@@ -1,7 +1,6 @@
import MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG from '@components/MultifactorAuthentication/config/scenarios';
import type {MultifactorAuthenticationScenarioConfigRecord} from '@components/MultifactorAuthentication/config/types';
import CONST from '@src/CONST';
-import SCREENS from '@src/SCREENS';
describe('MultifactorAuthentication Scenarios Config', () => {
it('should have all required properties for every scenario config', () => {
@@ -32,7 +31,6 @@ describe('MultifactorAuthentication Scenarios Config', () => {
expect(biometricsTestScenario).toBeDefined();
expect(biometricsTestScenario.allowedAuthenticationMethods).toStrictEqual([CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS]);
- expect(biometricsTestScenario.screen).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST);
expect(biometricsTestScenario.pure).toBe(true);
expect(biometricsTestScenario.action).toBeDefined();
});
@@ -67,7 +65,6 @@ describe('MultifactorAuthentication Scenarios Config', () => {
const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG as MultifactorAuthenticationScenarioConfigRecord;
const biometricsTestConfig = config[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST];
- // Invoke the callback with successful authentication and valid response data
const callbackResult = await biometricsTestConfig.callback?.(
true,
{
@@ -78,8 +75,6 @@ describe('MultifactorAuthentication Scenarios Config', () => {
undefined,
);
- // Verify that the callback returns SHOW_OUTCOME_SCREEN, indicating
- // the MFA flow should navigate to the outcome screen
expect(callbackResult).toBe(CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SHOW_OUTCOME_SCREEN);
});
diff --git a/tests/unit/hooks/useSplitContextHooks.test.tsx b/tests/unit/hooks/useSplitContextHooks.test.tsx
index 9bb8ea463525..71a5ca4d37cd 100644
--- a/tests/unit/hooks/useSplitContextHooks.test.tsx
+++ b/tests/unit/hooks/useSplitContextHooks.test.tsx
@@ -17,11 +17,9 @@ import {
import type {CustomStatusBarAndBackgroundActionsContextType, CustomStatusBarAndBackgroundStateContextType} from '@components/CustomStatusBarAndBackground/types';
import {DragAndDropActionsContext, DragAndDropStateContext, useDragAndDropActions, useDragAndDropState} from '@components/DragAndDrop/Provider/DragAndDropContext';
import type {DragAndDropActionsContextType, DragAndDropStateContextType} from '@components/DragAndDrop/Provider/types';
-import MultifactorAuthenticationStateProvider, {
- DEFAULT_STATE,
- useMultifactorAuthenticationActions,
- useMultifactorAuthenticationState,
-} from '@components/MultifactorAuthentication/Context/State';
+import {useMultifactorAuthenticationActions} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationActionsContext';
+import {DEFAULT_STATE, MultifactorAuthenticationStateProvider} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders';
+import {useMultifactorAuthenticationState} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationStateContext';
/**
* Creates a mock SharedValue that satisfies the SharedValue interface used in reanimated.