From 230816db4a4cd8bc69ecf5332018554855e914a9 Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Wed, 2 Sep 2026 00:17:25 +0530 Subject: [PATCH 01/18] Add Sign out escape hatch to required 2FA overlay Add a horizontal Sign out + Enable 2FA button row on the blocking require-2FA overlay. Sign out uses the same confirm modal as Settings, then redirects to sign-in so users can switch accounts. Fixes https://github.com/Expensify/App/issues/91985 --- .../RequireTwoFactorAuthenticationOverlay.tsx | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx index c17e46e73922..8aca2cc724ce 100644 --- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx +++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx @@ -1,8 +1,10 @@ import Button from '@components/ButtonComposed'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; import Icon from '@components/Icon'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import Text from '@components/Text'; +import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -15,6 +17,7 @@ import Navigation, {getDeepestFocusedScreen, isTwoFactorSetupScreen} from '@libs import variables from '@styles/variables'; +import {signOutAndRedirectToSignIn} from '@userActions/Session'; import {updateOnboardingLastVisitedPath} from '@userActions/Welcome'; import {buildOnboardingFlowParams, getRequired2FAOnboardingResumePath} from '@userActions/Welcome/OnboardingFlow'; @@ -57,6 +60,7 @@ function RequireTwoFactorAuthenticationOverlay() { const illustrations = useMemoizedLazyIllustrations(['Encryption']); const styles = useThemeStyles(); const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); const {getTwoFactorAuthRoute} = useTwoFactorAuthRoute(); const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH); const [account] = useOnyx(ONYXKEYS.ACCOUNT); @@ -90,11 +94,26 @@ function RequireTwoFactorAuthenticationOverlay() { snapshotOnboardingResumePathIfNeeded(); }, [shouldShowRequire2FAPage, isIn2FASetupFlow, snapshotOnboardingResumePathIfNeeded]); - const handleOnPress = () => { + const handleEnableTwoFactorAuthPress = () => { snapshotOnboardingResumePathIfNeeded(); Navigation.navigate(getTwoFactorAuthRoute(ROUTES.SETTINGS_SECURITY, {forceSetup: true})); }; + const handleSignOutPress = async () => { + const result = await showConfirmModal({ + title: translate('common.areYouSure'), + prompt: translate('initialSettingsPage.signOutConfirmationText'), + confirmText: translate('initialSettingsPage.signOut'), + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }); + + if (result.action === ModalActions.CONFIRM) { + signOutAndRedirectToSignIn(); + } + }; + if (!shouldShowRequire2FAPage || isIn2FASetupFlow) { return null; } @@ -121,14 +140,26 @@ function RequireTwoFactorAuthenticationOverlay() { {translate(is2FARequiredBecauseOfXero ? 'twoFactorAuth.twoFactorAuthIsRequiredXero' : 'twoFactorAuth.twoFactorAuthIsRequiredCompany')} - + + + + + + + + From 58740e275a9ff97201e9e5c6e38de114f9466d03 Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Wed, 2 Sep 2026 01:45:25 +0530 Subject: [PATCH 02/18] Use shared interactive sign-out in require-2FA overlay Extract Settings sign-out flow (receipt save, offline/GPS warnings) into InteractiveSignOut and wire the overlay to it so sign-out matches Settings. Rename overlay handlers per STYLE.md (enableTwoFactorAuth, confirmSignOut). --- src/libs/actions/InteractiveSignOut.ts | 129 ++++++++++++++++++ .../RequireTwoFactorAuthenticationOverlay.tsx | 28 +++- .../useInitialSettingsPageMenuData.ts | 112 ++------------- 3 files changed, 159 insertions(+), 110 deletions(-) create mode 100644 src/libs/actions/InteractiveSignOut.ts diff --git a/src/libs/actions/InteractiveSignOut.ts b/src/libs/actions/InteractiveSignOut.ts new file mode 100644 index 000000000000..06859c0a57a5 --- /dev/null +++ b/src/libs/actions/InteractiveSignOut.ts @@ -0,0 +1,129 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import type ConfirmModalWrapper from '@components/Modal/Global/ConfirmModalWrapper'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import type {ModalProps} from '@components/Modal/Global/ModalContext'; + +import {showPermissionErrorAlert} from '@libs/fileDownload/FileUtils'; +import Log from '@libs/Log'; +import {getSaveablePendingReceiptRequests, saveReceiptsToGallery} from '@libs/savePendingReceiptsToGallery'; + +import {BACKGROUND_LOCATION_TRACKING_TASK_NAME} from '@pages/iou/request/step/IOURequestStepDistanceGPS/const'; +import {stopGpsTripNotification} from '@pages/iou/request/step/IOURequestStepDistanceGPS/GPSNotifications'; + +import CONST from '@src/CONST'; + +import type React from 'react'; + +import {stopLocationUpdatesAsync} from 'expo-location'; + +import {signOutAndRedirectToSignIn} from './Session'; + +type ConfirmModalOptions = Omit, keyof ModalProps> & { + id?: string; +}; + +type ShowConfirmModal = (options: ConfirmModalOptions) => Promise<{action: string}>; + +type InteractiveSignOutParams = { + translate: LocaleContextProps['translate']; + isOffline: boolean; + isTrackingGPS: boolean; + showConfirmModal: ShowConfirmModal; + /** When true, the user already confirmed sign-out (e.g. from the require-2FA overlay). Skips the fast-path that signs out without prompts. */ + hasConfirmedSignOut?: boolean; +}; + +/** + * Interactive sign-out used by Settings and blocking overlays. + * Saves queued receipts, warns when offline or GPS tracking is active, then clears the session. + */ +async function signOutInteractively({translate, isOffline, isTrackingGPS, showConfirmModal, hasConfirmedSignOut = false}: InteractiveSignOutParams) { + const saveableReceipts = getSaveablePendingReceiptRequests(); + const shouldWarnBeforeSignOut = isOffline || isTrackingGPS; + const isOfflineReceiptsCase = isOffline && !isTrackingGPS && saveableReceipts.length > 0; + + if (!hasConfirmedSignOut && !shouldWarnBeforeSignOut && saveableReceipts.length === 0) { + return signOutAndRedirectToSignIn(); + } + + const confirmModalTitle = isTrackingGPS ? translate('gps.signOutWarningTripInProgress.title') : translate('common.areYouSure'); + const confirmModalPrompt = isTrackingGPS ? translate('gps.signOutWarningTripInProgress.prompt') : translate('initialSettingsPage.signOutConfirmationText'); + const confirmModalConfirmText = isTrackingGPS ? translate('gps.signOutWarningTripInProgress.confirm') : translate('initialSettingsPage.signOut'); + + const saveReceipts = async () => { + try { + const {savedCount, failedCount, permissionDenied} = await saveReceiptsToGallery(saveableReceipts); + Log.info('[Receipt] Saved pending receipts to gallery before sign-out', false, {savedCount, failedCount, permissionDenied}); + if (permissionDenied) { + showPermissionErrorAlert(translate); + } + } catch (error) { + Log.alert('[Receipt] Unexpected rejection from saveReceiptsToGallery; sign-out continued', {error}); + } + }; + + if (isOfflineReceiptsCase) { + const result = await showConfirmModal({ + title: translate('initialSettingsPage.saveReceiptsAndSignOutConfirmation.title'), + prompt: translate('initialSettingsPage.saveReceiptsAndSignOutConfirmation.prompt', { + count: saveableReceipts.length, + }), + confirmText: translate('initialSettingsPage.saveReceiptsAndSignOutConfirmation.confirm'), + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + await saveReceipts(); + } else { + if (shouldWarnBeforeSignOut) { + const result = await showConfirmModal({ + title: confirmModalTitle, + prompt: confirmModalPrompt, + confirmText: confirmModalConfirmText, + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + } + + if (saveableReceipts.length > 0) { + const result = await showConfirmModal({ + title: translate('initialSettingsPage.saveReceiptsConfirmation.title'), + prompt: translate('initialSettingsPage.saveReceiptsConfirmation.prompt', { + count: saveableReceipts.length, + }), + confirmText: translate('initialSettingsPage.saveReceiptsConfirmation.confirm'), + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + await saveReceipts(); + } + } + + if (isTrackingGPS) { + stopGpsTripNotification(); + stopLocationUpdatesAsync(BACKGROUND_LOCATION_TRACKING_TASK_NAME).catch((error) => console.error('[GPS distance request] Failed to stop location tracking', error)); + } + + signOutAndRedirectToSignIn(); +} + +/** + * Forced sign-out (expired session, SAML re-auth) must be non-interactive: it must not touch the gallery flow, + * which can trigger OS permission prompts and delay the redirect. + */ +function signOutImmediately() { + signOutAndRedirectToSignIn(); +} + +export {signOutInteractively, signOutImmediately}; +export type {InteractiveSignOutParams}; diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx index 8aca2cc724ce..66d3ad22b4c0 100644 --- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx +++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx @@ -7,23 +7,25 @@ import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useRootNavigationState from '@hooks/useRootNavigationState'; import useShouldShowRequire2FAPage from '@hooks/useShouldShowRequire2FAPage'; import useThemeStyles from '@hooks/useThemeStyles'; import useTwoFactorAuthRoute from '@hooks/useTwoFactorAuthRoute'; +import {signOutInteractively} from '@libs/actions/InteractiveSignOut'; import Navigation, {getDeepestFocusedScreen, isTwoFactorSetupScreen} from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; -import {signOutAndRedirectToSignIn} from '@userActions/Session'; import {updateOnboardingLastVisitedPath} from '@userActions/Welcome'; import {buildOnboardingFlowParams, getRequired2FAOnboardingResumePath} from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import {isTrackingSelector} from '@src/selectors/GPSDraftDetails'; import {emailSelector} from '@src/selectors/Session'; import type {Policy} from '@src/types/onyx'; @@ -60,8 +62,12 @@ function RequireTwoFactorAuthenticationOverlay() { const illustrations = useMemoizedLazyIllustrations(['Encryption']); const styles = useThemeStyles(); const {translate} = useLocalize(); + const network = useNetwork(); const {showConfirmModal} = useConfirmModal(); const {getTwoFactorAuthRoute} = useTwoFactorAuthRoute(); + const [isTrackingGPS = false] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS, { + selector: isTrackingSelector, + }); const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); @@ -94,12 +100,12 @@ function RequireTwoFactorAuthenticationOverlay() { snapshotOnboardingResumePathIfNeeded(); }, [shouldShowRequire2FAPage, isIn2FASetupFlow, snapshotOnboardingResumePathIfNeeded]); - const handleEnableTwoFactorAuthPress = () => { + const enableTwoFactorAuth = () => { snapshotOnboardingResumePathIfNeeded(); Navigation.navigate(getTwoFactorAuthRoute(ROUTES.SETTINGS_SECURITY, {forceSetup: true})); }; - const handleSignOutPress = async () => { + const confirmSignOut = async () => { const result = await showConfirmModal({ title: translate('common.areYouSure'), prompt: translate('initialSettingsPage.signOutConfirmationText'), @@ -109,9 +115,17 @@ function RequireTwoFactorAuthenticationOverlay() { buttonVariant: CONST.BUTTON_VARIANT.DANGER, }); - if (result.action === ModalActions.CONFIRM) { - signOutAndRedirectToSignIn(); + if (result.action !== ModalActions.CONFIRM) { + return; } + + await signOutInteractively({ + translate, + isOffline: network.isOffline, + isTrackingGPS, + showConfirmModal, + hasConfirmedSignOut: true, + }); }; if (!shouldShowRequire2FAPage || isIn2FASetupFlow) { @@ -144,7 +158,7 @@ function RequireTwoFactorAuthenticationOverlay() { @@ -153,7 +167,7 @@ function RequireTwoFactorAuthenticationOverlay() { - - - - + + + From bd1436c7b36fdbaf427245806638b2d2da337dfe Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Tue, 8 Sep 2026 00:54:06 +0530 Subject: [PATCH 05/18] Revert manual non-English translation updates Only en.ts should define new copy; other locales are generated by Polyglot Parrot. --- src/languages/de.ts | 1 - src/languages/el.ts | 1 - src/languages/es.ts | 1 - src/languages/fr.ts | 1 - src/languages/it.ts | 1 - src/languages/ja.ts | 1 - src/languages/nl.ts | 1 - src/languages/pl.ts | 1 - src/languages/pt-BR.ts | 1 - src/languages/zh-hans.ts | 1 - 10 files changed, 10 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 0dc808d08e9b..fcd64e43ac96 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2490,7 +2490,6 @@ const translations: TranslationDeepObject = { copy: 'Kopieren', disable: 'Deaktivieren', enableTwoFactorAuth: 'Zwei-Faktor-Authentifizierung aktivieren', - enable2FA: '2FA aktivieren', pleaseEnableTwoFactorAuth: 'Bitte aktiviere die Zwei-Faktor-Authentifizierung.', twoFactorAuthIsRequiredDescription: 'Aus Sicherheitsgründen erfordert Xero eine Zwei-Faktor-Authentifizierung, um die Integration zu verbinden.', twoFactorAuthIsRequiredForAdminsHeader: 'Zwei-Faktor-Authentifizierung erforderlich', diff --git a/src/languages/el.ts b/src/languages/el.ts index ff4d945994d2..138a081eea15 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -2549,7 +2549,6 @@ const translations: TranslationDeepObject = { copyCodes: 'Αντιγραφή κωδικών', disable: 'Απενεργοποίηση', enableTwoFactorAuth: 'Ενεργοποιήστε τον έλεγχο ταυτότητας δύο παραγόντων', - enable2FA: 'Ενεργοποίηση 2FA', pleaseEnableTwoFactorAuth: 'Παρακαλούμε ενεργοποιήστε τον έλεγχο ταυτότητας δύο παραγόντων.', twoFactorAuthIsRequiredDescription: 'Για λόγους ασφαλείας, το Xero απαιτεί έλεγχο ταυτότητας δύο παραγόντων για να συνδεθεί η ενοποίηση.', twoFactorAuthIsRequiredForAdminsHeader: 'Απαιτείται έλεγχος ταυτότητας δύο παραγόντων', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2dee63fc1636..5159e513269f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2412,7 +2412,6 @@ const translations: TranslationDeepObject = { copy: 'Copiar', disable: 'Deshabilitar', enableTwoFactorAuth: 'Activar la autenticación de dos factores', - enable2FA: 'Activar 2FA', pleaseEnableTwoFactorAuth: 'Activa la autenticación de dos factores.', twoFactorAuthIsRequiredDescription: 'Por razones de seguridad, Xero requiere la autenticación de dos factores para conectar la integración.', twoFactorAuthIsRequiredForAdminsHeader: 'Autenticación de dos factores requerida', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 7b66de39deed..81c7f0bc4d68 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2496,7 +2496,6 @@ const translations: TranslationDeepObject = { copy: 'Copier', disable: 'Désactiver', enableTwoFactorAuth: 'Activer l’authentification à deux facteurs', - enable2FA: 'Activer 2FA', pleaseEnableTwoFactorAuth: 'Veuillez activer l’authentification à deux facteurs.', twoFactorAuthIsRequiredDescription: 'Pour des raisons de sécurité, Xero exige une authentification à deux facteurs pour connecter l’intégration.', twoFactorAuthIsRequiredForAdminsHeader: 'Authentification à deux facteurs requise', diff --git a/src/languages/it.ts b/src/languages/it.ts index 3e1c7b9b1111..fe6473b120f2 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2484,7 +2484,6 @@ const translations: TranslationDeepObject = { copy: 'Copia', disable: 'Disattiva', enableTwoFactorAuth: "Abilita l'autenticazione a due fattori", - enable2FA: 'Abilita 2FA', pleaseEnableTwoFactorAuth: 'Abilita l’autenticazione a due fattori.', twoFactorAuthIsRequiredDescription: 'Per motivi di sicurezza, Xero richiede l’autenticazione a due fattori per connettere l’integrazione.', twoFactorAuthIsRequiredForAdminsHeader: 'Autenticazione a due fattori richiesta', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 43ae3df02be1..3c1b95ebe38f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2463,7 +2463,6 @@ const translations: TranslationDeepObject = { copy: 'コピー', disable: '無効にする', enableTwoFactorAuth: '2 要素認証を有効にする', - enable2FA: '2FAを有効にする', pleaseEnableTwoFactorAuth: '二要素認証を有効にしてください。', twoFactorAuthIsRequiredDescription: 'セキュリティ保護のため、統合を接続するには Xero で二要素認証が必要です。', twoFactorAuthIsRequiredForAdminsHeader: '2要素認証が必要です', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 8b3d4bcc91e7..7c747ea52230 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2483,7 +2483,6 @@ const translations: TranslationDeepObject = { copy: 'Kopiëren', disable: 'Uitschakelen', enableTwoFactorAuth: 'Verificatie in twee stappen inschakelen', - enable2FA: '2FA inschakelen', pleaseEnableTwoFactorAuth: 'Schakel alsjeblieft tweefactorauthenticatie in.', twoFactorAuthIsRequiredDescription: 'Om veiligheidsredenen vereist Xero twee-factor-authenticatie om de integratie te koppelen.', twoFactorAuthIsRequiredForAdminsHeader: 'Tweefactorauthenticatie vereist', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index c9cb37388d59..e69072478685 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2518,7 +2518,6 @@ const translations: TranslationDeepObject = { copy: 'Kopiuj', disable: 'Wyłącz', enableTwoFactorAuth: 'Włącz uwierzytelnianie dwuskładnikowe', - enable2FA: 'Włącz 2FA', pleaseEnableTwoFactorAuth: 'Włącz uwierzytelnianie dwuskładnikowe.', twoFactorAuthIsRequiredDescription: 'Ze względów bezpieczeństwa Xero wymaga uwierzytelniania dwuskładnikowego, aby połączyć integrację.', twoFactorAuthIsRequiredForAdminsHeader: 'Wymagane uwierzytelnianie dwuskładnikowe', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 59f959bd2090..15e141825255 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2477,7 +2477,6 @@ const translations: TranslationDeepObject = { copy: 'Copiar', disable: 'Desativar', enableTwoFactorAuth: 'Ativar autenticação em duas etapas', - enable2FA: 'Ativar 2FA', pleaseEnableTwoFactorAuth: 'Ative a autenticação em duas etapas.', twoFactorAuthIsRequiredDescription: 'Por motivos de segurança, a Xero exige autenticação em duas etapas para conectar a integração.', twoFactorAuthIsRequiredForAdminsHeader: 'Autenticação em duas etapas obrigatória', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2da179e9b156..79a861d90b99 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2398,7 +2398,6 @@ const translations: TranslationDeepObject = { copy: '复制', disable: '禁用', enableTwoFactorAuth: '启用双重身份验证', - enable2FA: '启用 2FA', pleaseEnableTwoFactorAuth: '请启用双重身份验证。', twoFactorAuthIsRequiredDescription: '出于安全原因,Xero 要求使用双重身份验证才能连接此集成。', twoFactorAuthIsRequiredForAdminsHeader: '需要启用双重身份验证', From 0e050196ea04a22aeb99bc3e9603b3ea15c484e8 Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Tue, 8 Sep 2026 12:57:40 +0530 Subject: [PATCH 06/18] Shorten require-2FA overlay button label to Enable Per design feedback, use Enable instead of Enable 2FA on the button; body copy still includes 2FA for context. --- src/languages/en.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 39ff405b7f28..cee456c834c2 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2592,7 +2592,7 @@ const translations = { copyCodes: 'Copy codes', disable: 'Disable', enableTwoFactorAuth: 'Enable two-factor authentication', - enable2FA: 'Enable 2FA', + enable2FA: 'Enable', pleaseEnableTwoFactorAuth: 'Please enable two-factor authentication.', twoFactorAuthIsRequiredDescription: 'For security purposes, Xero requires two-factor authentication to connect the integration.', twoFactorAuthIsRequiredNetSuiteDescription: 'For security purposes, NetSuite requires two-factor authentication to connect the integration.', From 2d3a8c232c1f7c14dfd4b97d21c39448f7613327 Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Thu, 10 Sep 2026 01:15:29 +0530 Subject: [PATCH 07/18] Apply Polyglot Parrot translations for require-2FA overlay strings. Adds twoFactorAuth.enable2FA and updates company 2FA copy across locales. --- src/languages/de.ts | 3 ++- src/languages/el.ts | 3 ++- src/languages/es.ts | 3 ++- src/languages/fr.ts | 3 ++- src/languages/it.ts | 3 ++- src/languages/ja.ts | 3 ++- src/languages/nl.ts | 3 ++- src/languages/pl.ts | 3 ++- src/languages/pt-BR.ts | 3 ++- src/languages/zh-hans.ts | 3 ++- 10 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index fcd64e43ac96..6518de3188c8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2495,7 +2495,7 @@ const translations: TranslationDeepObject = { twoFactorAuthIsRequiredForAdminsHeader: 'Zwei-Faktor-Authentifizierung erforderlich', twoFactorAuthIsRequiredForAdminsTitle: 'Bitte aktiviere die Zwei-Faktor-Authentifizierung', twoFactorAuthIsRequiredXero: 'Ihre Xero-Buchhaltungsverbindung erfordert eine Zwei-Faktor-Authentifizierung.', - twoFactorAuthIsRequiredCompany: 'Ihr Unternehmen verlangt eine Zwei-Faktor-Authentifizierung.', + twoFactorAuthIsRequiredCompany: 'Ihr Unternehmen verlangt eine Zwei-Faktor-Authentifizierung (2FA).', twoFactorAuthCannotDisable: '2FA kann nicht deaktiviert werden', twoFactorAuthRequired: 'Die Zwei-Faktor-Authentifizierung (2FA) ist für Ihre Xero-Verbindung erforderlich und kann nicht deaktiviert werden.', replaceDevice: 'Gerät ersetzen', @@ -2508,6 +2508,7 @@ const translations: TranslationDeepObject = { downloadCodes: 'Codes herunterladen', copyCodes: 'Codes kopieren', twoFactorAuthIsRequiredNetSuiteDescription: 'Aus Sicherheitsgründen erfordert NetSuite zur Verbindung der Integration eine Zwei-Faktor-Authentifizierung.', + enable2FA: 'Aktivieren', }, recoveryCodeForm: { error: { diff --git a/src/languages/el.ts b/src/languages/el.ts index 138a081eea15..20381332d87c 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -2554,7 +2554,7 @@ const translations: TranslationDeepObject = { twoFactorAuthIsRequiredForAdminsHeader: 'Απαιτείται έλεγχος ταυτότητας δύο παραγόντων', twoFactorAuthIsRequiredForAdminsTitle: 'Παρακαλούμε ενεργοποιήστε τον έλεγχο ταυτότητας δύο παραγόντων', twoFactorAuthIsRequiredXero: 'Η σύνδεσή σας με το λογιστικό σύστημα Xero απαιτεί έλεγχο ταυτότητας δύο παραγόντων.', - twoFactorAuthIsRequiredCompany: 'Η εταιρεία σας απαιτεί έλεγχο ταυτότητας δύο παραγόντων.', + twoFactorAuthIsRequiredCompany: 'Η εταιρεία σας απαιτεί έλεγχο ταυτότητας δύο παραγόντων (2FA).', twoFactorAuthCannotDisable: 'Δεν είναι δυνατή η απενεργοποίηση του 2FA', twoFactorAuthRequired: 'Η επαλήθευση δύο παραγόντων (2FA) είναι υποχρεωτική για τη σύνδεσή σας με το Xero και δεν μπορεί να απενεργοποιηθεί.', replaceDevice: 'Αντικατάσταση συσκευής', @@ -2565,6 +2565,7 @@ const translations: TranslationDeepObject = { verifyNewDeviceTitle: 'Ρύθμιση νέας συσκευής', verifyNewDeviceDescription: 'Σαρώστε τον κωδικό QR με τη νέα σας συσκευή και κατόπιν εισαγάγετε τον κωδικό για να ολοκληρώσετε τη ρύθμιση.', twoFactorAuthIsRequiredNetSuiteDescription: 'Για λόγους ασφαλείας, το NetSuite απαιτεί έλεγχο ταυτότητας δύο παραγόντων για τη σύνδεση της ενοποίησης.', + enable2FA: 'Ενεργοποίηση', }, recoveryCodeForm: { error: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 5159e513269f..4bf25a83ad34 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2417,7 +2417,7 @@ const translations: TranslationDeepObject = { twoFactorAuthIsRequiredForAdminsHeader: 'Autenticación de dos factores requerida', twoFactorAuthIsRequiredForAdminsTitle: 'Por favor, habilita la autenticación de dos factores', twoFactorAuthIsRequiredXero: 'Tu conexión de contabilidad con Xero requiere el uso de autenticación de dos factores. Por favor, habilítala para seguir usando Expensify.', - twoFactorAuthIsRequiredCompany: 'Tu empresa requiere el uso de autenticación de dos factores. Por favor, habilítala para seguir usando Expensify.', + twoFactorAuthIsRequiredCompany: 'Tu empresa requiere autenticación de dos factores (2FA).', twoFactorAuthCannotDisable: 'No se puede desactivar la autenticación de dos factores (2FA)', twoFactorAuthRequired: 'La autenticación de dos factores (2FA) es obligatoria para tu conexión a Xero y no se puede desactivar.', replaceDevice: 'Reemplazar dispositivo', @@ -2430,6 +2430,7 @@ const translations: TranslationDeepObject = { downloadCodes: 'Descargar códigos', copyCodes: 'Copiar códigos', twoFactorAuthIsRequiredNetSuiteDescription: 'Por motivos de seguridad, NetSuite requiere autenticación de dos factores para conectar la integración.', + enable2FA: 'Habilitar', }, recoveryCodeForm: { error: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 81c7f0bc4d68..417c8da75179 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2501,7 +2501,7 @@ const translations: TranslationDeepObject = { twoFactorAuthIsRequiredForAdminsHeader: 'Authentification à deux facteurs requise', twoFactorAuthIsRequiredForAdminsTitle: 'Veuillez activer l’authentification à deux facteurs', twoFactorAuthIsRequiredXero: 'Votre connexion comptable Xero nécessite une authentification à deux facteurs.', - twoFactorAuthIsRequiredCompany: 'Votre entreprise exige l’authentification à deux facteurs.', + twoFactorAuthIsRequiredCompany: 'Votre entreprise exige l’authentification à deux facteurs (2FA).', twoFactorAuthCannotDisable: 'Impossible de désactiver la 2FA', twoFactorAuthRequired: 'L’authentification à deux facteurs (2FA) est requise pour votre connexion Xero et ne peut pas être désactivée.', replaceDevice: 'Remplacer l’appareil', @@ -2514,6 +2514,7 @@ const translations: TranslationDeepObject = { downloadCodes: 'Télécharger les codes', copyCodes: 'Copier les codes', twoFactorAuthIsRequiredNetSuiteDescription: "Pour des raisons de sécurité, NetSuite exige une authentification à deux facteurs pour connecter l'intégration.", + enable2FA: 'Activer', }, recoveryCodeForm: { error: { diff --git a/src/languages/it.ts b/src/languages/it.ts index fe6473b120f2..65cd425c081c 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2489,7 +2489,7 @@ const translations: TranslationDeepObject = { twoFactorAuthIsRequiredForAdminsHeader: 'Autenticazione a due fattori richiesta', twoFactorAuthIsRequiredForAdminsTitle: 'Abilita l’autenticazione a due fattori', twoFactorAuthIsRequiredXero: 'La tua connessione contabile Xero richiede l’autenticazione a due fattori.', - twoFactorAuthIsRequiredCompany: 'La tua azienda richiede l’autenticazione a due fattori.', + twoFactorAuthIsRequiredCompany: 'La tua azienda richiede l’autenticazione a due fattori (2FA).', twoFactorAuthCannotDisable: "Impossibile disabilitare l'autenticazione a due fattori", twoFactorAuthRequired: 'Per la connessione a Xero è richiesta l’autenticazione a due fattori (2FA) e non può essere disattivata.', replaceDevice: 'Sostituisci dispositivo', @@ -2502,6 +2502,7 @@ const translations: TranslationDeepObject = { downloadCodes: 'Scarica codici', copyCodes: 'Copia codici', twoFactorAuthIsRequiredNetSuiteDescription: 'Per motivi di sicurezza, NetSuite richiede l’autenticazione a due fattori per collegare l’integrazione.', + enable2FA: 'Abilita', }, recoveryCodeForm: { error: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 3c1b95ebe38f..d952de9d01af 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2468,7 +2468,7 @@ const translations: TranslationDeepObject = { twoFactorAuthIsRequiredForAdminsHeader: '2要素認証が必要です', twoFactorAuthIsRequiredForAdminsTitle: '2要素認証を有効にしてください', twoFactorAuthIsRequiredXero: 'Xero 会計連携には二要素認証が必要です。', - twoFactorAuthIsRequiredCompany: 'あなたの会社では、2 要素認証が必須です。', + twoFactorAuthIsRequiredCompany: 'お客様の会社では、二要素認証(2FA)が必須です。', twoFactorAuthCannotDisable: '2要素認証を無効にできません', twoFactorAuthRequired: 'Xero 連携には二要素認証(2FA)が必須で、無効にすることはできません。', replaceDevice: 'デバイスを交換', @@ -2481,6 +2481,7 @@ const translations: TranslationDeepObject = { downloadCodes: 'コードをダウンロード', copyCodes: 'コードをコピー', twoFactorAuthIsRequiredNetSuiteDescription: 'セキュリティ上の理由により、連携を接続するには NetSuite で二要素認証が必要です。', + enable2FA: '有効にする', }, recoveryCodeForm: { error: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 7c747ea52230..d80991c722ed 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2488,7 +2488,7 @@ const translations: TranslationDeepObject = { twoFactorAuthIsRequiredForAdminsHeader: 'Tweefactorauthenticatie vereist', twoFactorAuthIsRequiredForAdminsTitle: 'Schakel tweestapsverificatie in', twoFactorAuthIsRequiredXero: 'Je Xero-boekhoudkoppeling vereist tweefactorauthenticatie.', - twoFactorAuthIsRequiredCompany: 'Je bedrijf vereist tweefactorauthenticatie.', + twoFactorAuthIsRequiredCompany: 'Je bedrijf vereist tweestapsverificatie (2FA).', twoFactorAuthCannotDisable: 'Kan 2FA niet uitschakelen', twoFactorAuthRequired: 'Tweestapsverificatie (2FA) is vereist voor je Xero-verbinding en kan niet worden uitgeschakeld.', replaceDevice: 'Apparaat vervangen', @@ -2501,6 +2501,7 @@ const translations: TranslationDeepObject = { downloadCodes: 'Codes downloaden', copyCodes: 'Codes kopiëren', twoFactorAuthIsRequiredNetSuiteDescription: 'Om veiligheidsredenen vereist NetSuite tweeledige verificatie om de koppeling te maken.', + enable2FA: 'Inschakelen', }, recoveryCodeForm: { error: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index e69072478685..d68912224031 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2523,7 +2523,7 @@ const translations: TranslationDeepObject = { twoFactorAuthIsRequiredForAdminsHeader: 'Wymagane uwierzytelnianie dwuskładnikowe', twoFactorAuthIsRequiredForAdminsTitle: 'Włącz uwierzytelnianie dwuskładnikowe', twoFactorAuthIsRequiredXero: 'Twoje połączenie księgowe z Xero wymaga uwierzytelniania dwuskładnikowego.', - twoFactorAuthIsRequiredCompany: 'Twoetapowe uwierzytelnianie jest wymagane przez Twoją firmę.', + twoFactorAuthIsRequiredCompany: 'Twoja firma wymaga uwierzytelniania dwuskładnikowego (2FA).', twoFactorAuthCannotDisable: 'Nie można wyłączyć 2FA', twoFactorAuthRequired: 'Dla połączenia z Xero wymagana jest weryfikacja dwuetapowa (2FA) i nie można jej wyłączyć.', replaceDevice: 'Zastąp urządzenie', @@ -2536,6 +2536,7 @@ const translations: TranslationDeepObject = { downloadCodes: 'Pobierz kody', copyCodes: 'Skopiuj kody', twoFactorAuthIsRequiredNetSuiteDescription: 'Ze względów bezpieczeństwa NetSuite wymaga uwierzytelniania dwuskładnikowego, aby połączyć integrację.', + enable2FA: 'Włącz', }, recoveryCodeForm: { error: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 15e141825255..d56cbda9bb7e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2482,7 +2482,7 @@ const translations: TranslationDeepObject = { twoFactorAuthIsRequiredForAdminsHeader: 'Autenticação em duas etapas obrigatória', twoFactorAuthIsRequiredForAdminsTitle: 'Ative a autenticação em duas etapas', twoFactorAuthIsRequiredXero: 'Sua conexão contábil com o Xero exige autenticação em duas etapas.', - twoFactorAuthIsRequiredCompany: 'Sua empresa exige autenticação em duas etapas.', + twoFactorAuthIsRequiredCompany: 'Sua empresa exige autenticação em duas etapas (2FA).', twoFactorAuthCannotDisable: 'Não é possível desativar a 2FA', twoFactorAuthRequired: 'A autenticação em duas etapas (2FA) é obrigatória para sua conexão com o Xero e não pode ser desativada.', replaceDevice: 'Substituir dispositivo', @@ -2495,6 +2495,7 @@ const translations: TranslationDeepObject = { downloadCodes: 'Baixar códigos', copyCodes: 'Copiar códigos', twoFactorAuthIsRequiredNetSuiteDescription: 'Por motivos de segurança, o NetSuite exige autenticação em duas etapas para conectar a integração.', + enable2FA: 'Ativar', }, recoveryCodeForm: { error: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 79a861d90b99..98e4b0582063 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2403,7 +2403,7 @@ const translations: TranslationDeepObject = { twoFactorAuthIsRequiredForAdminsHeader: '需要启用双重身份验证', twoFactorAuthIsRequiredForAdminsTitle: '请启用双重身份验证', twoFactorAuthIsRequiredXero: '您的 Xero 会计连接需要启用双重身份验证。', - twoFactorAuthIsRequiredCompany: '您的公司要求使用双重身份验证。', + twoFactorAuthIsRequiredCompany: '您的公司要求使用双重身份验证(2FA)。', twoFactorAuthCannotDisable: '无法禁用双重验证', twoFactorAuthRequired: '您的 Xero 连接需要启用双重身份验证(2FA),且无法将其禁用。', replaceDevice: '更换设备', @@ -2416,6 +2416,7 @@ const translations: TranslationDeepObject = { downloadCodes: '下载代码', copyCodes: '复制代码', twoFactorAuthIsRequiredNetSuiteDescription: '出于安全考虑,NetSuite 要求使用双重身份验证来连接此集成。', + enable2FA: '启用', }, recoveryCodeForm: { error: { From b2402bc0435e38b25b3840d4daed73613176e74c Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Fri, 18 Sep 2026 12:48:02 +0530 Subject: [PATCH 08/18] Address PR review: useSignOut hook, copilot leave account, tests Move interactive sign-out into useSignOut hook, show Leave account on the require-2FA overlay for copilots (disconnect instead of full sign-out), and add unit tests for sign-out and delegate leave paths. --- src/hooks/useSignOut.ts | 198 ++++++++++++++ src/languages/en.ts | 2 + src/libs/actions/InteractiveSignOut.ts | 129 --------- .../RequireTwoFactorAuthenticationOverlay.tsx | 36 +-- .../useInitialSettingsPageMenuData.ts | 15 +- tests/unit/hooks/useSignOutTest.ts | 246 ++++++++++++++++++ 6 files changed, 470 insertions(+), 156 deletions(-) create mode 100644 src/hooks/useSignOut.ts delete mode 100644 src/libs/actions/InteractiveSignOut.ts create mode 100644 tests/unit/hooks/useSignOutTest.ts diff --git a/src/hooks/useSignOut.ts b/src/hooks/useSignOut.ts new file mode 100644 index 000000000000..d330ffd87b3a --- /dev/null +++ b/src/hooks/useSignOut.ts @@ -0,0 +1,198 @@ +import {ModalActions} from '@components/Modal/Global/ModalContext'; + +import {disconnect} from '@libs/actions/Delegate'; +import {showPermissionErrorAlert} from '@libs/fileDownload/FileUtils'; +import {getGpsPoints, stopGpsTrip} from '@libs/GPSDraftDetailsUtils'; +import Log from '@libs/Log'; +import {getSaveablePendingReceiptRequests, saveReceiptsToGallery} from '@libs/savePendingReceiptsToGallery'; + +import {BACKGROUND_LOCATION_TRACKING_TASK_NAME} from '@pages/iou/request/step/IOURequestStepDistanceGPS/const'; +import {stopGpsTripNotification} from '@pages/iou/request/step/IOURequestStepDistanceGPS/GPSNotifications'; + +import {signOutAndRedirectToSignIn} from '@userActions/Session'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {isActingAsDelegateSelector} from '@src/selectors/Account'; +import {isTrackingSelector} from '@src/selectors/GPSDraftDetails'; + +import {stopLocationUpdatesAsync} from 'expo-location'; +import {useCallback} from 'react'; + +import useConfirmModal from './useConfirmModal'; +import useLocalize from './useLocalize'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; + +type SignOutOptions = { + /** When true, the user already confirmed generic sign-out (e.g. from the require-2FA overlay). Skips the fast-path and duplicate generic offline prompts; GPS and receipt prompts still run. */ + hasConfirmedSignOut?: boolean; +}; + +type LeaveDelegateAccountOptions = { + /** When true, the user already confirmed leaving the delegated account (e.g. from the require-2FA overlay). Skips the duplicate generic leave prompt; offline and GPS prompts still run. */ + hasConfirmedLeave?: boolean; +}; + +function useSignOut() { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const {showConfirmModal} = useConfirmModal(); + const [isTrackingGPS = false] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS, { + selector: isTrackingSelector, + }); + const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); + const [isActingAsDelegate = false] = useOnyx(ONYXKEYS.ACCOUNT, { + selector: isActingAsDelegateSelector, + }); + const [stashedCredentials = CONST.EMPTY_OBJECT] = useOnyx(ONYXKEYS.STASHED_CREDENTIALS); + const [stashedSession] = useOnyx(ONYXKEYS.STASHED_SESSION); + + const showOfflineModal = useCallback(() => { + showConfirmModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.offlinePrompt'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }, [showConfirmModal, translate]); + + const signOutImmediately = useCallback(() => { + signOutAndRedirectToSignIn(); + }, []); + + const signOut = useCallback( + async ({hasConfirmedSignOut = false}: SignOutOptions = {}) => { + const saveableReceipts = getSaveablePendingReceiptRequests(); + const shouldWarnBeforeSignOut = isOffline || isTrackingGPS; + const isOfflineReceiptsCase = isOffline && !isTrackingGPS && saveableReceipts.length > 0; + + if (!hasConfirmedSignOut && !shouldWarnBeforeSignOut && saveableReceipts.length === 0) { + signOutAndRedirectToSignIn(); + return; + } + + const confirmModalTitle = isTrackingGPS ? translate('gps.signOutWarningTripInProgress.title') : translate('common.areYouSure'); + const confirmModalPrompt = isTrackingGPS ? translate('gps.signOutWarningTripInProgress.prompt') : translate('initialSettingsPage.signOutConfirmationText'); + const confirmModalConfirmText = isTrackingGPS ? translate('gps.signOutWarningTripInProgress.confirm') : translate('initialSettingsPage.signOut'); + + const saveReceipts = async () => { + try { + const {savedCount, failedCount, permissionDenied} = await saveReceiptsToGallery(saveableReceipts); + Log.info('[Receipt] Saved pending receipts to gallery before sign-out', false, {savedCount, failedCount, permissionDenied}); + if (permissionDenied) { + showPermissionErrorAlert(translate); + } + } catch (error) { + Log.alert('[Receipt] Unexpected rejection from saveReceiptsToGallery; sign-out continued', {error}); + } + }; + + if (isOfflineReceiptsCase) { + const result = await showConfirmModal({ + title: translate('initialSettingsPage.saveReceiptsAndSignOutConfirmation.title'), + prompt: translate('initialSettingsPage.saveReceiptsAndSignOutConfirmation.prompt', { + count: saveableReceipts.length, + }), + confirmText: translate('initialSettingsPage.saveReceiptsAndSignOutConfirmation.confirm'), + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + await saveReceipts(); + } else { + const shouldShowOfflineOrGpsWarning = shouldWarnBeforeSignOut && (!hasConfirmedSignOut || isTrackingGPS); + if (shouldShowOfflineOrGpsWarning) { + const result = await showConfirmModal({ + title: confirmModalTitle, + prompt: confirmModalPrompt, + confirmText: confirmModalConfirmText, + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + } + + if (saveableReceipts.length > 0) { + const result = await showConfirmModal({ + title: translate('initialSettingsPage.saveReceiptsConfirmation.title'), + prompt: translate('initialSettingsPage.saveReceiptsConfirmation.prompt', { + count: saveableReceipts.length, + }), + confirmText: translate('initialSettingsPage.saveReceiptsConfirmation.confirm'), + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + await saveReceipts(); + } + } + + if (isTrackingGPS) { + stopGpsTripNotification(); + stopLocationUpdatesAsync(BACKGROUND_LOCATION_TRACKING_TASK_NAME).catch((error) => console.error('[GPS distance request] Failed to stop location tracking', error)); + } + + signOutAndRedirectToSignIn(); + }, + [isOffline, isTrackingGPS, showConfirmModal, translate], + ); + + const leaveDelegateAccount = useCallback( + async ({hasConfirmedLeave = false}: LeaveDelegateAccountOptions = {}) => { + if (isOffline) { + showOfflineModal(); + return; + } + + if (!hasConfirmedLeave) { + const result = await showConfirmModal({ + title: translate('common.areYouSure'), + prompt: translate('delegate.leaveAccountConfirmationText'), + confirmText: translate('delegate.leaveAccount'), + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + } + + const leaveAction = () => disconnect({stashedCredentials, stashedSession}); + + if (isTrackingGPS) { + const result = await showConfirmModal({ + title: translate('gps.switchAccountWarningTripInProgress.title'), + prompt: translate('gps.switchAccountWarningTripInProgress.prompt'), + confirmText: translate('gps.switchAccountWarningTripInProgress.confirm'), + cancelText: translate('common.cancel'), + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + await stopGpsTrip(false, getGpsPoints(gpsDraftDetails), true); + } + + leaveAction(); + }, + [gpsDraftDetails, isOffline, isTrackingGPS, showConfirmModal, showOfflineModal, stashedCredentials, stashedSession, translate], + ); + + return { + signOut, + signOutImmediately, + leaveDelegateAccount, + isActingAsDelegate, + }; +} + +export default useSignOut; diff --git a/src/languages/en.ts b/src/languages/en.ts index cee456c834c2..6f9fa1fc504c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -10750,6 +10750,8 @@ const translations = { removedRoomAvatar: 'removed the room avatar', }, delegate: { + leaveAccount: 'Leave account', + leaveAccountConfirmationText: "You'll return to your own account. You won't be fully signed out.", switchAccount: 'Switch accounts:', switch: 'Switch', copilot: 'Copilot', diff --git a/src/libs/actions/InteractiveSignOut.ts b/src/libs/actions/InteractiveSignOut.ts deleted file mode 100644 index c1f53e43e309..000000000000 --- a/src/libs/actions/InteractiveSignOut.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type {LocaleContextProps} from '@components/LocaleContextProvider'; -import type ConfirmModalWrapper from '@components/Modal/Global/ConfirmModalWrapper'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; -import type {ModalProps} from '@components/Modal/Global/ModalContext'; - -import {showPermissionErrorAlert} from '@libs/fileDownload/FileUtils'; -import Log from '@libs/Log'; -import {getSaveablePendingReceiptRequests, saveReceiptsToGallery} from '@libs/savePendingReceiptsToGallery'; - -import {BACKGROUND_LOCATION_TRACKING_TASK_NAME} from '@pages/iou/request/step/IOURequestStepDistanceGPS/const'; -import {stopGpsTripNotification} from '@pages/iou/request/step/IOURequestStepDistanceGPS/GPSNotifications'; - -import CONST from '@src/CONST'; - -import type React from 'react'; - -import {stopLocationUpdatesAsync} from 'expo-location'; - -import {signOutAndRedirectToSignIn} from './Session'; - -type ConfirmModalOptions = Omit, keyof ModalProps> & { - id?: string; -}; - -type ShowConfirmModal = (options: ConfirmModalOptions) => Promise<{action: string}>; - -type InteractiveSignOutParams = { - translate: LocaleContextProps['translate']; - isOffline: boolean; - isTrackingGPS: boolean; - showConfirmModal: ShowConfirmModal; - /** When true, the user already confirmed generic sign-out (e.g. from the require-2FA overlay). Skips the fast-path and duplicate generic offline prompts; GPS and receipt prompts still run. */ - hasConfirmedSignOut?: boolean; -}; - -/** - * Interactive sign-out used by Settings and blocking overlays. - * Saves queued receipts, warns when offline or GPS tracking is active, then clears the session. - */ -async function signOutInteractively({translate, isOffline, isTrackingGPS, showConfirmModal, hasConfirmedSignOut = false}: InteractiveSignOutParams) { - const saveableReceipts = getSaveablePendingReceiptRequests(); - const shouldWarnBeforeSignOut = isOffline || isTrackingGPS; - const isOfflineReceiptsCase = isOffline && !isTrackingGPS && saveableReceipts.length > 0; - - if (!hasConfirmedSignOut && !shouldWarnBeforeSignOut && saveableReceipts.length === 0) { - return signOutAndRedirectToSignIn(); - } - - const confirmModalTitle = isTrackingGPS ? translate('gps.signOutWarningTripInProgress.title') : translate('common.areYouSure'); - const confirmModalPrompt = isTrackingGPS ? translate('gps.signOutWarningTripInProgress.prompt') : translate('initialSettingsPage.signOutConfirmationText'); - const confirmModalConfirmText = isTrackingGPS ? translate('gps.signOutWarningTripInProgress.confirm') : translate('initialSettingsPage.signOut'); - - const saveReceipts = async () => { - try { - const {savedCount, failedCount, permissionDenied} = await saveReceiptsToGallery(saveableReceipts); - Log.info('[Receipt] Saved pending receipts to gallery before sign-out', false, {savedCount, failedCount, permissionDenied}); - if (permissionDenied) { - showPermissionErrorAlert(translate); - } - } catch (error) { - Log.alert('[Receipt] Unexpected rejection from saveReceiptsToGallery; sign-out continued', {error}); - } - }; - - if (isOfflineReceiptsCase) { - const result = await showConfirmModal({ - title: translate('initialSettingsPage.saveReceiptsAndSignOutConfirmation.title'), - prompt: translate('initialSettingsPage.saveReceiptsAndSignOutConfirmation.prompt', { - count: saveableReceipts.length, - }), - confirmText: translate('initialSettingsPage.saveReceiptsAndSignOutConfirmation.confirm'), - cancelText: translate('common.cancel'), - shouldShowCancelButton: true, - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }); - if (result.action !== ModalActions.CONFIRM) { - return; - } - await saveReceipts(); - } else { - const shouldShowOfflineOrGpsWarning = shouldWarnBeforeSignOut && (!hasConfirmedSignOut || isTrackingGPS); - if (shouldShowOfflineOrGpsWarning) { - const result = await showConfirmModal({ - title: confirmModalTitle, - prompt: confirmModalPrompt, - confirmText: confirmModalConfirmText, - cancelText: translate('common.cancel'), - shouldShowCancelButton: true, - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }); - if (result.action !== ModalActions.CONFIRM) { - return; - } - } - - if (saveableReceipts.length > 0) { - const result = await showConfirmModal({ - title: translate('initialSettingsPage.saveReceiptsConfirmation.title'), - prompt: translate('initialSettingsPage.saveReceiptsConfirmation.prompt', { - count: saveableReceipts.length, - }), - confirmText: translate('initialSettingsPage.saveReceiptsConfirmation.confirm'), - cancelText: translate('common.cancel'), - shouldShowCancelButton: true, - }); - if (result.action !== ModalActions.CONFIRM) { - return; - } - await saveReceipts(); - } - } - - if (isTrackingGPS) { - stopGpsTripNotification(); - stopLocationUpdatesAsync(BACKGROUND_LOCATION_TRACKING_TASK_NAME).catch((error) => console.error('[GPS distance request] Failed to stop location tracking', error)); - } - - signOutAndRedirectToSignIn(); -} - -/** - * Forced sign-out (expired session, SAML re-auth) must be non-interactive: it must not touch the gallery flow, - * which can trigger OS permission prompts and delay the redirect. - */ -function signOutImmediately() { - signOutAndRedirectToSignIn(); -} - -export {signOutInteractively, signOutImmediately}; diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx index 9d1ed9a3d5b6..d13ff7ce5577 100644 --- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx +++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx @@ -7,14 +7,13 @@ import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useRootNavigationState from '@hooks/useRootNavigationState'; import useShouldShowRequire2FAPage from '@hooks/useShouldShowRequire2FAPage'; +import useSignOut from '@hooks/useSignOut'; import useThemeStyles from '@hooks/useThemeStyles'; import useTwoFactorAuthRoute from '@hooks/useTwoFactorAuthRoute'; -import {signOutInteractively} from '@libs/actions/InteractiveSignOut'; import Navigation, {getDeepestFocusedScreen, isTwoFactorSetupScreen} from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; @@ -25,7 +24,6 @@ import {buildOnboardingFlowParams, getRequired2FAOnboardingResumePath} from '@us import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import {isTrackingSelector} from '@src/selectors/GPSDraftDetails'; import {emailSelector} from '@src/selectors/Session'; import type {Policy} from '@src/types/onyx'; @@ -62,12 +60,9 @@ function RequireTwoFactorAuthenticationOverlay() { const illustrations = useMemoizedLazyIllustrations(['Encryption']); const styles = useThemeStyles(); const {translate} = useLocalize(); - const network = useNetwork(); const {showConfirmModal} = useConfirmModal(); const {getTwoFactorAuthRoute} = useTwoFactorAuthRoute(); - const [isTrackingGPS = false] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS, { - selector: isTrackingSelector, - }); + const {signOut, leaveDelegateAccount, isActingAsDelegate} = useSignOut(); const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); @@ -119,13 +114,24 @@ function RequireTwoFactorAuthenticationOverlay() { return; } - await signOutInteractively({ - translate, - isOffline: network.isOffline, - isTrackingGPS, - showConfirmModal, - hasConfirmedSignOut: true, + await signOut({hasConfirmedSignOut: true}); + }; + + const confirmLeaveAccount = async () => { + const result = await showConfirmModal({ + title: translate('common.areYouSure'), + prompt: translate('delegate.leaveAccountConfirmationText'), + confirmText: translate('delegate.leaveAccount'), + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + buttonVariant: CONST.BUTTON_VARIANT.DANGER, }); + + if (result.action !== ModalActions.CONFIRM) { + return; + } + + await leaveDelegateAccount({hasConfirmedLeave: true}); }; if (!shouldShowRequire2FAPage || isIn2FASetupFlow) { @@ -157,9 +163,9 @@ function RequireTwoFactorAuthenticationOverlay() { diff --git a/tests/unit/hooks/useSignOutTest.ts b/tests/unit/hooks/useSignOutTest.ts index 2c4b3002b1c1..fea63c3fe494 100644 --- a/tests/unit/hooks/useSignOutTest.ts +++ b/tests/unit/hooks/useSignOutTest.ts @@ -15,6 +15,11 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {isActingAsDelegateSelector} from '@src/selectors/Account'; import {isTrackingSelector} from '@src/selectors/GPSDraftDetails'; +// eslint-disable-next-line no-restricted-imports -- Type-only namespace import used to type jest.requireActual for the react-native-onyx mock. +import type * as ReactNativeOnyx from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + const mockShowConfirmModal = jest.fn(); const mockTranslate = jest.fn((key: string) => key); @@ -40,6 +45,22 @@ jest.mock('@hooks/useOnyx', () => ({ default: jest.fn(), })); +jest.mock('react-native-onyx', () => { + const actual = jest.requireActual('react-native-onyx'); + return { + ...actual, + __esModule: true, + default: { + ...actual.default, + connectWithoutView: jest.fn(({callback}: {callback: (value: unknown) => void}) => { + callback(undefined); + return 'mock-connection'; + }), + disconnect: jest.fn(), + }, + }; +}); + jest.mock('@userActions/Session', () => ({ signOutAndRedirectToSignIn: jest.fn(), })); @@ -72,8 +93,10 @@ jest.mock('@pages/iou/request/step/IOURequestStepDistanceGPS/const', () => ({ })); const mockUseOnyx = jest.mocked(useOnyx); +const mockConnectWithoutView = jest.mocked(Onyx.connectWithoutView); +const mockOnyxDisconnect = jest.mocked(Onyx.disconnect); const mockSignOutAndRedirectToSignIn = jest.mocked(signOutAndRedirectToSignIn); -const mockDisconnect = jest.mocked(disconnect); +const mockDelegateDisconnect = jest.mocked(disconnect); const mockGetSaveablePendingReceiptRequests = jest.mocked(getSaveablePendingReceiptRequests); const mockSaveReceiptsToGallery = jest.mocked(saveReceiptsToGallery); const mockStopGpsTrip = jest.mocked(stopGpsTrip); @@ -83,24 +106,17 @@ type MockOnyxState = { isActingAsDelegate?: boolean; stashedCredentials?: Record; stashedSession?: {email?: string}; - gpsDraftDetails?: Record; }; const loadedOnyxMetadata = {status: 'loaded'} as const; -function mockOnyxState({ - isTrackingGPS = false, - isActingAsDelegate = false, - stashedCredentials = {}, - stashedSession = {email: 'copilot@expensify.com'}, - gpsDraftDetails = {}, -}: MockOnyxState = {}) { +function mockOnyxState({isTrackingGPS = false, isActingAsDelegate = false, stashedCredentials = {}, stashedSession = {email: 'copilot@expensify.com'}}: MockOnyxState = {}) { mockUseOnyx.mockImplementation((key, options) => { if (key === ONYXKEYS.GPS_DRAFT_DETAILS) { if (options?.selector === isTrackingSelector) { return [isTrackingGPS, loadedOnyxMetadata]; } - return [gpsDraftDetails, loadedOnyxMetadata]; + return [undefined, loadedOnyxMetadata]; } if (key === ONYXKEYS.ACCOUNT) { const account = isActingAsDelegate ? {delegatedAccess: {delegate: 'delegate@expensify.com'}} : {}; @@ -124,41 +140,59 @@ describe('useSignOut', () => { jest.clearAllMocks(); mockIsOffline = false; mockOnyxState(); + mockConnectWithoutView.mockImplementation(({callback}: {callback: (value: unknown) => void}) => { + callback({}); + return 'mock-connection'; + }); mockShowConfirmModal.mockResolvedValue({action: ModalActions.CONFIRM}); }); it('should sign out immediately when online with no GPS tracking or pending receipts', async () => { + // Given the user is online with no active GPS trip and no pending receipts const {result} = renderHook(() => useSignOut()); + // When they sign out from settings await act(async () => { await result.current.signOut(); }); + // Then sign-out proceeds without an extra confirm modal expect(mockSignOutAndRedirectToSignIn).toHaveBeenCalledTimes(1); expect(mockShowConfirmModal).not.toHaveBeenCalled(); }); - it('should skip duplicate generic confirm when hasConfirmedSignOut is true but still sign out', async () => { + it('should show generic confirm when shouldAlwaysConfirm is true before signing out', async () => { + // Given the require-2FA overlay asks the hook to always confirm sign-out const {result} = renderHook(() => useSignOut()); + // When signOut is called with shouldAlwaysConfirm await act(async () => { - await result.current.signOut({hasConfirmedSignOut: true}); + await result.current.signOut({shouldAlwaysConfirm: true}); }); + // Then the generic sign-out confirm appears before redirecting + expect(mockShowConfirmModal).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: 'initialSettingsPage.signOutConfirmationText', + confirmText: 'initialSettingsPage.signOut', + }), + ); expect(mockSignOutAndRedirectToSignIn).toHaveBeenCalledTimes(1); - expect(mockShowConfirmModal).not.toHaveBeenCalled(); }); it('should show merged save-and-sign-out confirm when offline with pending receipts', async () => { + // Given the user is offline and has pending receipts to save mockIsOffline = true; mockGetSaveablePendingReceiptRequests.mockReturnValue([{localPath: 'file:///receipt.jpg', filename: 'receipt.jpg', type: 'image/jpeg'}]); const {result} = renderHook(() => useSignOut()); + // When they attempt to sign out await act(async () => { await result.current.signOut(); }); + // Then the merged save-and-sign-out modal runs before redirecting expect(mockShowConfirmModal).toHaveBeenCalledWith( expect.objectContaining({ title: 'initialSettingsPage.saveReceiptsAndSignOutConfirmation.title', @@ -169,78 +203,105 @@ describe('useSignOut', () => { }); it('should call signOutImmediately without showing confirm modals', () => { + // Given the settings page uses the immediate sign-out escape hatch const {result} = renderHook(() => useSignOut()); + // When signOutImmediately is invoked act(() => { result.current.signOutImmediately(); }); + // Then redirect happens with no confirm modals expect(mockSignOutAndRedirectToSignIn).toHaveBeenCalledTimes(1); expect(mockShowConfirmModal).not.toHaveBeenCalled(); }); it('should disconnect instead of signing out when leaving a delegated account', async () => { + // Given the user is acting as a copilot delegate while online mockOnyxState({isActingAsDelegate: true}); const {result} = renderHook(() => useSignOut()); + // When they choose to leave the delegated account await act(async () => { await result.current.leaveDelegateAccount(); }); + // Then the leave confirm appears and disconnect runs instead of sign-out expect(mockShowConfirmModal).toHaveBeenCalledWith( expect.objectContaining({ prompt: 'delegate.leaveAccountConfirmationText', confirmText: 'delegate.leaveAccount', }), ); - expect(mockDisconnect).toHaveBeenCalledTimes(1); + expect(mockDelegateDisconnect).toHaveBeenCalledTimes(1); expect(mockSignOutAndRedirectToSignIn).not.toHaveBeenCalled(); }); it('should show GPS switch-account warning before disconnecting from a delegated account', async () => { + // Given a copilot is leaving while a GPS trip is in progress mockOnyxState({isActingAsDelegate: true, isTrackingGPS: true}); + mockConnectWithoutView.mockImplementation(({key, callback}: {key: string; callback: (value: unknown) => void}) => { + if (key === ONYXKEYS.GPS_DRAFT_DETAILS) { + callback({points: [{latitude: 1, longitude: 2}]}); + } + return 'mock-connection'; + }); const {result} = renderHook(() => useSignOut()); + // When they confirm leaving the delegated account await act(async () => { - await result.current.leaveDelegateAccount({hasConfirmedLeave: true}); + await result.current.leaveDelegateAccount(); }); + // Then GPS points are read at call time and the trip is stopped before disconnect + expect(mockConnectWithoutView).toHaveBeenCalledWith( + expect.objectContaining({ + key: ONYXKEYS.GPS_DRAFT_DETAILS, + }), + ); + expect(mockOnyxDisconnect).toHaveBeenCalled(); expect(mockShowConfirmModal).toHaveBeenCalledWith( expect.objectContaining({ title: 'gps.switchAccountWarningTripInProgress.title', }), ); expect(mockStopGpsTrip).toHaveBeenCalledTimes(1); - expect(mockDisconnect).toHaveBeenCalledTimes(1); + expect(mockDelegateDisconnect).toHaveBeenCalledTimes(1); expect(mockSignOutAndRedirectToSignIn).not.toHaveBeenCalled(); }); it('should block leaving a delegated account while offline', async () => { + // Given a copilot is offline and tries to leave the delegated account mockIsOffline = true; mockOnyxState({isActingAsDelegate: true}); const {result} = renderHook(() => useSignOut()); + // When leaveDelegateAccount is invoked await act(async () => { - await result.current.leaveDelegateAccount({hasConfirmedLeave: true}); + await result.current.leaveDelegateAccount(); }); + // Then the offline modal appears first and disconnect never runs expect(mockShowConfirmModal).toHaveBeenCalledWith( expect.objectContaining({ title: 'common.youAppearToBeOffline', }), ); - expect(mockDisconnect).not.toHaveBeenCalled(); + expect(mockDelegateDisconnect).not.toHaveBeenCalled(); expect(mockSignOutAndRedirectToSignIn).not.toHaveBeenCalled(); }); it('should expose isActingAsDelegate from account state', () => { + // Given the account is in delegate mode mockOnyxState({isActingAsDelegate: true}); const {result} = renderHook(() => useSignOut()); + // When the hook is rendered + // Then callers can branch on isActingAsDelegate expect(result.current.isActingAsDelegate).toBe(true); }); }); From 3799fda244c022938f4e4ef1a97d364b96a8c188 Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Sat, 19 Sep 2026 13:03:08 +0530 Subject: [PATCH 12/18] Fix typecheck for useSignOut tests and document readOnce Type connectWithoutView mocks with ConnectOptions/Connection. Add inline comment explaining why GPS points use a one-shot connectWithoutView read instead of useOnyx. --- src/hooks/useSignOut.ts | 6 ++++- tests/unit/hooks/useSignOutTest.ts | 36 ++++++++++++++++++++---------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/hooks/useSignOut.ts b/src/hooks/useSignOut.ts index f142952167db..bd139d44c66d 100644 --- a/src/hooks/useSignOut.ts +++ b/src/hooks/useSignOut.ts @@ -31,7 +31,11 @@ type SignOutOptions = { shouldAlwaysConfirm?: boolean; }; -/** Onyx has no promise-based read. */ +/** + * Onyx has no promise-based read. GPS points are only needed after the user confirms leaving a delegated + * account during an active trip (async post-modal callback), so useOnyx cannot supply them without + * subscribing to the full GPS_DRAFT_DETAILS object and re-rendering Settings on every location tick. + */ function readOnce(key: TKey): Promise> { return new Promise((resolve) => { const connection = Onyx.connectWithoutView({ diff --git a/tests/unit/hooks/useSignOutTest.ts b/tests/unit/hooks/useSignOutTest.ts index fea63c3fe494..2b775da4fe7d 100644 --- a/tests/unit/hooks/useSignOutTest.ts +++ b/tests/unit/hooks/useSignOutTest.ts @@ -14,9 +14,11 @@ import {signOutAndRedirectToSignIn} from '@userActions/Session'; import ONYXKEYS from '@src/ONYXKEYS'; import {isActingAsDelegateSelector} from '@src/selectors/Account'; import {isTrackingSelector} from '@src/selectors/GPSDraftDetails'; +import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; // eslint-disable-next-line no-restricted-imports -- Type-only namespace import used to type jest.requireActual for the react-native-onyx mock. import type * as ReactNativeOnyx from 'react-native-onyx'; +import type {ConnectOptions, OnyxKey} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -25,6 +27,13 @@ const mockTranslate = jest.fn((key: string) => key); let mockIsOffline = false; +const mockOnyxConnection = {id: 'mock-connection', callbackID: 'mock-callback-id'}; + +function mockConnectWithoutViewImplementation(connectOptions: ConnectOptions) { + connectOptions.callback?.(undefined, connectOptions.key); + return mockOnyxConnection; +} + jest.mock('@hooks/useLocalize', () => ({ __esModule: true, default: () => ({translate: mockTranslate}), @@ -52,10 +61,7 @@ jest.mock('react-native-onyx', () => { __esModule: true, default: { ...actual.default, - connectWithoutView: jest.fn(({callback}: {callback: (value: unknown) => void}) => { - callback(undefined); - return 'mock-connection'; - }), + connectWithoutView: jest.fn(mockConnectWithoutViewImplementation), disconnect: jest.fn(), }, }; @@ -140,10 +146,7 @@ describe('useSignOut', () => { jest.clearAllMocks(); mockIsOffline = false; mockOnyxState(); - mockConnectWithoutView.mockImplementation(({callback}: {callback: (value: unknown) => void}) => { - callback({}); - return 'mock-connection'; - }); + mockConnectWithoutView.mockImplementation(mockConnectWithoutViewImplementation); mockShowConfirmModal.mockResolvedValue({action: ModalActions.CONFIRM}); }); @@ -241,11 +244,20 @@ describe('useSignOut', () => { it('should show GPS switch-account warning before disconnecting from a delegated account', async () => { // Given a copilot is leaving while a GPS trip is in progress mockOnyxState({isActingAsDelegate: true, isTrackingGPS: true}); - mockConnectWithoutView.mockImplementation(({key, callback}: {key: string; callback: (value: unknown) => void}) => { - if (key === ONYXKEYS.GPS_DRAFT_DETAILS) { - callback({points: [{latitude: 1, longitude: 2}]}); + mockConnectWithoutView.mockImplementation((connectOptions) => { + if (connectOptions.key === ONYXKEYS.GPS_DRAFT_DETAILS) { + const gpsDraftDetails: GpsDraftDetails = { + gpsPoints: [[{lat: 1, long: 2}]], + distanceInMeters: 0, + isTracking: true, + reportID: '1', + unit: 'mi', + }; + connectOptions.callback?.(gpsDraftDetails, connectOptions.key); + } else { + connectOptions.callback?.(undefined, connectOptions.key); } - return 'mock-connection'; + return mockOnyxConnection; }); const {result} = renderHook(() => useSignOut()); From a108d3760a289b670b11fc612ff0c0d8af453a1c Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Sat, 19 Sep 2026 13:19:08 +0530 Subject: [PATCH 13/18] Fix duplicate react-native-onyx import in useSignOutTest Merge type and value imports to satisfy import/no-duplicates ESLint rule. --- tests/unit/hooks/useSignOutTest.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/unit/hooks/useSignOutTest.ts b/tests/unit/hooks/useSignOutTest.ts index 2b775da4fe7d..bf7d24d7e36a 100644 --- a/tests/unit/hooks/useSignOutTest.ts +++ b/tests/unit/hooks/useSignOutTest.ts @@ -16,8 +16,6 @@ import {isActingAsDelegateSelector} from '@src/selectors/Account'; import {isTrackingSelector} from '@src/selectors/GPSDraftDetails'; import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; -// eslint-disable-next-line no-restricted-imports -- Type-only namespace import used to type jest.requireActual for the react-native-onyx mock. -import type * as ReactNativeOnyx from 'react-native-onyx'; import type {ConnectOptions, OnyxKey} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -55,7 +53,7 @@ jest.mock('@hooks/useOnyx', () => ({ })); jest.mock('react-native-onyx', () => { - const actual = jest.requireActual('react-native-onyx'); + const actual = jest.requireActual<{default: typeof Onyx}>('react-native-onyx'); return { ...actual, __esModule: true, From 391660bbc32b9144367e2b03e682178326c2b4a5 Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Tue, 22 Sep 2026 13:53:27 +0530 Subject: [PATCH 14/18] Address tgolen review: ref sync for GPS, centralize readOnce Replace connectWithoutView in useSignOut with GpsDraftDetailsRefSync on the overlay. leaveDelegateAccount accepts gpsDraftDetails from the ref. Extract readOnceOnyxValue for the staging-server migration. --- src/components/GpsDraftDetailsRefSync.tsx | 29 +++++++++ src/hooks/useSignOut.ts | 28 +++------ ...eShouldUseStagingServerWithActiveServer.ts | 20 ++---- src/libs/readOnceOnyxValue.ts | 18 ++++++ .../RequireTwoFactorAuthenticationOverlay.tsx | 10 ++- tests/unit/hooks/useSignOutTest.ts | 62 ++++--------------- 6 files changed, 76 insertions(+), 91 deletions(-) create mode 100644 src/components/GpsDraftDetailsRefSync.tsx create mode 100644 src/libs/readOnceOnyxValue.ts diff --git a/src/components/GpsDraftDetailsRefSync.tsx b/src/components/GpsDraftDetailsRefSync.tsx new file mode 100644 index 000000000000..f24d1c68cfd8 --- /dev/null +++ b/src/components/GpsDraftDetailsRefSync.tsx @@ -0,0 +1,29 @@ +import useOnyx from '@hooks/useOnyx'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; + +import type {RefObject} from 'react'; + +import {useEffect} from 'react'; + +type GpsDraftDetailsRefSyncProps = { + gpsDraftDetailsRef: RefObject; +}; + +/** + * Keeps the latest GPS draft details in a ref without re-rendering the parent. + * Mount only while copilot leave + active GPS trip (e.g. require-2FA overlay). + */ +function GpsDraftDetailsRefSync({gpsDraftDetailsRef}: GpsDraftDetailsRefSyncProps) { + const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); + + useEffect(() => { + // eslint-disable-next-line no-param-reassign -- ref sync intentionally mutates .current for the parent overlay + gpsDraftDetailsRef.current = gpsDraftDetails; + }, [gpsDraftDetails, gpsDraftDetailsRef]); + + return null; +} + +export default GpsDraftDetailsRefSync; diff --git a/src/hooks/useSignOut.ts b/src/hooks/useSignOut.ts index bd139d44c66d..a3578724ebe4 100644 --- a/src/hooks/useSignOut.ts +++ b/src/hooks/useSignOut.ts @@ -15,11 +15,9 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isActingAsDelegateSelector} from '@src/selectors/Account'; import {isTrackingSelector} from '@src/selectors/GPSDraftDetails'; - -import type {OnyxKey, OnyxValue} from 'react-native-onyx'; +import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; import {stopLocationUpdatesAsync} from 'expo-location'; -import Onyx from 'react-native-onyx'; import useConfirmModal from './useConfirmModal'; import useLocalize from './useLocalize'; @@ -31,22 +29,10 @@ type SignOutOptions = { shouldAlwaysConfirm?: boolean; }; -/** - * Onyx has no promise-based read. GPS points are only needed after the user confirms leaving a delegated - * account during an active trip (async post-modal callback), so useOnyx cannot supply them without - * subscribing to the full GPS_DRAFT_DETAILS object and re-rendering Settings on every location tick. - */ -function readOnce(key: TKey): Promise> { - return new Promise((resolve) => { - const connection = Onyx.connectWithoutView({ - key, - callback: (value) => { - Onyx.disconnect(connection); - resolve(value); - }, - }); - }); -} +type LeaveDelegateAccountOptions = { + /** GPS draft details from a ref synced by GpsDraftDetailsRefSync; required when leaving during an active trip. */ + gpsDraftDetails?: GpsDraftDetails; +}; function useSignOut() { const {translate} = useLocalize(); @@ -156,7 +142,7 @@ function useSignOut() { signOutAndRedirectToSignIn(); }; - const leaveDelegateAccount = async () => { + const leaveDelegateAccount = async ({gpsDraftDetails}: LeaveDelegateAccountOptions = {}) => { if (isOffline) { showOfflineModal(); return; @@ -186,7 +172,6 @@ function useSignOut() { if (gpsResult.action !== ModalActions.CONFIRM) { return; } - const gpsDraftDetails = await readOnce(ONYXKEYS.GPS_DRAFT_DETAILS); await stopGpsTrip(false, getGpsPoints(gpsDraftDetails), true); } @@ -198,6 +183,7 @@ function useSignOut() { signOutImmediately, leaveDelegateAccount, isActingAsDelegate, + isTrackingGPS, }; } diff --git a/src/libs/migrations/ReplaceShouldUseStagingServerWithActiveServer.ts b/src/libs/migrations/ReplaceShouldUseStagingServerWithActiveServer.ts index 6b1f04eafa74..91e7cdbb57b4 100644 --- a/src/libs/migrations/ReplaceShouldUseStagingServerWithActiveServer.ts +++ b/src/libs/migrations/ReplaceShouldUseStagingServerWithActiveServer.ts @@ -1,38 +1,26 @@ /* eslint-disable @typescript-eslint/no-deprecated -- draining SHOULD_USE_STAGING_SERVER is this file's entire purpose */ import Log from '@libs/Log'; +import readOnceOnyxValue from '@libs/readOnceOnyxValue'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {OnyxKey, OnyxMultiSetInput, OnyxValue} from 'react-native-onyx'; +import type {OnyxMultiSetInput} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -/** Onyx has no promise-based read. */ -function readOnce(key: TKey): Promise> { - return new Promise((resolve) => { - const connection = Onyx.connectWithoutView({ - key, - callback: (value) => { - Onyx.disconnect(connection); - resolve(value); - }, - }); - }); -} - /** * A missing legacy key must write NOTHING: `resolveActiveServer` derives the environment default from * `value === undefined`, so seeding 'production' would pin every staging and adhoc build to production. */ export default async function ReplaceShouldUseStagingServerWithActiveServer(): Promise { - const shouldUseStagingServer = await readOnce(ONYXKEYS.SHOULD_USE_STAGING_SERVER); + const shouldUseStagingServer = await readOnceOnyxValue(ONYXKEYS.SHOULD_USE_STAGING_SERVER); if (shouldUseStagingServer === undefined) { return; } const updates: OnyxMultiSetInput = {[ONYXKEYS.SHOULD_USE_STAGING_SERVER]: null}; - if ((await readOnce(ONYXKEYS.ACTIVE_SERVER)) === undefined) { + if ((await readOnceOnyxValue(ONYXKEYS.ACTIVE_SERVER)) === undefined) { updates[ONYXKEYS.ACTIVE_SERVER] = shouldUseStagingServer ? CONST.SERVER.STAGING : CONST.SERVER.PRODUCTION; } diff --git a/src/libs/readOnceOnyxValue.ts b/src/libs/readOnceOnyxValue.ts new file mode 100644 index 000000000000..794048e6d59f --- /dev/null +++ b/src/libs/readOnceOnyxValue.ts @@ -0,0 +1,18 @@ +import type {OnyxKey, OnyxValue} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +/** Onyx has no promise-based read. */ +function readOnceOnyxValue(key: TKey): Promise> { + return new Promise((resolve) => { + const connection = Onyx.connectWithoutView({ + key, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); +} + +export default readOnceOnyxValue; diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx index 48c548a8e0a0..fd6759c8ad05 100644 --- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx +++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx @@ -1,5 +1,6 @@ import Button from '@components/Button'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; +import GpsDraftDetailsRefSync from '@components/GpsDraftDetailsRefSync'; import Icon from '@components/Icon'; import Text from '@components/Text'; @@ -24,11 +25,12 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {emailSelector} from '@src/selectors/Session'; import type {Policy} from '@src/types/onyx'; +import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; import type {OnyxCollection} from 'react-native-onyx'; import {useNavigation} from '@react-navigation/core'; -import React, {useCallback, useEffect} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; import {StyleSheet, View} from 'react-native'; /** @@ -59,7 +61,8 @@ function RequireTwoFactorAuthenticationOverlay() { const styles = useThemeStyles(); const {translate} = useLocalize(); const {getTwoFactorAuthRoute} = useTwoFactorAuthRoute(); - const {signOut, leaveDelegateAccount, isActingAsDelegate} = useSignOut(); + const {signOut, leaveDelegateAccount, isActingAsDelegate, isTrackingGPS} = useSignOut(); + const gpsDraftDetailsRef = useRef(undefined); const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); @@ -103,6 +106,7 @@ function RequireTwoFactorAuthenticationOverlay() { return ( + {isActingAsDelegate && isTrackingGPS && } diff --git a/tests/unit/hooks/useSignOutTest.ts b/tests/unit/hooks/useSignOutTest.ts index bf7d24d7e36a..1d10695234b2 100644 --- a/tests/unit/hooks/useSignOutTest.ts +++ b/tests/unit/hooks/useSignOutTest.ts @@ -16,22 +16,11 @@ import {isActingAsDelegateSelector} from '@src/selectors/Account'; import {isTrackingSelector} from '@src/selectors/GPSDraftDetails'; import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; -import type {ConnectOptions, OnyxKey} from 'react-native-onyx'; - -import Onyx from 'react-native-onyx'; - const mockShowConfirmModal = jest.fn(); const mockTranslate = jest.fn((key: string) => key); let mockIsOffline = false; -const mockOnyxConnection = {id: 'mock-connection', callbackID: 'mock-callback-id'}; - -function mockConnectWithoutViewImplementation(connectOptions: ConnectOptions) { - connectOptions.callback?.(undefined, connectOptions.key); - return mockOnyxConnection; -} - jest.mock('@hooks/useLocalize', () => ({ __esModule: true, default: () => ({translate: mockTranslate}), @@ -52,19 +41,6 @@ jest.mock('@hooks/useOnyx', () => ({ default: jest.fn(), })); -jest.mock('react-native-onyx', () => { - const actual = jest.requireActual<{default: typeof Onyx}>('react-native-onyx'); - return { - ...actual, - __esModule: true, - default: { - ...actual.default, - connectWithoutView: jest.fn(mockConnectWithoutViewImplementation), - disconnect: jest.fn(), - }, - }; -}); - jest.mock('@userActions/Session', () => ({ signOutAndRedirectToSignIn: jest.fn(), })); @@ -97,8 +73,6 @@ jest.mock('@pages/iou/request/step/IOURequestStepDistanceGPS/const', () => ({ })); const mockUseOnyx = jest.mocked(useOnyx); -const mockConnectWithoutView = jest.mocked(Onyx.connectWithoutView); -const mockOnyxDisconnect = jest.mocked(Onyx.disconnect); const mockSignOutAndRedirectToSignIn = jest.mocked(signOutAndRedirectToSignIn); const mockDelegateDisconnect = jest.mocked(disconnect); const mockGetSaveablePendingReceiptRequests = jest.mocked(getSaveablePendingReceiptRequests); @@ -114,6 +88,14 @@ type MockOnyxState = { const loadedOnyxMetadata = {status: 'loaded'} as const; +const mockGpsDraftDetails: GpsDraftDetails = { + gpsPoints: [[{lat: 1, long: 2}]], + distanceInMeters: 0, + isTracking: true, + reportID: '1', + unit: 'mi', +}; + function mockOnyxState({isTrackingGPS = false, isActingAsDelegate = false, stashedCredentials = {}, stashedSession = {email: 'copilot@expensify.com'}}: MockOnyxState = {}) { mockUseOnyx.mockImplementation((key, options) => { if (key === ONYXKEYS.GPS_DRAFT_DETAILS) { @@ -144,7 +126,6 @@ describe('useSignOut', () => { jest.clearAllMocks(); mockIsOffline = false; mockOnyxState(); - mockConnectWithoutView.mockImplementation(mockConnectWithoutViewImplementation); mockShowConfirmModal.mockResolvedValue({action: ModalActions.CONFIRM}); }); @@ -242,36 +223,15 @@ describe('useSignOut', () => { it('should show GPS switch-account warning before disconnecting from a delegated account', async () => { // Given a copilot is leaving while a GPS trip is in progress mockOnyxState({isActingAsDelegate: true, isTrackingGPS: true}); - mockConnectWithoutView.mockImplementation((connectOptions) => { - if (connectOptions.key === ONYXKEYS.GPS_DRAFT_DETAILS) { - const gpsDraftDetails: GpsDraftDetails = { - gpsPoints: [[{lat: 1, long: 2}]], - distanceInMeters: 0, - isTracking: true, - reportID: '1', - unit: 'mi', - }; - connectOptions.callback?.(gpsDraftDetails, connectOptions.key); - } else { - connectOptions.callback?.(undefined, connectOptions.key); - } - return mockOnyxConnection; - }); const {result} = renderHook(() => useSignOut()); - // When they confirm leaving the delegated account + // When they confirm leaving the delegated account with GPS draft details from the overlay ref await act(async () => { - await result.current.leaveDelegateAccount(); + await result.current.leaveDelegateAccount({gpsDraftDetails: mockGpsDraftDetails}); }); - // Then GPS points are read at call time and the trip is stopped before disconnect - expect(mockConnectWithoutView).toHaveBeenCalledWith( - expect.objectContaining({ - key: ONYXKEYS.GPS_DRAFT_DETAILS, - }), - ); - expect(mockOnyxDisconnect).toHaveBeenCalled(); + // Then the GPS switch-account warning appears and the trip is stopped before disconnect expect(mockShowConfirmModal).toHaveBeenCalledWith( expect.objectContaining({ title: 'gps.switchAccountWarningTripInProgress.title', From 750240283676c01768d5d1a96c6d2e6cfa410198 Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Thu, 24 Sep 2026 17:02:22 +0530 Subject: [PATCH 15/18] Block a second sign-out while the first confirm is open, and read GPS points after that confirm. --- src/hooks/useSignOut.ts | 41 ++++++++++++++++--- .../RequireTwoFactorAuthenticationOverlay.tsx | 17 +++++++- tests/unit/hooks/useSignOutTest.ts | 40 ++++++++++++++++-- 3 files changed, 87 insertions(+), 11 deletions(-) diff --git a/src/hooks/useSignOut.ts b/src/hooks/useSignOut.ts index a3578724ebe4..98e4ffc8ab48 100644 --- a/src/hooks/useSignOut.ts +++ b/src/hooks/useSignOut.ts @@ -17,6 +17,8 @@ import {isActingAsDelegateSelector} from '@src/selectors/Account'; import {isTrackingSelector} from '@src/selectors/GPSDraftDetails'; import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; +import type {RefObject} from 'react'; + import {stopLocationUpdatesAsync} from 'expo-location'; import useConfirmModal from './useConfirmModal'; @@ -30,10 +32,13 @@ type SignOutOptions = { }; type LeaveDelegateAccountOptions = { - /** GPS draft details from a ref synced by GpsDraftDetailsRefSync; required when leaving during an active trip. */ - gpsDraftDetails?: GpsDraftDetails; + /** Ref kept current by GpsDraftDetailsRefSync. Read only after the user confirms, so points stay fresh while the modal is open. */ + gpsDraftDetailsRef?: RefObject; }; +/** Stops a second Sign out or Leave press from stacking another confirm modal. */ +let isSignOutFlowInFlight = false; + function useSignOut() { const {translate} = useLocalize(); const {isOffline} = useNetwork(); @@ -60,7 +65,7 @@ function useSignOut() { signOutAndRedirectToSignIn(); }; - const signOut = async ({shouldAlwaysConfirm = false}: SignOutOptions = {}) => { + const runSignOut = async ({shouldAlwaysConfirm = false}: SignOutOptions = {}) => { const saveableReceipts = getSaveablePendingReceiptRequests(); const shouldWarnBeforeSignOut = isOffline || isTrackingGPS; const isOfflineReceiptsCase = isOffline && !isTrackingGPS && saveableReceipts.length > 0; @@ -142,7 +147,20 @@ function useSignOut() { signOutAndRedirectToSignIn(); }; - const leaveDelegateAccount = async ({gpsDraftDetails}: LeaveDelegateAccountOptions = {}) => { + const signOut = async ({shouldAlwaysConfirm = false}: SignOutOptions = {}) => { + if (isSignOutFlowInFlight) { + return; + } + isSignOutFlowInFlight = true; + + try { + await runSignOut({shouldAlwaysConfirm}); + } finally { + isSignOutFlowInFlight = false; + } + }; + + const runLeaveDelegateAccount = async (gpsDraftDetailsRef?: RefObject) => { if (isOffline) { showOfflineModal(); return; @@ -172,12 +190,25 @@ function useSignOut() { if (gpsResult.action !== ModalActions.CONFIRM) { return; } - await stopGpsTrip(false, getGpsPoints(gpsDraftDetails), true); + await stopGpsTrip(false, getGpsPoints(gpsDraftDetailsRef?.current), true); } leaveAction(); }; + const leaveDelegateAccount = async ({gpsDraftDetailsRef}: LeaveDelegateAccountOptions = {}) => { + if (isSignOutFlowInFlight) { + return; + } + isSignOutFlowInFlight = true; + + try { + await runLeaveDelegateAccount(gpsDraftDetailsRef); + } finally { + isSignOutFlowInFlight = false; + } + }; + return { signOut, signOutImmediately, diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx index fd6759c8ad05..b65208861931 100644 --- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx +++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx @@ -30,7 +30,7 @@ import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; import type {OnyxCollection} from 'react-native-onyx'; import {useNavigation} from '@react-navigation/core'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; /** @@ -63,6 +63,7 @@ function RequireTwoFactorAuthenticationOverlay() { const {getTwoFactorAuthRoute} = useTwoFactorAuthRoute(); const {signOut, leaveDelegateAccount, isActingAsDelegate, isTrackingGPS} = useSignOut(); const gpsDraftDetailsRef = useRef(undefined); + const [isEscapeInFlight, setIsEscapeInFlight] = useState(false); const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); @@ -95,6 +96,17 @@ function RequireTwoFactorAuthenticationOverlay() { snapshotOnboardingResumePathIfNeeded(); }, [shouldShowRequire2FAPage, isIn2FASetupFlow, snapshotOnboardingResumePathIfNeeded]); + const onEscapePress = () => { + if (isEscapeInFlight) { + return; + } + setIsEscapeInFlight(true); + const escapeAction = isActingAsDelegate ? leaveDelegateAccount({gpsDraftDetailsRef}) : signOut({shouldAlwaysConfirm: true}); + escapeAction.finally(() => { + setIsEscapeInFlight(false); + }); + }; + const enableTwoFactorAuth = () => { snapshotOnboardingResumePathIfNeeded(); Navigation.navigate(getTwoFactorAuthRoute(ROUTES.SETTINGS_SECURITY, {forceSetup: true})); @@ -130,7 +142,8 @@ function RequireTwoFactorAuthenticationOverlay() { diff --git a/tests/unit/hooks/useSignOutTest.ts b/tests/unit/hooks/useSignOutTest.ts index 1d10695234b2..7e3c88f39ebf 100644 --- a/tests/unit/hooks/useSignOutTest.ts +++ b/tests/unit/hooks/useSignOutTest.ts @@ -6,7 +6,7 @@ import useOnyx from '@hooks/useOnyx'; import useSignOut from '@hooks/useSignOut'; import {disconnect} from '@libs/actions/Delegate'; -import {stopGpsTrip} from '@libs/GPSDraftDetailsUtils'; +import {getGpsPoints, stopGpsTrip} from '@libs/GPSDraftDetailsUtils'; import {getSaveablePendingReceiptRequests, saveReceiptsToGallery} from '@libs/savePendingReceiptsToGallery'; import {signOutAndRedirectToSignIn} from '@userActions/Session'; @@ -78,6 +78,7 @@ const mockDelegateDisconnect = jest.mocked(disconnect); const mockGetSaveablePendingReceiptRequests = jest.mocked(getSaveablePendingReceiptRequests); const mockSaveReceiptsToGallery = jest.mocked(saveReceiptsToGallery); const mockStopGpsTrip = jest.mocked(stopGpsTrip); +const mockGetGpsPoints = jest.mocked(getGpsPoints); type MockOnyxState = { isTrackingGPS?: boolean; @@ -162,6 +163,36 @@ describe('useSignOut', () => { expect(mockSignOutAndRedirectToSignIn).toHaveBeenCalledTimes(1); }); + it('should ignore a second sign-out while the first confirm is still open', async () => { + // Given the first sign-out confirm is still waiting for the user + let resolveModal: (value: {action: string}) => void = () => {}; + mockShowConfirmModal.mockImplementation( + () => + new Promise((resolve) => { + resolveModal = resolve; + }), + ); + const {result} = renderHook(() => useSignOut()); + + let firstSignOut: Promise | undefined; + act(() => { + firstSignOut = result.current.signOut({shouldAlwaysConfirm: true}); + }); + + // When they press Sign out again before the first modal closes + await act(async () => { + await result.current.signOut({shouldAlwaysConfirm: true}); + }); + + // Then only one confirm modal is opened + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveModal({action: ModalActions.CLOSE}); + await firstSignOut; + }); + }); + it('should show merged save-and-sign-out confirm when offline with pending receipts', async () => { // Given the user is offline and has pending receipts to save mockIsOffline = true; @@ -226,12 +257,13 @@ describe('useSignOut', () => { const {result} = renderHook(() => useSignOut()); - // When they confirm leaving the delegated account with GPS draft details from the overlay ref + // When they confirm leaving after the ref has the latest GPS draft await act(async () => { - await result.current.leaveDelegateAccount({gpsDraftDetails: mockGpsDraftDetails}); + await result.current.leaveDelegateAccount({gpsDraftDetailsRef: {current: mockGpsDraftDetails}}); }); - // Then the GPS switch-account warning appears and the trip is stopped before disconnect + // Then points are read from the ref after confirm, and the trip is stopped before disconnect + expect(mockGetGpsPoints).toHaveBeenCalledWith(mockGpsDraftDetails); expect(mockShowConfirmModal).toHaveBeenCalledWith( expect.objectContaining({ title: 'gps.switchAccountWarningTripInProgress.title', From b074700f39f1ea61ff818e4a8d081c8732d4efde Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Thu, 24 Sep 2026 17:08:42 +0530 Subject: [PATCH 16/18] Clear the sign-out in-flight flag with promise finally so React Compiler can compile the hook. --- src/hooks/useSignOut.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/hooks/useSignOut.ts b/src/hooks/useSignOut.ts index 98e4ffc8ab48..c406b7dd5fbd 100644 --- a/src/hooks/useSignOut.ts +++ b/src/hooks/useSignOut.ts @@ -153,11 +153,9 @@ function useSignOut() { } isSignOutFlowInFlight = true; - try { - await runSignOut({shouldAlwaysConfirm}); - } finally { + return runSignOut({shouldAlwaysConfirm}).finally(() => { isSignOutFlowInFlight = false; - } + }); }; const runLeaveDelegateAccount = async (gpsDraftDetailsRef?: RefObject) => { @@ -202,11 +200,9 @@ function useSignOut() { } isSignOutFlowInFlight = true; - try { - await runLeaveDelegateAccount(gpsDraftDetailsRef); - } finally { + return runLeaveDelegateAccount(gpsDraftDetailsRef).finally(() => { isSignOutFlowInFlight = false; - } + }); }; return { From fb1dfaa3a1a01a50a6ea81fea1a2fa5c4ea5c82e Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Mon, 28 Sep 2026 14:57:24 +0530 Subject: [PATCH 17/18] Match the 2FA overlay sign-out confirm to Settings, and keep the focus trap to one child so it does not crash on mount. --- .../RequireTwoFactorAuthenticationOverlay.tsx | 80 ++++++++++--------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx index b65208861931..2b7f7061b380 100644 --- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx +++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx @@ -101,7 +101,7 @@ function RequireTwoFactorAuthenticationOverlay() { return; } setIsEscapeInFlight(true); - const escapeAction = isActingAsDelegate ? leaveDelegateAccount({gpsDraftDetailsRef}) : signOut({shouldAlwaysConfirm: true}); + const escapeAction = isActingAsDelegate ? leaveDelegateAccount({gpsDraftDetailsRef}) : signOut(); escapeAction.finally(() => { setIsEscapeInFlight(false); }); @@ -117,50 +117,52 @@ function RequireTwoFactorAuthenticationOverlay() { } return ( - + <> {isActingAsDelegate && isTrackingGPS && } - - - - - - - - - {translate('twoFactorAuth.twoFactorAuthIsRequiredForAdminsHeader')} - - {translate(is2FARequiredBecauseOfXero ? 'twoFactorAuth.twoFactorAuthIsRequiredXero' : 'twoFactorAuth.twoFactorAuthIsRequiredCompany')} - + + + + + + - - - + + + {translate('twoFactorAuth.twoFactorAuthIsRequiredForAdminsHeader')} + + {translate(is2FARequiredBecauseOfXero ? 'twoFactorAuth.twoFactorAuthIsRequiredXero' : 'twoFactorAuth.twoFactorAuthIsRequiredCompany')} + + + + + + - - + + ); } From b00bfa5ebedb58ec0726eee8db5b9b0c4e5b68c5 Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Mon, 28 Sep 2026 23:13:43 +0530 Subject: [PATCH 18/18] Keep sign-out in flight until redirect so the overlay button stays loading. --- src/hooks/useSignOut.ts | 5 ++-- tests/unit/hooks/useSignOutTest.ts | 43 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/hooks/useSignOut.ts b/src/hooks/useSignOut.ts index c406b7dd5fbd..4a3b8e4cef8b 100644 --- a/src/hooks/useSignOut.ts +++ b/src/hooks/useSignOut.ts @@ -71,8 +71,7 @@ function useSignOut() { const isOfflineReceiptsCase = isOffline && !isTrackingGPS && saveableReceipts.length > 0; if (!shouldAlwaysConfirm && !shouldWarnBeforeSignOut && saveableReceipts.length === 0) { - signOutAndRedirectToSignIn(); - return; + return signOutAndRedirectToSignIn(); } const confirmModalTitle = isTrackingGPS ? translate('gps.signOutWarningTripInProgress.title') : translate('common.areYouSure'); @@ -144,7 +143,7 @@ function useSignOut() { stopLocationUpdatesAsync(BACKGROUND_LOCATION_TRACKING_TASK_NAME).catch((error) => console.error('[GPS distance request] Failed to stop location tracking', error)); } - signOutAndRedirectToSignIn(); + return signOutAndRedirectToSignIn(); }; const signOut = async ({shouldAlwaysConfirm = false}: SignOutOptions = {}) => { diff --git a/tests/unit/hooks/useSignOutTest.ts b/tests/unit/hooks/useSignOutTest.ts index 7e3c88f39ebf..7584e40ac539 100644 --- a/tests/unit/hooks/useSignOutTest.ts +++ b/tests/unit/hooks/useSignOutTest.ts @@ -163,6 +163,49 @@ describe('useSignOut', () => { expect(mockSignOutAndRedirectToSignIn).toHaveBeenCalledTimes(1); }); + it('should stay in flight until sign-out finishes and ignore a second press', async () => { + // Given sign-out has started but the redirect has not finished + let resolveSignOut: () => void = () => {}; + mockSignOutAndRedirectToSignIn.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSignOut = () => { + resolve(undefined); + }; + }), + ); + const {result} = renderHook(() => useSignOut()); + + let firstSignOut: Promise | undefined; + act(() => { + firstSignOut = result.current.signOut(); + }); + + // When they press Sign out again before the first one finishes + await act(async () => { + await result.current.signOut(); + }); + + // Then the second press is ignored and the first call is still waiting + expect(mockSignOutAndRedirectToSignIn).toHaveBeenCalledTimes(1); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + + let didSettle = false; + const trackedSignOut = firstSignOut?.finally(() => { + didSettle = true; + }); + await act(async () => { + await Promise.resolve(); + }); + expect(didSettle).toBe(false); + + await act(async () => { + resolveSignOut(); + await trackedSignOut; + }); + expect(didSettle).toBe(true); + }); + it('should ignore a second sign-out while the first confirm is still open', async () => { // Given the first sign-out confirm is still waiting for the user let resolveModal: (value: {action: string}) => void = () => {};