Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
230816d
Add Sign out escape hatch to required 2FA overlay
neerajbachani Sep 1, 2026
58740e2
Use shared interactive sign-out in require-2FA overlay
neerajbachani Sep 1, 2026
89eefdf
Skip duplicate offline sign-out confirm from overlay
neerajbachani Sep 1, 2026
e5fd23b
Merge branch 'main' into 91985-require-2fa-overlay-sign-out
neerajbachani Sep 3, 2026
3a2fcbb
Apply design feedback to require-2FA overlay buttons
neerajbachani Sep 7, 2026
bd1436c
Revert manual non-English translation updates
neerajbachani Sep 7, 2026
0e05019
Shorten require-2FA overlay button label to Enable
neerajbachani Sep 8, 2026
2d3a8c2
Apply Polyglot Parrot translations for require-2FA overlay strings.
neerajbachani Sep 9, 2026
b2402bc
Address PR review: useSignOut hook, copilot leave account, tests
neerajbachani Sep 18, 2026
99abf72
Apply Polyglot Parrot translations for delegate leave account strings
neerajbachani Sep 18, 2026
1a27297
Merge Expensify/main and resolve locale conflicts
neerajbachani Sep 18, 2026
88a7965
Remove stale NetSuite locale key after main merge
neerajbachani Sep 18, 2026
3e2f912
Address Melvin review: GPS read at call time, hook-owned confirms
neerajbachani Sep 19, 2026
3799fda
Fix typecheck for useSignOut tests and document readOnce
neerajbachani Sep 19, 2026
a108d37
Fix duplicate react-native-onyx import in useSignOutTest
neerajbachani Sep 19, 2026
391660b
Address tgolen review: ref sync for GPS, centralize readOnce
neerajbachani Sep 22, 2026
7ebf610
Merge Expensify/main and keep the sign-out hook imports.
neerajbachani Sep 24, 2026
7502402
Block a second sign-out while the first confirm is open, and read GPS…
neerajbachani Sep 24, 2026
b074700
Clear the sign-out in-flight flag with promise finally so React Compi…
neerajbachani Sep 24, 2026
86ca099
Merge upstream/main into the sign-out overlay branch.
neerajbachani Sep 28, 2026
fb1dfaa
Match the 2FA overlay sign-out confirm to Settings, and keep the focu…
neerajbachani Sep 28, 2026
b00bfa5
Keep sign-out in flight until redirect so the overlay button stays lo…
neerajbachani Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/components/GpsDraftDetailsRefSync.tsx
Original file line number Diff line number Diff line change
@@ -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<GpsDraftDetails | undefined>;
};

/**
* 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;
216 changes: 216 additions & 0 deletions src/hooks/useSignOut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
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 type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails';

import type {RefObject} from 'react';

import {stopLocationUpdatesAsync} from 'expo-location';

import useConfirmModal from './useConfirmModal';
import useLocalize from './useLocalize';
import useNetwork from './useNetwork';
import useOnyx from './useOnyx';

type SignOutOptions = {
/** When true, always show the generic sign-out confirm (e.g. from the require-2FA overlay). GPS and receipt prompts still run when applicable. */
shouldAlwaysConfirm?: boolean;
};

type LeaveDelegateAccountOptions = {
/** Ref kept current by GpsDraftDetailsRefSync. Read only after the user confirms, so points stay fresh while the modal is open. */
gpsDraftDetailsRef?: RefObject<GpsDraftDetails | undefined>;
};

/** Stops a second Sign out or Leave press from stacking another confirm modal. */
let isSignOutFlowInFlight = false;

function useSignOut() {
const {translate} = useLocalize();
const {isOffline} = useNetwork();
const {showConfirmModal} = useConfirmModal();
const [isTrackingGPS = false] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS, {
selector: isTrackingSelector,
});
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 = () => {
showConfirmModal({
title: translate('common.youAppearToBeOffline'),
prompt: translate('common.offlinePrompt'),
confirmText: translate('common.buttonConfirm'),
shouldShowCancelButton: false,
});
};

const signOutImmediately = () => {
signOutAndRedirectToSignIn();
};

const runSignOut = async ({shouldAlwaysConfirm = false}: SignOutOptions = {}) => {
const saveableReceipts = getSaveablePendingReceiptRequests();
const shouldWarnBeforeSignOut = isOffline || isTrackingGPS;
const isOfflineReceiptsCase = isOffline && !isTrackingGPS && saveableReceipts.length > 0;

if (!shouldAlwaysConfirm && !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 || shouldAlwaysConfirm;
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));
}

return signOutAndRedirectToSignIn();
};

