Skip to content

[ECUK In-App 3DS] MFA: isolate 3DS flow into self-contained modal navigator - #89992

Merged
rafecolton merged 76 commits into
Expensify:mainfrom
software-mansion-labs:dariusz-biela/feat/3ds/internal-navigation-v3
Jun 2, 2026
Merged

[ECUK In-App 3DS] MFA: isolate 3DS flow into self-contained modal navigator#89992
rafecolton merged 76 commits into
Expensify:mainfrom
software-mansion-labs:dariusz-biela/feat/3ds/internal-navigation-v3

Conversation

@dariusz-biela

@dariusz-biela dariusz-biela commented May 8, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

MFA screens (3DS prompt, magic code, outcome) used to live inside RightModalNavigator as 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 a NavigationIndependentTree. The MFA context provider wraps the authenticated app, so any screen can call executeScenario and the navigator mounts on top of whatever is currently visible. MFA routes are removed from ROUTES / SCREENS / linking config; internal navigation between MFA screens goes through a dedicated mfaNavigation module instead of the global Navigation API.

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.

  • A page refresh mid-flow drops the user back at the trigger screen with a clean slate; the flow can simply be restarted. This trades URL persistence for the guarantee that nobody is ever stranded on a screen whose context state has been wiped (and the underlying commands are rate-limited, so restarting is safe).
  • Browser back and Android hardware back surface the scenario's cancel-confirm modal via a custom history marker (CUSTOM_HISTORY_ENTRY_MFA_MODAL_NAVIGATOR, mirroring the side-panel pattern) instead of tearing the flow down.
  • On success/failure the navigator unmounts and the underlying screen routes the user wherever the scenario decided (e.g. back to the report).

All cancel entry points — backdrop, header back, hardware back, browser back, offline — funnel through a single requestCancel decision 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.

  • BiometricsTestPage removed — the dev tool now calls executeScenario directly, so the standalone page no longer has a reason to exist.
  • Biometrics test row gated to authenticated sessionsuseMultifactorAuthentication() is only valid under AuthScreens; reaching the test-tools modal from SignInPage previously crashed.
  • Test/Revoke buttons disabled while offlineprocess() short-circuits on isOffline, so without the guard the user would be stranded on the transparent placeholder with no way out.
  • onClose override on OutcomeScreenBaseAuthorizeTransactionPage (deny outcome) and ChangePINAtATMPage render the screen inside the RHP, outside the MFA navigator; the default CLOSE_MODAL dispatch was a no-op there and left the buttons inert.
  • Context module split into leaf files — the previous import graph formed a cycle that resolved customConfig(undefined) at module-load and crashed the sortTransactionsPending3DSReview test suite.

Fixed Issues

$ #81021
$ #82162
PROPOSAL:

Tests

Tester access note. I only have hands-on access to the Biometrics Test scenario (run from the in-app Test Tool Menu) and ran the Authorize Transaction scenario against the mocked outcome handler. The remaining card-related scenarios (SET-PIN-ORDER-CARD, REVEAL-PIN, CHANGE-PIN) and an end-to-end Authorize Transaction against the real backend require someone with access to an Expensify Card, or with the ability to run a properly configured backend in developer mode.

General checks (run for every scenario below):

  • Header back button → cancel-confirm modal
  • "Got it" button on success/failure outcome → closes flow, returns to trigger screen
  • Android hardware back → cancel-confirm modal (does not tear the flow down)
  • Browser back (web) → cancel-confirm modal
  • Backdrop tap → cancel-confirm modal
  • Re-run back-to-back works (clean slate each time)
  • Rapid double-tap on the trigger does NOT start two flows
  • Refresh mid-flow → app returns to trigger screen, scenario re-runnable
  • App close + reopen mid-flow → trigger screen, no stale MFA UI
  • Offline cancel paths close cleanly (modal does not stick open)
  • No JS console errors / Sentry-visible BackHandler warnings

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]."

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

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>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this file, I only removed the MultifactorAuthenticationContextProviders, and because it was a wrapper, the GitHub diff shows that many changes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Comment thread src/styles/index.ts Outdated
Comment thread src/components/MultifactorAuthentication/mfaNavigation.ts Outdated
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.
@chuckdries

chuckdries commented May 28, 2026

