From 13c2f3c6ca31af5d2d88b32e77d26ee2d4ef5be1 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 15 Jan 2026 18:53:51 +0100 Subject: [PATCH 1/5] feat: add basic multifactor authentication components --- .../NoEligibleMethodsDescription.tsx | 34 ++++++++++ .../PromptContent.tsx | 38 +++++++++++ .../TriggerCancelConfirmModal.tsx | 44 +++++++++++++ .../ValidateCodeResendButton.tsx | 64 +++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 src/components/MultifactorAuthentication/NoEligibleMethodsDescription.tsx create mode 100644 src/components/MultifactorAuthentication/PromptContent.tsx create mode 100644 src/components/MultifactorAuthentication/TriggerCancelConfirmModal.tsx create mode 100644 src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx diff --git a/src/components/MultifactorAuthentication/NoEligibleMethodsDescription.tsx b/src/components/MultifactorAuthentication/NoEligibleMethodsDescription.tsx new file mode 100644 index 000000000000..e011160edccd --- /dev/null +++ b/src/components/MultifactorAuthentication/NoEligibleMethodsDescription.tsx @@ -0,0 +1,34 @@ +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import goToSettings from '@libs/goToSettings/index.native'; + +const base = 'multifactorAuthentication.pleaseEnableInSystemSettings' as const; + +const tPaths = { + start: `${base}.start`, + link: `${base}.link`, + end: `${base}.end`, +} as const; + +function NoEligibleMethodsDescription() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const start = translate(tPaths.start); + const link = translate(tPaths.link); + const end = translate(tPaths.end); + + return ( + + {start} + {link} + {end} + + ); +} + +NoEligibleMethodsDescription.displayName = 'NoEligibleMethodsDescription'; + +export default NoEligibleMethodsDescription; diff --git a/src/components/MultifactorAuthentication/PromptContent.tsx b/src/components/MultifactorAuthentication/PromptContent.tsx new file mode 100644 index 000000000000..2d87aa920014 --- /dev/null +++ b/src/components/MultifactorAuthentication/PromptContent.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import {View} from 'react-native'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import type DotLottieAnimation from '@components/LottieAnimations/types'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {TranslationPaths} from '@src/languages/types'; + +type MultifactorAuthenticationPromptContentProps = { + animation: DotLottieAnimation; + title: TranslationPaths; + subtitle: TranslationPaths; +}; + +function MultifactorAuthenticationPromptContent({title, subtitle, animation}: MultifactorAuthenticationPromptContentProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + return ( + + + + ); +} + +MultifactorAuthenticationPromptContent.displayName = 'MultifactorAuthenticationPromptContent'; + +export default MultifactorAuthenticationPromptContent; diff --git a/src/components/MultifactorAuthentication/TriggerCancelConfirmModal.tsx b/src/components/MultifactorAuthentication/TriggerCancelConfirmModal.tsx new file mode 100644 index 000000000000..510526e82603 --- /dev/null +++ b/src/components/MultifactorAuthentication/TriggerCancelConfirmModal.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import ConfirmModal from '@components/ConfirmModal'; +import useLocalize from '@hooks/useLocalize'; +import type {TranslationPaths} from '@src/languages/types'; + +type MultifactorAuthenticationTriggerCancelConfirmModalProps = { + isVisible: boolean; + onConfirm: () => void; + onCancel: () => void; +}; + +const mockedConfig = { + title: 'common.areYouSure', + description: 'multifactorAuthentication.biometricsTest.areYouSureToReject', + confirmButtonText: 'multifactorAuthentication.biometricsTest.rejectAuthentication', + cancelButtonText: 'common.cancel', +} as const satisfies Record; + +function MultifactorAuthenticationTriggerCancelConfirmModal({isVisible, onConfirm, onCancel}: MultifactorAuthenticationTriggerCancelConfirmModalProps) { + const {translate} = useLocalize(); + + const title = translate(mockedConfig.title); + const description = translate(mockedConfig.description); + const confirmButtonText = translate(mockedConfig.confirmButtonText); + const cancelButtonText = translate(mockedConfig.cancelButtonText); + + return ( + + ); +} + +MultifactorAuthenticationTriggerCancelConfirmModal.displayName = 'MultifactorAuthenticationTriggerCancelConfirmModal'; + +export default MultifactorAuthenticationTriggerCancelConfirmModal; diff --git a/src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx b/src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx new file mode 100644 index 000000000000..254d6945a7ec --- /dev/null +++ b/src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import {View} from 'react-native'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import RenderHTML from '@components/RenderHTML'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; + +type MultifactorAuthenticationValidateCodeResendButtonProps = { + shouldShowTimer: boolean; + timeRemaining: number; + shouldDisableResendCode: boolean; + hasError: boolean; + resendButtonText: TranslationPaths; + onResendValidationCode: () => void; +}; + +function MultifactorAuthenticationValidateCodeResendButton({ + shouldShowTimer, + timeRemaining, + shouldDisableResendCode, + hasError, + resendButtonText, + onResendValidationCode, +}: MultifactorAuthenticationValidateCodeResendButtonProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + + if (shouldShowTimer) { + return ( + + + + ); + } + + return ( + + + {hasError ? translate('validateCodeForm.requestNewCodeAfterErrorOccurred') : translate(resendButtonText)} + + + ); +} + +MultifactorAuthenticationValidateCodeResendButton.displayName = 'MultifactorAuthenticationValidateCodeResendButton'; + +export default MultifactorAuthenticationValidateCodeResendButton; From 70eb1a46cf8b9f5f317c871451e885adf4ef92b4 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 15 Jan 2026 18:55:52 +0100 Subject: [PATCH 2/5] feat: add basic multifactor authentication pages --- src/ROUTES.ts | 24 +- src/SCREENS.ts | 8 + .../ModalStackNavigators/index.tsx | 10 + .../Navigators/RightModalNavigator.tsx | 4 + src/libs/Navigation/linkingConfig/config.ts | 9 + src/libs/Navigation/types.ts | 13 + .../BiometricsTestPage.tsx | 58 +++++ .../NotificationPage.tsx | 88 +++++++ .../MultifactorAuthentication/PromptPage.tsx | 82 +++++++ .../ValidateCodePage.tsx | 224 ++++++++++++++++++ tests/unit/NetworkTest.tsx | 13 +- 11 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 src/pages/MultifactorAuthentication/BiometricsTestPage.tsx create mode 100644 src/pages/MultifactorAuthentication/NotificationPage.tsx create mode 100644 src/pages/MultifactorAuthentication/PromptPage.tsx create mode 100644 src/pages/MultifactorAuthentication/ValidateCodePage.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 1799b9078b2b..4b0bdc639ae1 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -51,6 +51,11 @@ const PUBLIC_SCREENS_ROUTES = { // Exported for identifying a url as a verify-account route, associated with a page extending the VerifyAccountPageBase component const VERIFY_ACCOUNT = 'verify-account'; +const MULTIFACTOR_AUTHENTICATION_PROTECTED_ROUTES = { + FACTOR: 'multifactor-authentication/factor', + PROMPT: 'multifactor-authentication/prompt', +} as const; + const ROUTES = { ...PUBLIC_SCREENS_ROUTES, // This route renders the list of reports. @@ -3685,6 +3690,23 @@ const ROUTES = { route: 'domain/:domainAccountID/admins/:accountID/reset-domain', getRoute: (domainAccountID: number, accountID: number) => `domain/${domainAccountID}/admins/${accountID}/reset-domain` as const, }, + + MULTIFACTOR_AUTHENTICATION_MAGIC_CODE: `${MULTIFACTOR_AUTHENTICATION_PROTECTED_ROUTES.FACTOR}/magic-code`, + MULTIFACTOR_AUTHENTICATION_BIOMETRICS_TEST: 'multifactor-authentication/scenario/biometrics-test', + + // The exact notification & prompt type will be added as a part of Multifactor Authentication config in another PR, + // for now a string is accepted to avoid blocking this PR. + MULTIFACTOR_AUTHENTICATION_NOTIFICATION: { + route: 'multifactor-authentication/notification/:notificationType', + getRoute: (notificationType: string) => `multifactor-authentication/notification/${notificationType}` as const, + }, + + MULTIFACTOR_AUTHENTICATION_PROMPT: { + route: `${MULTIFACTOR_AUTHENTICATION_PROTECTED_ROUTES.PROMPT}/:promptType`, + getRoute: (promptType: string) => `${MULTIFACTOR_AUTHENTICATION_PROTECTED_ROUTES.PROMPT}/${promptType}` as const, + }, + + MULTIFACTOR_AUTHENTICATION_NOT_FOUND: 'multifactor-authentication/not-found', } as const; /** @@ -3700,7 +3722,7 @@ const SHARED_ROUTE_PARAMS: Partial> = { [SCREENS.WORKSPACE.INITIAL]: ['backTo'], } as const; -export {PUBLIC_SCREENS_ROUTES, SHARED_ROUTE_PARAMS, VERIFY_ACCOUNT}; +export {PUBLIC_SCREENS_ROUTES, SHARED_ROUTE_PARAMS, VERIFY_ACCOUNT, MULTIFACTOR_AUTHENTICATION_PROTECTED_ROUTES}; export default ROUTES; type ReportAttachmentsRoute = typeof ROUTES.REPORT_ATTACHMENTS.route; diff --git a/src/SCREENS.ts b/src/SCREENS.ts index c6f861e0666d..9744c56eac72 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -276,6 +276,7 @@ const SCREENS = { REPORT_CARD_ACTIVATE: 'Report_Card_Activate', DOMAIN: 'Domain', EXPENSE_REPORT: 'ExpenseReport', + MULTIFACTOR_AUTHENTICATION: 'MultifactorAuthentication', }, REPORT_CARD_ACTIVATE: 'Report_Card_Activate_Root', PUBLIC_CONSOLE_DEBUG: 'Console_Debug', @@ -883,6 +884,13 @@ const SCREENS = { MEMBER_DETAILS: 'Member_Details', RESET_DOMAIN: 'Domain_Reset', }, + MULTIFACTOR_AUTHENTICATION: { + MAGIC_CODE: 'Multifactor_Authentication_Magic_Code', + BIOMETRICS_TEST: 'Multifactor_Authentication_Biometrics_Test', + NOTIFICATION: 'Multifactor_Authentication_Notification', + PROMPT: 'Multifactor_Authentication_Prompt', + NOT_FOUND: 'Multifactor_Authentication_Not_Found', + }, } as const; type Screen = DeepValueOf; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 5041594252a0..7bdfeca882ef 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -17,6 +17,7 @@ import type { MergeTransactionNavigatorParamList, MissingPersonalDetailsParamList, MoneyRequestNavigatorParamList, + MultifactorAuthenticationParamList, NewChatNavigatorParamList, NewReportWorkspaceSelectionNavigatorParamList, NewTaskNavigatorParamList, @@ -984,6 +985,14 @@ const WorkspacesDomainModalStackNavigator = createModalStackNavigator require('../../../../pages/domain/DomainAccessRestrictedPage').default, }); +const MultifactorAuthenticationStackNavigator = createModalStackNavigator({ + [SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE]: () => require('../../../../pages/MultifactorAuthentication/ValidateCodePage').default, + [SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST]: () => require('../../../../pages/MultifactorAuthentication/BiometricsTestPage').default, + [SCREENS.MULTIFACTOR_AUTHENTICATION.NOTIFICATION]: () => require('../../../../pages/MultifactorAuthentication/NotificationPage').default, + [SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT]: () => require('../../../../pages/MultifactorAuthentication/PromptPage').default, + [SCREENS.MULTIFACTOR_AUTHENTICATION.NOT_FOUND]: () => require('../../../../pages/ErrorPage/NotFoundPage').default, +}); + export { AddPersonalBankAccountModalStackNavigator, AddUnreportedExpenseModalStackNavigator, @@ -1033,4 +1042,5 @@ export { WorkspaceConfirmationModalStackNavigator, WorkspaceDuplicateModalStackNavigator, WorkspacesDomainModalStackNavigator, + MultifactorAuthenticationStackNavigator, }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 92cbd4148cf2..0b1bf503ee63 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -400,6 +400,10 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { name={SCREENS.RIGHT_MODAL.SEARCH_COLUMNS} component={ModalStackNavigators.SearchColumnsModalStackNavigator} /> + {/* The third and second overlays are displayed here to cover RHP screens wider than the currently focused screen. */} diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index caf20777d7be..2b8ac2cef4ec 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1903,6 +1903,15 @@ 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.NOTIFICATION]: ROUTES.MULTIFACTOR_AUTHENTICATION_NOTIFICATION.route, + [SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT]: ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.route, + [SCREENS.MULTIFACTOR_AUTHENTICATION.NOT_FOUND]: ROUTES.MULTIFACTOR_AUTHENTICATION_NOT_FOUND, + }, + }, }, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 04fa0a403789..eb08b66886b0 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2289,6 +2289,7 @@ type RightModalNavigatorParamList = { }; [SCREENS.RIGHT_MODAL.DOMAIN]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.SEARCH_COLUMNS]: NavigatorScreenParams; + [SCREENS.RIGHT_MODAL.MULTIFACTOR_AUTHENTICATION]: NavigatorScreenParams; }; type TravelNavigatorParamList = { @@ -2939,6 +2940,17 @@ type TestToolsModalModalNavigatorParamList = { }; }; +type MultifactorAuthenticationParamList = { + [SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE]: undefined; + [SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST]: undefined; + [SCREENS.MULTIFACTOR_AUTHENTICATION.NOTIFICATION]: { + notificationType: string; + }; + [SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT]: { + promptType: string; + }; +}; + type RootNavigatorParamList = PublicScreensParamList & AuthScreensParamList & SearchFullscreenNavigatorParamList; type OnboardingFlowName = keyof OnboardingModalNavigatorParamList; @@ -3051,4 +3063,5 @@ export type { DomainSplitNavigatorParamList, DomainScreenName, SearchColumnsParamList, + MultifactorAuthenticationParamList, }; diff --git a/src/pages/MultifactorAuthentication/BiometricsTestPage.tsx b/src/pages/MultifactorAuthentication/BiometricsTestPage.tsx new file mode 100644 index 000000000000..4e78158c83af --- /dev/null +++ b/src/pages/MultifactorAuthentication/BiometricsTestPage.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import {View} from 'react-native'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import Button from '@components/Button'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import variables from '@styles/variables'; +import ROUTES from '@src/ROUTES'; + +function MultifactorAuthenticationBiometricsTestPage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const onGoBackPress = () => Navigation.dismissModal(); + const illustrations = useMemoizedLazyIllustrations(['OpenPadlock'] as const); + + return ( + + + + + + + +