const signOut = async ({shouldAlwaysConfirm = false}: SignOutOptions = {}) => {
if (isSignOutFlowInFlight) {
return;
}
isSignOutFlowInFlight = true;

return runSignOut({shouldAlwaysConfirm}).finally(() => {
isSignOutFlowInFlight = false;
});
};

const runLeaveDelegateAccount = async (gpsDraftDetailsRef?: RefObject<GpsDraftDetails | undefined>) => {
if (isOffline) {
showOfflineModal();
return;
}

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 gpsResult = await showConfirmModal({
title: translate('gps.switchAccountWarningTripInProgress.title'),
prompt: translate('gps.switchAccountWarningTripInProgress.prompt'),
confirmText: translate('gps.switchAccountWarningTripInProgress.confirm'),
cancelText: translate('common.cancel'),
});
if (gpsResult.action !== ModalActions.CONFIRM) {
return;
}
await stopGpsTrip(false, getGpsPoints(gpsDraftDetailsRef?.current), true);
}

leaveAction();
};

const leaveDelegateAccount = async ({gpsDraftDetailsRef}: LeaveDelegateAccountOptions = {}) => {
if (isSignOutFlowInFlight) {
return;
}
isSignOutFlowInFlight = true;

return runLeaveDelegateAccount(gpsDraftDetailsRef).finally(() => {
isSignOutFlowInFlight = false;
});
};

return {
signOut,
signOutImmediately,
leaveDelegateAccount,
isActingAsDelegate,
isTrackingGPS,
};
}

export default useSignOut;
5 changes: 4 additions & 1 deletion src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2554,7 +2554,7 @@ const translations: TranslationDeepObject<typeof en> = {
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 erfordert 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',
Expand All @@ -2566,6 +2566,7 @@ const translations: TranslationDeepObject<typeof en> = {
verifyNewDeviceDescription: 'Scannen Sie den QR-Code mit Ihrem neuen Gerät und geben Sie dann den Code ein, um die Einrichtung abzuschließen.',
downloadCodes: 'Codes herunterladen',
copyCodes: 'Codes kopieren',
enable2FA: 'Aktivieren',
},
recoveryCodeForm: {
error: {
Expand Down Expand Up @@ -11106,6 +11107,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc
`Sind Sie sicher, dass Sie Ihren Copilot-Zugriff auf das Expensify-Konto von ${delegatorName} entfernen möchten? Diese Aktion kann nicht rückgängig gemacht werden.`,
removeCopilotAccessConfirm: 'Zugriff entfernen',
copilotAccess: 'Copilot-Zugriff',
leaveAccount: 'Konto verlassen',
leaveAccountConfirmationText: 'Sie kehren zu Ihrem eigenen Konto zurück. Sie werden nicht vollständig abgemeldet.',
},
debug: {
debug: 'Debug',
Expand Down
5 changes: 4 additions & 1 deletion src/languages/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2608,7 +2608,7 @@ const translations: TranslationDeepObject<typeof en> = {
twoFactorAuthIsRequiredForAdminsHeader: 'Απαιτείται έλεγχος ταυτότητας δύο παραγόντων',
twoFactorAuthIsRequiredForAdminsTitle: 'Παρακαλούμε ενεργοποιήστε τον έλεγχο ταυτότητας δύο παραγόντων',
twoFactorAuthIsRequiredXero: 'Η σύνδεσή σας με το λογιστικό σύστημα Xero απαιτεί έλεγχο ταυτότητας δύο παραγόντων.',
twoFactorAuthIsRequiredCompany: 'Η εταιρεία σας απαιτεί έλεγχο ταυτότητας δύο παραγόντων.',
twoFactorAuthIsRequiredCompany: 'Η εταιρεία σας απαιτεί έλεγχο ταυτότητας δύο παραγόντων (2FA).',
twoFactorAuthCannotDisable: 'Δεν είναι δυνατή η απενεργοποίηση του 2FA',
twoFactorAuthRequired: 'Η επαλήθευση δύο παραγόντων (2FA) είναι υποχρεωτική για τη σύνδεσή σας με το Xero και δεν μπορεί να απενεργοποιηθεί.',
replaceDevice: 'Αντικατάσταση συσκευής',
Expand All @@ -2618,6 +2618,7 @@ const translations: TranslationDeepObject<typeof en> = {
verifyOldDeviceDescriptionWithRecovery: 'Εισαγάγετε έναν έγκυρο κωδικό ανάκτησης για να επιβεβαιώσετε ότι έχετε πρόσβαση στον λογαριασμό σας.',
verifyNewDeviceTitle: 'Ρύθμιση νέας συσκευής',
verifyNewDeviceDescription: 'Σαρώστε τον κωδικό QR με τη νέα σας συσκευή και κατόπιν εισαγάγετε τον κωδικό για να ολοκληρώσετε τη ρύθμιση.',
enable2FA: 'Ενεργοποίηση',
},
recoveryCodeForm: {
error: {
Expand Down Expand Up @@ -11323,6 +11324,8 @@ ${reportName}`,
notAllowedMessage: (accountOwnerEmail: string) =>
`Ως <a href="${CONST.DELEGATE_ROLE_HELP_DOT_ARTICLE_LINK}">συνοδοιπόρος</a> για τον/την ${accountOwnerEmail}, δεν έχετε δικαίωμα να εκτελέσετε αυτήν την ενέργεια. Συγγνώμη!`,
copilotAccess: 'Πρόσβαση στο Copilot',
leaveAccount: 'Αποχώρηση από τον λογαριασμό',
leaveAccountConfirmationText: 'Θα επιστρέψετε στον δικό σας λογαριασμό. Δεν θα αποσυνδεθείτε πλήρως.',
},
debug: {
debug: 'Εντοπισμός σφαλμάτων',
Expand Down
5 changes: 4 additions & 1 deletion src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2660,12 +2660,13 @@ const translations = {
copyCodes: 'Copy codes',
disable: 'Disable',
enableTwoFactorAuth: 'Enable two-factor authentication',
enable2FA: 'Enable',
pleaseEnableTwoFactorAuth: 'Please enable two-factor authentication.',
twoFactorAuthIsRequiredDescription: 'For security purposes, Xero requires two-factor authentication to connect the integration.',
twoFactorAuthIsRequiredForAdminsHeader: 'Two-factor authentication required',
twoFactorAuthIsRequiredForAdminsTitle: 'Please enable two-factor authentication',
twoFactorAuthIsRequiredXero: 'Your Xero accounting connection requires two-factor authentication.',
twoFactorAuthIsRequiredCompany: 'Your company requires two-factor authentication.',
twoFactorAuthIsRequiredCompany: 'Your company requires two-factor authentication (2FA).',
twoFactorAuthCannotDisable: 'Cannot disable 2FA',
twoFactorAuthRequired: 'Two-factor authentication (2FA) is required for your Xero connection and cannot be disabled.',
replaceDevice: 'Replace device',
Expand Down Expand Up @@ -11246,6 +11247,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',
Expand Down
5 changes: 4 additions & 1 deletion src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2470,7 +2470,7 @@ const translations: TranslationDeepObject<typeof en> = {
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',
Expand All @@ -2482,6 +2482,7 @@ const translations: TranslationDeepObject<typeof en> = {
verifyNewDeviceDescription: 'Escanea el código QR con tu nuevo dispositivo y luego introduce el código para completar la configuración.',
downloadCodes: 'Descargar códigos',
copyCodes: 'Copiar códigos',
enable2FA: 'Habilitar',
},
recoveryCodeForm: {
error: {
Expand Down Expand Up @@ -11298,6 +11299,8 @@ ${reportName}`,
notAllowedMessage: (accountOwnerEmail) =>
`Como <a href="${CONST.DELEGATE_ROLE_HELP_DOT_ARTICLE_LINK}">copiloto</a> de ${accountOwnerEmail}, no tienes permiso para realizar esta acción. ¡Lo siento!`,
copilotAccess: 'Acceso a Copilot',
leaveAccount: 'Abandonar cuenta',
leaveAccountConfirmationText: 'Volverás a tu propia cuenta. No cerrarás sesión por completo.',
},
debug: {
debug: 'Depuración',
Expand Down
5 changes: 4 additions & 1 deletion src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2560,7 +2560,7 @@ const translations: TranslationDeepObject<typeof en> = {
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',
Expand All @@ -2572,6 +2572,7 @@ const translations: TranslationDeepObject<typeof en> = {
verifyNewDeviceDescription: 'Scannez le code QR avec votre nouvel appareil, puis saisissez le code pour terminer la configuration.',
downloadCodes: 'Télécharger les codes',
copyCodes: 'Copier les codes',
enable2FA: 'Activer',
},
recoveryCodeForm: {
error: {
Expand Down Expand Up @@ -11128,6 +11129,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e
`Êtes-vous sûr de vouloir supprimer votre accès copilote au compte Expensify de ${delegatorName} ? Cette action est irréversible.`,
removeCopilotAccessConfirm: "Supprimer l'accès",
copilotAccess: 'Accès Copilot',
leaveAccount: 'Quitter le compte',
leaveAccountConfirmationText: 'Vous reviendrez à votre propre compte. Vous ne serez pas complètement déconnecté.',
},
debug: {
debug: 'Déboguer',
Expand Down
Loading
Loading