Copy link
Copy Markdown
Contributor

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick).
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
MFA.refactor.test.2.android.change.PIN.pass.mp4
MFA.refactor.test.2.android.authorize.transaction.failure.pass.mp4
MFA.refactor.test.2.android.authorize.transaction.success.pass.mp4
MFA.refactor.test.2.android.reveal.PIN.pass.mp4
MFA.refactor.test.2.android.reveal.PAN.pass.mp4
MFA.refactor.test.2.android.set.PIN.pass.mp4
MFA.refactor.test.2.android.troubleshoot.flow.pass.mp4
Android: mWeb Chrome
MFA.refactor.test.2.mweb.chrome.troubleshoot.flow.pass.mp4
iOS: HybridApp
MFA.refactor.test.2.iOS.authorize.transaction.success.pass.mp4
MFA.refactor.test.2.iOS.authorize.transaction.failure.case.pass.mp4
MFA.refactor.test.2.iOS.set.PIN.success.pass.mp4
MFA.refactor.test.2.iOS.set.PIN.failure.case.pass.mp4
MFA.refactor.test.2.iOS.reveal.PAN.pass.mp4
MFA.refactor.test.2.iOS.change.PIN.pass.mp4
MFA.refactor.test.2.iOS.reveal.PIN.pass.mp4
iOS: mWeb Safari
MFA.refactor.test.2.mweb.safari.troubleshoot.flow.pass.mp4
MacOS: Chrome / Safari
MFA.refactor.test.2.chrome.authorize.transaction.success.pass.mp4
MFA.refactor.test.2.chrome.authorize.transaction.failure.case.pass.mp4
MFA.refactor.test.2.chrome.reveal.PAN.pass.mp4
MFA.refactor.test.2.chrome.change.PIN.pass.mp4
MFA.refactor.test.2.chrome.reveal.PIN.pass.mp4
MFA.refactor.test.2.chrome.troubleshoot.flow.pass.mp4

@DylanDylann DylanDylann left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@melvin-bot
melvin-bot Bot requested a review from chuckdries May 29, 2026 14:50
@DylanDylann

Copy link
Copy Markdown
Contributor

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

@dariusz-biela

Copy link
Copy Markdown
Contributor Author

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

@DylanDylann

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.

@rafecolton
rafecolton merged commit 7f7da22 into Expensify:main Jun 2, 2026
50 of 52 checks passed
@OSBotify

OSBotify commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

🚧 @rafecolton has triggered a test Expensify/App build. You can view the workflow run here.

@kavimuru

kavimuru commented Jun 3, 2026

Copy link
Copy Markdown

@dariusz-biela @rafecolton @trjExpensify Could you provide some more details regarding the QA steps?

@trjExpensify

Copy link
Copy Markdown
Contributor

I don't have the best context on this one, sorry. @rafecolton @chuckdries and @joekaufmanexpensify will be better suited to advise. 👍

@OSBotify

OSBotify commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/rafecolton in version: 9.3.97-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

Bundle Size Analysis (Sentry):

@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 Help site review: no docs changes required.

I reviewed this PR against the help articles under App/docs/articles (most relevant: Expensify-Card-3D-Secure-Verification.md) and did not create a draft PR.

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 RightModalNavigator into a self-contained MultifactorAuthenticationModalNavigator. Nothing here changes a procedure that the help site documents for customers.

What I checked
  • No customer-facing copy changed. The diff touches no src/languages/* files and adds no user-visible strings — the only new "labels" are internal accessibilityLabel / Sentry labels.
  • No docs/articles files are in the diff.
  • The behavior changes are internal/edge-case plumbing, not documented steps:
    • MFA now mounts on top of the current screen instead of as a URL-driven RHP route.
    • Refresh mid-flow returns the user to the trigger screen (clean-slate restart).
    • Browser/Android hardware back surfaces the existing cancel-confirm modal instead of tearing the flow down.
    • Side cleanups (removing BiometricsTestPage, gating the Biometrics test row, disabling Test/Revoke offline) are dev-tool-only.
  • The existing 3D Secure article describes the SMS-on-the-merchant-checkout-page flow, which this PR does not change. The In-App 3DS feature itself is still in development (per the author's tester-access note, the card scenarios are only runnable with special access / mocked handlers), so it isn't live customer behavior to document yet.

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.

@chuckdries

Copy link
Copy Markdown
Contributor

Hi @kavimuru, this one should've been marked [No QA]. Sorry for the oversight!

@OSBotify

OSBotify commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/lakchote in version: 9.3.97-1 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@suneox

suneox commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

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 transparentModal.

The current fix in #93557 addresses this by explicitly calling Navigation.dismissModal() (guarded by an active-route check) before executeScenario, and by registering TEST_TOOLS_MODAL_NAVIGATOR in MODAL_ROUTES_TO_DISMISS.

@rafecolton

Copy link
Copy Markdown
Member

Nice, thanks for rolling forward 🙌

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants