[ECUK In-App 3DS] MFA: isolate 3DS flow into self-contained modal navigator - #89992
Conversation
MFA screens (magic code, prompt, outcome) lived inside RightModalNavigator as regular RHP routes. This created coupling between MFA flow lifecycle and app navigation state — cancelling, refreshing, or deep-linking could leave stale MFA routes in the stack. Move MFA to a self-contained overlay with its own NavigationIndependentTree and dedicated mfaNavigation module. The overlay mounts/unmounts based on MFA context state, uses the Expensify modal card-style interpolator for slide-from-right animation (same as RHP), and is fully decoupled from the app navigation tree. - Remove MFA routes from ROUTES.ts, linking config, and RHP navigator - Delete BiometricsTestPage (test button now calls executeScenario directly) - Add mfaNavigation.ts with deferred push for first-screen slide animation - Add MultifactorAuthenticationOverlay as sibling to RootStack Refs: Expensify#81021
- Remove unreachable NOT_FOUND screen (registered but never navigated to) - Replace offscreen style hack with early return null when not visible - Trim verbose comments to essential context - Remove redundant conditional guards (component returns null early)
CLOSE_MODAL sets isModalOpen=false without clearing scenario data, letting the overlay play its exit animation while screens remain mounted. RESET fires only after the animation completes, preventing the snap-disappear on close. - Add isModalOpen to MFA state, set true on INIT - Add CLOSE_MODAL action (keeps scenario/data intact) - Overlay drives visibility from isModalOpen instead of !!scenario - Overlay dispatches RESET after close animation callback - UI dispatch sites (outcome close, cancel, skip-outcome) use CLOSE_MODAL instead of RESET
goBack() on the inner navigator triggers the Stack's reverse slide-from-right animation while the backdrop fades simultaneously. Previously the screen stayed static during the backdrop fade and then snap-disappeared on unmount.
|
|
||
| return ( | ||
| <NarrowPaneContextProvider> | ||
| <MultifactorAuthenticationContextProviders> |
There was a problem hiding this comment.
In this file, I only removed the MultifactorAuthenticationContextProviders, and because it was a wrapper, the GitHub diff shows that many changes.
There was a problem hiding this comment.
Confirmed, the rest of the diff on this file disappears with ?w=1
- Use scheduleOnRN instead of deprecated runOnJS - Use progress.set()/get() instead of deprecated .value - Add sentryLabel via CONST.SENTRY_LABEL.MFA_OVERLAY.BACKDROP - Fix import alias in TestToolMenu (@components → ./) - Remove unused eslint-disable in stateReducer - Suppress set-state-in-effect in overlay and ValidateCodePage with justification comments
Replace the imperative isVisible setState-in-effect pattern with a render-time prevIsModalOpen mirror and a derived isVisible. The effect now lists every referenced dep, removing the exhaustive-deps and set-state-in-effect disables.
Three iOS-specific issues addressed: - Navigation buffer race: on iOS native-stack, INITIAL.onLayout fires synchronously on mount — before process() reaches navigate() — so applyPendingNavigation runs with an empty buffer, and the buffered push set by navigate() never fires. Add a hasInitialLaidOut module flag; once true, navigate() pushes directly instead of buffering. - White flash on first open: INITIAL_SCREEN inherited the opaque app background from screenOptions.native.contentStyle. Set explicit transparent contentStyle (and cardStyle for web) per-screen. - Crash on close: the original close effect used a withTiming completion callback that called scheduleOnRN to update React state on the JS thread. The worklet → JS bridge raced against the native pop animation triggered by goBack(), and the resulting unmount hit an intermediate native-stack state and exited the app. Replace the worklet callback path with a plain setTimeout, and return a cleanup that clears the timer if the component unmounts mid-animation.
Use TRANSPARENT_MODAL presentation, disable the default react-navigation card overlay, and switch the cardStyleInterpolator to forHorizontalIOS with a Safari-only fallback to the Expensify modal interpolator. The previous always-custom interpolator was justified by a width=0 race that no longer applies — the TransparentScreen placeholder guarantees a measured layout before the first push.
Dispatch SET_FLOW_COMPLETE alongside CLOSE_MODAL so the state-machine guard in process() short-circuits if any dep field changes during the 300ms exit animation (e.g. user taps the fading backdrop and triggers cancel(), which dispatches SET_ERROR).
Stack history is always [INITIAL, <current>] because non-initial navigations replace the top. iOS swipe-back / Android hardware back popped the active screen and left the transparent INITIAL placeholder focused with the overlay still open, trapping the user on narrow layouts where no backdrop exists. Setting gestureEnabled: false on the shared screenOptions blocks the pop; users still exit via the header back button or backdrop, both of which dispatch CLOSE_MODAL.
clearPendingNavigation() reset pendingNavigation but left hasInitialLaidOut as true across reopens. On web, ResizeObserver-backed onLayout fires async after isReady becomes true, opening a window where navigate() would see a stale hasInitialLaidOut and dispatch a direct push before the INITIAL_SCREEN had laid out for the new session, breaking the slide-in interpolator measurement.
Single `as MultifactorAuthenticationScenarioConfig` is sufficient — the `as unknown as` form weakens type safety with no compiler benefit.
…nd overlay The overlay-internal param list type was duplicated. Export it from mfaNavigation.ts and import it in MultifactorAuthenticationOverlay so the type has a single source of truth.
Add mfaOverlayZIndex to styles/variables and use it in the overlay root style instead of a hardcoded 1000.
Drop useMemo/useCallback usage in MultifactorAuthenticationOverlay. The project relies on React Compiler for automatic memoization, so the manual wrappers added complexity without a clear correctness benefit.
Align with the rest of the overlay components (MultifactorAuthenticationOverlay, OutcomeScreenBase, ...) that all set displayName for nicer debug output.
Replace the `as Record<string, unknown> | undefined` cast with a typed variable annotation so TypeScript proves the assignment instead of forcing it.
Project convention (contributingGuides/STYLING.md) requires non-theme static styles to live in src/styles. Remove the local StyleSheet.create in MultifactorAuthenticationOverlay and expose mfaOverlayRoot through useThemeStyles instead.
…odalNavigator Align the standalone MFA navigator with the *ModalNavigator naming used by peers in src/libs/Navigation/AppNavigator/Navigators/ (RightModalNavigator, OnboardingModalNavigator, etc.). The component owns a stack of screens, so "ModalNavigator" describes it more accurately than "Overlay" and avoids collision with the existing Overlay component (backdrop dimmer). Renames: - File MultifactorAuthenticationOverlay.tsx -> MultifactorAuthenticationModalNavigator.tsx - Component MultifactorAuthenticationOverlay -> MultifactorAuthenticationModalNavigator - Type MultifactorAuthenticationOverlayParamList -> MultifactorAuthenticationModalNavigatorParamList - Type MfaOverlayInternalParamList -> MultifactorAuthenticationModalNavigatorInternalParamList - Style mfaOverlayRoot -> mfaModalNavigatorRoot - Variable mfaOverlayZIndex -> mfaModalNavigatorZIndex "Overlay" is reserved for the backdrop layer (MFA_OVERLAY.BACKDROP Sentry label, backdropAnimatedStyle), which is its actual role here.
…r self-doc - clearPendingNavigation -> resetMfaNavigation. The function resets two pieces of module state (pendingNavigation and hasInitialLaidOut), not just the pending request. The old name invited callers to assume a narrower contract, which risked silently zeroing the laid-out flag. - applyPendingNavigation -> handleInitialScreenLayout. It is wired to the placeholder's onLayout; naming it as a layout handler matches the callsite and surfaces the side effect (flushing buffered nav) as a layout consequence rather than the primary semantic. - progress -> backdropProgress. The shared value drives only the backdrop opacity; the slide is owned by the Stack. The specific name prevents future reuse for card-style transitions.
Browser back and hardware back now surface the scenario's cancel-confirm modal instead of immediately tearing down the MFA flow. The synthetic CUSTOM_HISTORY_ENTRY_MFA_MODAL_NAVIGATOR marker mirrors the existing side-panel pattern, and the new useSyncMfaModalNavigatorWithHistory hook isolates the marker push/pop, back-press detection, and confirm-state plumbing from the navigator. Confirming runs cancel; rejecting keeps the flow open with the URL pointer unchanged. Forward stack is truncated on confirm so the cancelled flow cannot be resurrected. addRootHistoryRouterExtension now preserves the contiguous trailing run of known markers through rehydration, generalising the previous side-panel-only branch.
Each MFA screen previously owned its own cancel-confirmation state and back-press logic. Move it into the context so there is a single decision point shared by hardware/browser back, header back button, focus-trap escape, and the backdrop press. - Add requestCancel/hideCancelConfirm/confirmCancel to the MFA context. requestCancel decides — based on isFlowComplete, scenario, isOffline — whether to close the modal, cancel directly, or surface the confirm. - Move isCancelConfirmVisible into the reducer state to keep the state/actions context split lint rule happy. - Render the cancel-confirmation modal once in the MFA navigator; PromptPage and ValidateCodePage no longer own confirm-modal state. - Move useSyncMfaModalNavigatorWithHistory into the context and split it into two effects so the marker lifecycle is not churned when requestCancel changes. - Collapse handleCallback so SET_FLOW_COMPLETE dispatches once and the outcome-screen branch uses a ternary.
… modalBaseZIndex - Rename INITIAL_SCREEN to MFA_INITIAL_SCREEN to disambiguate at import sites. - Replace one-off mfaModalNavigatorZIndex with the existing modalBaseZIndex variable; drop the now-unused entry from variables.ts.
Resolved conflicts: - src/CONST/index.ts: kept both MFA_OVERLAY and DOMAIN SENTRY_LABEL additions - src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx: took main version, removed MultifactorAuthenticationContextProviders wrap and its import (provider hoisted to AuthScreens by this PR)
Offline requestCancel previously dispatched SET_ERROR, but process() short-circuits on isOffline, leaving the modal stuck open with no path to handleCallback. Centralize the offline-close decision in cancel() so every entry point (backdrop, hardware back, browser back, confirmCancel) exits the flow immediately when offline.
TestToolMenu called useMultifactorAuthentication() unconditionally, but the provider is only mounted under AuthScreens. Reaching the test-tools modal from SignInPage (4-tap PanResponder) threw "must be used within a MultifactorAuthenticationContextProviders". Move the biometrics row into BiometricsTestToolRow, rendered only when isAuthenticated === true, so the context hook is not invoked pre-auth. Update TestToolMenuBiometricsTest.tsx: mock the new context, drop dead mocks (useSingleExecution, useWaitForNavigation, Navigation), and add executeScenario(BIOMETRICS_TEST) assertion to the Test-button case.
Promotes the explanation from the original review thread into an inline comment so future readers don't re-litigate whether the placeholder should be exposed via SCREENS.ts / ParamList.
NavigationRoot and the MFA modal navigator both translate themePreference into the matching react-navigation base theme. The MFA copy used a raw themePreference === CONST.THEME.DARK check, which mis-buckets every non-DARK variant (DARK_CONTRAST etc.) into the light base. NavigationRoot did the same job correctly via getBaseTheme(). Extract the conversion into getNavigationBaseTheme() and reuse from both call sites so contrast and system theme variants resolve consistently across every NavigationContainer in the app.
Previous comment described a path that cannot happen — after SET_FLOW_COMPLETE, requestCancel routes back-press directly to CLOSE_MODAL and never opens the confirm dialog. Replace with the actual scenario: confirm dialog opened on an active prompt screen can survive through the outcome screen until the user taps "Got it" (CLOSE_MODAL), and without this clear it would linger over the closing navigator.
Outcome screen path cannot leave the cancel-confirm dialog open — SET_FLOW_COMPLETE clears it before the screen mounts. The only CLOSE_MODAL dispatch that bypasses SET_FLOW_COMPLETE is cancel()'s offline / no-scenario short-circuit, so anchor the comment there instead of describing an outcome-screen survival path that cannot actually happen.
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppMFA.refactor.test.2.android.change.PIN.pass.mp4MFA.refactor.test.2.android.authorize.transaction.failure.pass.mp4MFA.refactor.test.2.android.authorize.transaction.success.pass.mp4MFA.refactor.test.2.android.reveal.PIN.pass.mp4MFA.refactor.test.2.android.reveal.PAN.pass.mp4MFA.refactor.test.2.android.set.PIN.pass.mp4MFA.refactor.test.2.android.troubleshoot.flow.pass.mp4Android: mWeb ChromeMFA.refactor.test.2.mweb.chrome.troubleshoot.flow.pass.mp4iOS: HybridAppMFA.refactor.test.2.iOS.authorize.transaction.success.pass.mp4MFA.refactor.test.2.iOS.authorize.transaction.failure.case.pass.mp4MFA.refactor.test.2.iOS.set.PIN.success.pass.mp4MFA.refactor.test.2.iOS.set.PIN.failure.case.pass.mp4MFA.refactor.test.2.iOS.reveal.PAN.pass.mp4MFA.refactor.test.2.iOS.change.PIN.pass.mp4MFA.refactor.test.2.iOS.reveal.PIN.pass.mp4iOS: mWeb SafariMFA.refactor.test.2.mweb.safari.troubleshoot.flow.pass.mp4MacOS: Chrome / SafariMFA.refactor.test.2.chrome.authorize.transaction.success.pass.mp4MFA.refactor.test.2.chrome.authorize.transaction.failure.case.pass.mp4MFA.refactor.test.2.chrome.reveal.PAN.pass.mp4MFA.refactor.test.2.chrome.change.PIN.pass.mp4MFA.refactor.test.2.chrome.reveal.PIN.pass.mp4MFA.refactor.test.2.chrome.troubleshoot.flow.pass.mp4 |
|
I've noticed a minor issue with the discard modal. From what I can see, the scenario is still executing in the background even when the discard modal is shown. This means that if users cancel the flow, the API request still executes and returns a response. I think this is acceptable, but I wanted to flag it here in case anyone wants to discuss further Screen.Recording.2026-05-29.at.21.50.06.mov |
I've looked into this and I think we should address it in a follow-up #81197. The main goal of refactoring MFA into a state machine is to solve problems like this. It could be fixed right now, but it would require fairly significant changes to the MFA Main Context that aren’t related to this PR, and this bug also occurs on the main branch. I’ll add this comment to the next state machine issue so we can make sure we’ve resolved it there. |
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🚧 @rafecolton has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
@dariusz-biela @rafecolton @trjExpensify Could you provide some more details regarding the QA steps? |
|
I don't have the best context on this one, sorry. @rafecolton @chuckdries and @joekaufmanexpensify will be better suited to advise. 👍 |
|
🚀 Deployed to staging by https://github.com/rafecolton in version: 9.3.97-0 🚀
Bundle Size Analysis (Sentry): |
|
🤖 Help site review: no docs changes required. I reviewed this PR against the help articles under Why: This is a pure internal navigation/architecture refactor of the in-development In-App 3DS / MFA flow — it relocates the MFA screens (3DS prompt, magic code, outcome) out of the What I checked
When In-App 3DS actually ships to cardholders, the 3D Secure article will likely need updating (e.g. the "Do I approve 3D Secure transactions inside Expensify?" FAQ would change) — but that's a feature-launch docs task, not something this refactor PR warrants. @dariusz-biela — if you believe a help site update should be prepared proactively ahead of the In-App 3DS launch (rather than waiting until the feature is GA), let me know and I'll draft one. Otherwise no action is needed here. |
|
Hi @kavimuru, this one should've been marked [No QA]. Sorry for the oversight! |
|
🚀 Deployed to production by https://github.com/lakchote in version: 9.3.97-1 🚀
|
|
Coming from #92558 BugZero checklist: This PR caused a regression where, on iOS, the Test Tools menu no longer closes when tapping Test on the Biometrics row — the "Let's verify it's you" MFA page ends up hidden behind the still-open native The current fix in #93557 addresses this by explicitly calling |
|
Nice, thanks for rolling forward 🙌 |
Explanation of Change
MFA screens (3DS prompt, magic code, outcome) used to live inside
RightModalNavigatoras regular RHP routes. Because MFA state is held in React context (not Onyx), URL-driven routes could outlive the state they depended on — a reload, deep link, or unrelated RHP navigation could leave the user on a half-initialized MFA screen, exactly the kind of broken state #81021 asks for guards against.New architecture. MFA is moved out of the RHP into a self-contained
MultifactorAuthenticationModalNavigator, mounted as a sibling of the root stack inside aNavigationIndependentTree. The MFA context provider wraps the authenticated app, so any screen can callexecuteScenarioand the navigator mounts on top of whatever is currently visible. MFA routes are removed fromROUTES/SCREENS/ linking config; internal navigation between MFA screens goes through a dedicatedmfaNavigationmodule instead of the globalNavigationAPI.How it behaves. The MFA flow no longer touches the URL or the app navigation state — the page underneath (e.g.
transaction-preview) stays put while MFA runs on top.CUSTOM_HISTORY_ENTRY_MFA_MODAL_NAVIGATOR, mirroring the side-panel pattern) instead of tearing the flow down.All cancel entry points — backdrop, header back, hardware back, browser back, offline — funnel through a single
requestCanceldecision in the MFA context, so there is one source of truth for "should this attempt to close prompt a confirm, run cancel, or just close?".Side cleanups.
BiometricsTestPageremoved — the dev tool now callsexecuteScenariodirectly, so the standalone page no longer has a reason to exist.useMultifactorAuthentication()is only valid underAuthScreens; reaching the test-tools modal fromSignInPagepreviously crashed.process()short-circuits onisOffline, so without the guard the user would be stranded on the transparent placeholder with no way out.onCloseoverride onOutcomeScreenBase—AuthorizeTransactionPage(deny outcome) andChangePINAtATMPagerender the screen inside the RHP, outside the MFA navigator; the defaultCLOSE_MODALdispatch was a no-op there and left the buttons inert.customConfig(undefined)at module-load and crashed thesortTransactionsPending3DSReviewtest suite.Fixed Issues
$ #81021
$ #82162
PROPOSAL:
Tests
General checks (run for every scenario below):
Per-scenario (happy + failure + any scenario-specific checks):
BIOMETRICS-TEST — happy / failure
AUTHORIZE-TRANSACTION — happy / failure; deny outcome rendered inline in RHP — "Got it" / header back close the RHP (no dead button)
SET-PIN-ORDER-CARD — happy / failure
REVEAL-PIN — happy / failure
CHANGE-PIN — happy / failure
Verify that no errors appear in the JS console
Offline tests
QA Steps
// TODO: These must be filled out, or the issue title must include "[No QA]."
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari