From 01b92f2a36fc55777e42c6353eb18e3682d454a2 Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Thu, 5 Feb 2026 16:27:59 -0800 Subject: [PATCH 1/3] Fix prettier --- src/components/MultifactorAuthentication/Context/Main.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MultifactorAuthentication/Context/Main.tsx b/src/components/MultifactorAuthentication/Context/Main.tsx index a82914375d64..bb456664b229 100644 --- a/src/components/MultifactorAuthentication/Context/Main.tsx +++ b/src/components/MultifactorAuthentication/Context/Main.tsx @@ -197,7 +197,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // 4. Authorize the user if that has not already been done if (!isAuthorizationComplete) { if (!Navigation.isActiveRoute(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute('enable-biometrics'))) { - Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute('enable-biometrics'), { forceReplace: true }); + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute('enable-biometrics'), {forceReplace: true}); } // Request authorization challenge if not already fetched From ab847b1f82e0f14f56fd089fd042157597521d55 Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Thu, 5 Feb 2026 17:20:56 -0800 Subject: [PATCH 2/3] Show soft prompt to users who have valid keys but have not accepted the soft prompt since they last reinstalled the app --- src/ONYXKEYS.ts | 9 +++++++++ .../Context/Main.tsx | 20 +++++++++++++++++++ .../Context/usePromptContent.ts | 8 +++++--- .../MultifactorAuthentication/index.ts | 15 +++++++++++++- .../MultifactorAuthentication/PromptPage.tsx | 2 ++ src/types/onyx/DeviceBiometrics.ts | 12 +++++++++++ src/types/onyx/index.ts | 2 ++ 7 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 src/types/onyx/DeviceBiometrics.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index a3f1d16a9a8a..f0c4b83ad2de 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -29,6 +29,14 @@ const ONYXKEYS = { /** A unique ID for the device */ DEVICE_ID: 'deviceID', + /** Holds information about device-specific biometrics which: + * - does need to be persisted + * - does not need to be kept in secure storage + * - does not persist across uninstallations + * (secure storage persists across uninstallation) + */ + DEVICE_BIOMETRICS: 'deviceBiometrics', + /** Boolean flag set whenever the sidebar has loaded */ IS_SIDEBAR_LOADED: 'isSidebarLoaded', @@ -1417,6 +1425,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_OPEN_CONFIRM_NAVIGATE_EXPENSIFY_CLASSIC_MODAL_OPEN]: boolean; [ONYXKEYS.PERSONAL_POLICY_ID]: string; [ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE]: Record>; + [ONYXKEYS.DEVICE_BIOMETRICS]: OnyxTypes.DeviceBiometrics; }; type OnyxDerivedValuesMapping = { diff --git a/src/components/MultifactorAuthentication/Context/Main.tsx b/src/components/MultifactorAuthentication/Context/Main.tsx index bb456664b229..71f63037c3c6 100644 --- a/src/components/MultifactorAuthentication/Context/Main.tsx +++ b/src/components/MultifactorAuthentication/Context/Main.tsx @@ -1,5 +1,7 @@ import React, {createContext, useCallback, useContext, useEffect, useMemo} from 'react'; import type {ReactNode} from 'react'; +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import {MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG} from '@components/MultifactorAuthentication/config'; import {getOutcomePaths} from '@components/MultifactorAuthentication/config/outcomePaths'; import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioParams} from '@components/MultifactorAuthentication/config/types'; @@ -11,11 +13,21 @@ import Navigation from '@navigation/Navigation'; import {requestAuthorizationChallenge, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication'; import {processRegistration, processScenario} from '@userActions/MultifactorAuthentication/processing'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {DeviceBiometrics} from '@src/types/onyx'; import {useMultifactorAuthenticationState} from './State'; import useNativeBiometrics from './useNativeBiometrics'; import type {AuthorizeResult, RegisterResult} from './useNativeBiometrics'; +let deviceBiometricsState: OnyxEntry; +Onyx.connectWithoutView({ + key: ONYXKEYS.DEVICE_BIOMETRICS, + callback: (data) => { + deviceBiometricsState = data; + }, +}); + type ExecuteScenarioParams = MultifactorAuthenticationScenarioParams & Partial; type MultifactorAuthenticationContextValue = { @@ -194,6 +206,14 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent return; } + // Registration isn't required, but they have never seen the soft prompt + // this happens on ios if they delete and reinstall the app. Their keys are preserved in the secure store, but + // they'll be shown the "do you want to enable FaceID again" system prompt, so we want to show them the soft prompt + if (!deviceBiometricsState?.hasAcceptedSoftPrompt) { + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(CONST.MULTIFACTOR_AUTHENTICATION.PROMPT.ENABLE_BIOMETRICS), {forceReplace: true}); + return; + } + // 4. Authorize the user if that has not already been done if (!isAuthorizationComplete) { if (!Navigation.isActiveRoute(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute('enable-biometrics'))) { diff --git a/src/components/MultifactorAuthentication/Context/usePromptContent.ts b/src/components/MultifactorAuthentication/Context/usePromptContent.ts index bf3f109382ca..e98831fb569f 100644 --- a/src/components/MultifactorAuthentication/Context/usePromptContent.ts +++ b/src/components/MultifactorAuthentication/Context/usePromptContent.ts @@ -37,11 +37,13 @@ function serverHasRegisteredCredentials(data: OnyxEntry) { function usePromptContent(promptType: MultifactorAuthenticationPromptType): PromptContent { const {state} = useMultifactorAuthenticationState(); const [serverHasCredentials = false] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true, selector: serverHasRegisteredCredentials}); + const [deviceBiometricsState] = useOnyx(ONYXKEYS.DEVICE_BIOMETRICS, {canBeMissing: true}); + const hasEverAcceptedSoftPrompt = deviceBiometricsState?.hasAcceptedSoftPrompt ?? false; const contentData = MULTIFACTOR_AUTHENTICATION_PROMPT_UI[promptType]; // Returning user: server has credentials, but user hasn't approved soft prompt yet - const isReturningUser = serverHasCredentials && !state.softPromptApproved; + const isReturningUser = hasEverAcceptedSoftPrompt && serverHasCredentials && !state.softPromptApproved; let title: TranslationPaths = contentData.title; let subtitle: TranslationPaths | undefined = contentData.subtitle; @@ -54,7 +56,7 @@ function usePromptContent(promptType: MultifactorAuthenticationPromptType): Prom if (isReturningUser) { title = 'multifactorAuthentication.letsAuthenticateYou'; subtitle = undefined; - } else if (state.isRegistrationComplete) { + } else if (state.isRegistrationComplete && hasEverAcceptedSoftPrompt) { title = 'multifactorAuthentication.nowLetsAuthenticateYou'; subtitle = undefined; } @@ -62,7 +64,7 @@ function usePromptContent(promptType: MultifactorAuthenticationPromptType): Prom // Display confirm button only for new users during their first biometric registration. // Hide it for: users who already approved the soft prompt, users who finished registration, // or returning users with existing server credentials. The button prompts users to enable biometrics. - const shouldDisplayConfirmButton = !state.softPromptApproved && !state.isRegistrationComplete && !serverHasCredentials; + const shouldDisplayConfirmButton = !hasEverAcceptedSoftPrompt || (!state.softPromptApproved && !state.isRegistrationComplete && !serverHasCredentials); return { animation: contentData.animation, diff --git a/src/libs/actions/MultifactorAuthentication/index.ts b/src/libs/actions/MultifactorAuthentication/index.ts index a625c29ac486..0bd64c808216 100644 --- a/src/libs/actions/MultifactorAuthentication/index.ts +++ b/src/libs/actions/MultifactorAuthentication/index.ts @@ -187,4 +187,17 @@ async function revokeMultifactorAuthenticationCredentials() { } } -export {registerAuthenticationKey, requestRegistrationChallenge, requestAuthorizationChallenge, troubleshootMultifactorAuthentication, revokeMultifactorAuthenticationCredentials}; +function markHasAcceptedSoftPrompt() { + Onyx.merge(ONYXKEYS.DEVICE_BIOMETRICS, { + hasAcceptedSoftPrompt: true, + }); +} + +export { + registerAuthenticationKey, + requestRegistrationChallenge, + requestAuthorizationChallenge, + troubleshootMultifactorAuthentication, + revokeMultifactorAuthenticationCredentials, + markHasAcceptedSoftPrompt, +}; diff --git a/src/pages/MultifactorAuthentication/PromptPage.tsx b/src/pages/MultifactorAuthentication/PromptPage.tsx index c709164eca65..c3df269d5b85 100644 --- a/src/pages/MultifactorAuthentication/PromptPage.tsx +++ b/src/pages/MultifactorAuthentication/PromptPage.tsx @@ -12,6 +12,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; 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'; @@ -31,6 +32,7 @@ function MultifactorAuthenticationPromptPage({route}: MultifactorAuthenticationP const [isCancelModalVisible, setCancelModalVisibility] = useState(false); const onConfirm = () => { + markHasAcceptedSoftPrompt(); dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); }; diff --git a/src/types/onyx/DeviceBiometrics.ts b/src/types/onyx/DeviceBiometrics.ts new file mode 100644 index 000000000000..e906e0cc0f8f --- /dev/null +++ b/src/types/onyx/DeviceBiometrics.ts @@ -0,0 +1,12 @@ +/** Holds information about device-specific biometrics which: + * - does need to be persisted + * - does not need to be kept in secure storage + * - does not persist across uninstallations + * (secure storage persists across uninstallation) + */ +type DeviceBiometrics = { + /** Whether the user has been shown the Biometrics Soft Prompt screen and accepted it */ + hasAcceptedSoftPrompt: boolean; +}; + +export default DeviceBiometrics; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 9857e2db528d..d36862d429e9 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -49,6 +49,7 @@ import type { TodoMetadata, TodosDerivedValue, } from './DerivedValues'; +import type DeviceBiometrics from './DeviceBiometrics'; import type DismissedProductTraining from './DismissedProductTraining'; import type DismissedReferralBanners from './DismissedReferralBanners'; import type Domain from './Domain'; @@ -349,4 +350,5 @@ export type { DomainSecurityGroup, CodingRuleMatchingTransaction, UserSecurityGroupData, + DeviceBiometrics, }; From 4dd4f6ea05fd6cc91b6dde32ba9d8754a7e2c705 Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Thu, 5 Feb 2026 17:37:32 -0800 Subject: [PATCH 3/3] Add MFA screens to EXCLUDE_FROM_LAST_VISITED_PATH --- src/CONST/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e031733607ee..05b9cb4131c5 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7466,7 +7466,7 @@ const CONST = { CASH_BACK: 'earnedCashback', }, - EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN, SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT, SCREENS.MONEY_REQUEST.STEP_SCAN] as string[], + EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN, SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT, SCREENS.MONEY_REQUEST.STEP_SCAN, ...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION)] as string[], CANCELLATION_TYPE: { MANUAL: 'manual',