diff --git a/src/CONST/SENTRY_LABEL.ts b/src/CONST/SENTRY_LABEL.ts
index 3964b04a8156..908b5118bb8d 100644
--- a/src/CONST/SENTRY_LABEL.ts
+++ b/src/CONST/SENTRY_LABEL.ts
@@ -2777,6 +2777,7 @@ const SENTRY_LABEL = {
ADD_COPILOT_ACCESS_LEVEL: 'SettingsSecurity-AddCopilotAccessLevel',
ADD_COPILOT_CONFIRM: 'SettingsSecurity-AddCopilotConfirm',
DEVICE_REVOKE: 'SettingsSecurity-DeviceRevoke',
+ SIGN_OUT_ALL_DEVICES: 'SettingsSecurity-SignOutAllDevices',
LOCK_ACCOUNT_SUBMIT: 'SettingsSecurity-LockAccountSubmit',
MERGE_ACCOUNTS_NEXT: 'SettingsSecurity-MergeAccountsNext',
MERGE_RESULT_CONFIRM: 'SettingsSecurity-MergeResultConfirm',
diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx
index 1be74928996e..d1242f1c2b9e 100644
--- a/src/components/ConfirmContent.tsx
+++ b/src/components/ConfirmContent.tsx
@@ -239,6 +239,7 @@ function ConfirmContent({
onPress={onConfirm}
size={CONST.BUTTON_SIZE.LARGE}
accessibilityLabel={confirmText || translate('common.yes')}
+ accessibilityState={{busy: !!isConfirmLoading}}
isDisabled={(isOffline && shouldDisableConfirmButtonWhenOffline) || isConfirmDisabled}
isLoading={isConfirmLoading}
sentryLabel={CONST.SENTRY_LABEL.CONFIRM_CONTENT.CONFIRM_BUTTON}
@@ -272,6 +273,7 @@ function ConfirmContent({
variant={confirmButtonVariant}
style={[styles.flex1]}
onPress={onConfirm}
+ accessibilityState={{busy: !!isConfirmLoading}}
isDisabled={(isOffline && shouldDisableConfirmButtonWhenOffline) || isConfirmDisabled}
isLoading={isConfirmLoading}
sentryLabel={CONST.SENTRY_LABEL.CONFIRM_CONTENT.CONFIRM_BUTTON}
diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/index.native.tsx b/src/components/GrowlNotification/GrowlNotificationContainer/index.native.tsx
index c135dfc071d0..5522ea33f5aa 100644
--- a/src/components/GrowlNotification/GrowlNotificationContainer/index.native.tsx
+++ b/src/components/GrowlNotification/GrowlNotificationContainer/index.native.tsx
@@ -7,7 +7,7 @@ import Animated, {useAnimatedStyle} from 'react-native-reanimated';
import type GrowlNotificationContainerProps from './types';
-function GrowlNotificationContainer({children, progress, inactiveY}: GrowlNotificationContainerProps) {
+function GrowlNotificationContainer({children, progress, inactiveY, useBottomPosition}: GrowlNotificationContainerProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const insets = useSafeAreaInsets();
@@ -15,7 +15,16 @@ function GrowlNotificationContainer({children, progress, inactiveY}: GrowlNotifi
// omitted from the deps array — only the plain JS value `inactiveY` needs to invalidate the style.
const animatedStyles = useAnimatedStyle(() => ({transform: [{translateY: inactiveY * (1 - progress.get())}]}), [inactiveY]);
- return {children};
+ const containerStyle = useBottomPosition ? [styles.growlNotificationContainerBottomRight, styles.pAbsolute] : styles.growlNotificationContainer;
+
+ return (
+
+ {children}
+
+ );
}
export default GrowlNotificationContainer;
diff --git a/src/components/GrowlNotification/GrowlNotificationContent.tsx b/src/components/GrowlNotification/GrowlNotificationContent.tsx
index 0fb727cb204e..2be3f1fec951 100644
--- a/src/components/GrowlNotification/GrowlNotificationContent.tsx
+++ b/src/components/GrowlNotification/GrowlNotificationContent.tsx
@@ -137,7 +137,10 @@ function GrowlNotificationContent({bodyText, type, duration, action, position, n
return (
-
+
boolean = () => false;
-
-export default shouldUseExplicitBottomPosition;
diff --git a/src/components/Modal/Global/ConfirmModalWrapper.tsx b/src/components/Modal/Global/ConfirmModalWrapper.tsx
index dd43453805dd..cae0f37c2598 100644
--- a/src/components/Modal/Global/ConfirmModalWrapper.tsx
+++ b/src/components/Modal/Global/ConfirmModalWrapper.tsx
@@ -1,13 +1,19 @@
import type {ConfirmModalProps} from '@components/ConfirmModal';
import ConfirmModal from '@components/ConfirmModal';
+import FormHelpMessage from '@components/FormHelpMessage';
+import Text from '@components/Text';
import useActiveElementRole from '@hooks/useActiveElementRole';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
+import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import Log from '@libs/Log';
import CONST from '@src/CONST';
-import React, {useState} from 'react';
+import React, {useRef, useState} from 'react';
import type {ModalProps} from './ModalContext';
@@ -17,6 +23,9 @@ type ConfirmModalWrapperProps = ModalProps &
Omit & {
/** Whether a backdrop tap should also hide the modal after running onBackdropPress. Leave it off when onBackdropPress is meant to keep the modal open */
shouldHideOnBackdropPress?: boolean;
+
+ /** Resolves without a value to close, or with a localized error to keep the modal open for retry. */
+ onConfirm?: () => Promise;
};
// This wrapper bridges the ConfirmModal API with the global modal system, providing handlers for the onConfirm and onCancel callbacks to ConfirmModal.
@@ -24,14 +33,50 @@ type ConfirmModalWrapperProps = ModalProps &
// - handle closeModal inside ConfirmModal
// - remove ConfirmModalWrapper
-function ConfirmModalWrapper({closeModal, onModalHide, resolveModal, onBackdropPress, shouldHideOnBackdropPress = false, ...props}: ConfirmModalWrapperProps) {
+function ConfirmModalWrapper({closeModal, onModalHide, resolveModal, onBackdropPress, shouldHideOnBackdropPress = false, onConfirm, ...props}: ConfirmModalWrapperProps) {
const activeElementRole = useActiveElementRole();
const {isOffline} = useNetwork();
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
const [isVisible, setIsVisible] = useState(true);
const [closeAction, setCloseAction] = useState(ModalActions.CLOSE);
const [isConfirmLoading, setIsConfirmLoading] = useState(false);
+ const [confirmationError, setConfirmationError] = useState('');
+ const isSubmittingRef = useRef(false);
+
+ const isConfirmDisabledWhileOffline = !!props.shouldDisableConfirmButtonWhenOffline && isOffline;
+
+ const submitConfirmation = async () => {
+ if (!onConfirm) {
+ return;
+ }
+ isSubmittingRef.current = true;
+ setIsConfirmLoading(true);
+ setConfirmationError('');
+ try {
+ const error = await onConfirm();
+ if (error) {
+ setConfirmationError(error);
+ } else {
+ setCloseAction(ModalActions.CONFIRM);
+ setIsVisible(false);
+ }
+ } catch (error) {
+ Log.hmmm('Unable to complete confirmation', {error});
+ setConfirmationError(translate('common.genericErrorMessage'));
+ }
+ isSubmittingRef.current = false;
+ setIsConfirmLoading(false);
+ };
const handleConfirm = () => {
+ if (isSubmittingRef.current || isConfirmLoading || props.isConfirmLoading || props.isConfirmDisabled || isConfirmDisabledWhileOffline) {
+ return;
+ }
+ if (onConfirm) {
+ submitConfirmation();
+ return;
+ }
setCloseAction(ModalActions.CONFIRM);
// If isConfirmLoading is passed, don't close immediately - show loading state instead
// The caller should use closeModal() from useConfirmModal when the async operation completes
@@ -46,6 +91,9 @@ function ConfirmModalWrapper({closeModal, onModalHide, resolveModal, onBackdropP
};
const handleCancel = () => {
+ if (isSubmittingRef.current) {
+ return;
+ }
setCloseAction(ModalActions.CLOSE);
setIsVisible(false);
};
@@ -68,8 +116,6 @@ function ConfirmModalWrapper({closeModal, onModalHide, resolveModal, onBackdropP
};
// Mirror the confirm button's disabled state so the Enter shortcut can't bypass a button that is visually disabled.
- const isConfirmDisabledWhileOffline = !!props.shouldDisableConfirmButtonWhenOffline && isOffline;
-
const shortcutConfig = {
isActive: activeElementRole !== CONST.ROLE.BUTTON && !isConfirmLoading && !isConfirmDisabledWhileOffline && !props.isConfirmDisabled,
shouldPreventDefault: false,
@@ -81,12 +127,27 @@ function ConfirmModalWrapper({closeModal, onModalHide, resolveModal, onBackdropP
return (
+ {typeof props.prompt === 'string' ? {props.prompt} : props.prompt}
+
+ >
+ ) : (
+ props.prompt
+ )
+ }
isVisible={isVisible}
onConfirm={handleConfirm}
onCancel={handleCancel}
onBackdropPress={handleBackdropPress}
onModalHide={handleModalHide}
isConfirmLoading={isConfirmLoading || props.isConfirmLoading}
+ shouldShowCancelButton={onConfirm && isConfirmLoading ? false : props.shouldShowCancelButton}
/>
);
}
diff --git a/src/components/Pressable/GenericPressable/implementation/index.tsx b/src/components/Pressable/GenericPressable/implementation/index.tsx
index 0a84d4f614d4..bcc5264066ad 100644
--- a/src/components/Pressable/GenericPressable/implementation/index.tsx
+++ b/src/components/Pressable/GenericPressable/implementation/index.tsx
@@ -50,6 +50,7 @@ function WebGenericPressable({focusable = true, ref, sentryLabel, ...props}: Pre
role={shouldRenderAsNonNativeButton ? undefined : role}
id={props.id}
aria-label={props.accessibilityLabel}
+ aria-busy={props['aria-busy'] ?? props.accessibilityState?.busy}
aria-labelledby={props.accessibilityLabelledBy}
aria-valuenow={props.accessibilityValue?.now}
aria-valuemin={props.accessibilityValue?.min}
diff --git a/src/languages/de.ts b/src/languages/de.ts
index c1cd846b6263..2695ec7311ba 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -2675,6 +2675,10 @@ const translations: TranslationDeepObject = {
'Verwalten Sie alle Geräte, bei denen Sie sich mit Ihrem Expensify Konto angemeldet haben. Erfahren Sie mehr.',
revoke: 'Widerrufen',
unknownDevice: 'Unbekanntes Gerät',
+ signOutAllDevices: 'Auf allen Geräten abmelden',
+ signOutAllDevicesConfirmation: 'Sie werden auf allen Geräten abgemeldet, einschließlich diesem. Sie müssen sich erneut anmelden, um Expensify zu nutzen.',
+ signOutAllDevicesError: 'Wir konnten Sie nicht auf allen Geräten abmelden. Bitte versuchen Sie es erneut.',
+ signOutAllDevicesSuccess: 'Sie wurden auf allen Geräten abgemeldet.',
},
twoFactorAuth: {
headerTitle: 'Zwei-Faktor-Authentifizierung',
diff --git a/src/languages/el.ts b/src/languages/el.ts
index ed5715b7ea86..4d82007547af 100644
--- a/src/languages/el.ts
+++ b/src/languages/el.ts
@@ -2731,6 +2731,10 @@ const translations: TranslationDeepObject = {
'Διαχειριστείτε όλες τις συσκευές στις οποίες έχετε συνδεθεί με τον λογαριασμό σας Expensify. Μάθετε περισσότερα.',
revoke: 'Ανακαλέστε',
unknownDevice: 'Άγνωστη συσκευή',
+ signOutAllDevices: 'Αποσύνδεση από όλες τις συσκευές',
+ signOutAllDevicesConfirmation: 'Θα αποσυνδεθείτε από όλες τις συσκευές, συμπεριλαμβανομένης αυτής. Θα χρειαστεί να συνδεθείτε ξανά για να χρησιμοποιήσετε το Expensify.',
+ signOutAllDevicesError: 'Δεν μπορέσαμε να σας αποσυνδέσουμε από όλες τις συσκευές. Δοκιμάστε ξανά.',
+ signOutAllDevicesSuccess: 'Αποσυνδεθήκατε από όλες τις συσκευές.',
},
twoFactorAuth: {
headerTitle: 'Έλεγχος ταυτότητας δύο παραγόντων',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index a1834688c7f9..bebfd82dc697 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -2827,6 +2827,10 @@ const translations = {
'Manage all the devices that you have logged into with your Expensify Account. Learn more.',
revoke: 'Revoke',
unknownDevice: 'Unknown Device',
+ signOutAllDevices: 'Sign out on all devices',
+ signOutAllDevicesConfirmation: 'You’ll be signed out on all devices, including this one. You’ll need to sign in again to use Expensify.',
+ signOutAllDevicesError: 'We couldn’t sign you out on all devices. Please try again.',
+ signOutAllDevicesSuccess: 'You’ve been signed out on all devices.',
},
twoFactorAuth: {
headerTitle: 'Two-factor authentication',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index a48105617da7..579c2ff43984 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -2655,6 +2655,10 @@ const translations: TranslationDeepObject = {
'Gestiona todos los dispositivos en los que has iniciado sesión con tu cuenta de Expensify. Más información.',
revoke: 'Revocar',
unknownDevice: 'Dispositivo Desconocido',
+ signOutAllDevices: 'Cerrar sesión en todos los dispositivos',
+ signOutAllDevicesConfirmation: 'Se cerrará tu sesión en todos los dispositivos, incluido este. Tendrás que volver a iniciar sesión para usar Expensify.',
+ signOutAllDevicesError: 'No pudimos cerrar tu sesión en todos los dispositivos. Inténtalo de nuevo.',
+ signOutAllDevicesSuccess: 'Se ha cerrado tu sesión en todos los dispositivos.',
},
twoFactorAuth: {
headerTitle: 'Autenticación de dos factores',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 910279d14502..abaa95a9fdf4 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -2683,6 +2683,10 @@ const translations: TranslationDeepObject = {
'Gérez tous les appareils sur lesquels vous vous êtes connecté avec votre compte Expensify. En savoir plus.',
revoke: 'Révoquer',
unknownDevice: 'Appareil Inconnu',
+ signOutAllDevices: 'Se déconnecter de tous les appareils',
+ signOutAllDevicesConfirmation: 'Vous serez déconnecté de tous les appareils, y compris celui-ci. Vous devrez vous reconnecter pour utiliser Expensify.',
+ signOutAllDevicesError: 'Nous n’avons pas pu vous déconnecter de tous les appareils. Veuillez réessayer.',
+ signOutAllDevicesSuccess: 'Vous avez été déconnecté de tous les appareils.',
},
twoFactorAuth: {
headerTitle: 'Authentification à deux facteurs',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 17a40beb29b0..0d988be2e743 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -2666,6 +2666,10 @@ const translations: TranslationDeepObject = {
'Gestisci tutti i dispositivi su cui hai effettuato l\'accesso con il tuo account Expensify. Scopri di più.',
revoke: 'Revoca',
unknownDevice: 'Dispositivo Sconosciuto',
+ signOutAllDevices: 'Esci da tutti i dispositivi',
+ signOutAllDevicesConfirmation: 'Uscirai da tutti i dispositivi, incluso questo. Dovrai accedere di nuovo per usare Expensify.',
+ signOutAllDevicesError: 'Non è stato possibile disconnetterti da tutti i dispositivi. Riprova.',
+ signOutAllDevicesSuccess: 'Hai effettuato la disconnessione da tutti i dispositivi.',
},
twoFactorAuth: {
headerTitle: 'Autenticazione a due fattori',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 4cb974c33f6a..c86cbdbe1b93 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -2648,6 +2648,10 @@ const translations: TranslationDeepObject = {
'Expensify アカウントでログインしているすべてのデバイスを管理できます。詳しくはこちら.',
revoke: '取り消す',
unknownDevice: '不明なデバイス',
+ signOutAllDevices: 'すべてのデバイスからログアウト',
+ signOutAllDevicesConfirmation: 'このデバイスを含むすべてのデバイスからログアウトします。Expensifyを使用するには、再度ログインする必要があります。',
+ signOutAllDevicesError: 'すべてのデバイスからログアウトできませんでした。もう一度お試しください。',
+ signOutAllDevicesSuccess: 'すべてのデバイスからログアウトしました。',
},
twoFactorAuth: {
headerTitle: '2要素認証',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 815840a54874..2f854a929fa9 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -2664,6 +2664,10 @@ const translations: TranslationDeepObject = {
'Beheer alle apparaten waarop je bent ingelogd met je Expensify-account. Meer informatie.',
revoke: 'Intrekken',
unknownDevice: 'Onbekend Apparaat',
+ signOutAllDevices: 'Uitloggen op alle apparaten',
+ signOutAllDevicesConfirmation: 'Je wordt uitgelogd op alle apparaten, inclusief dit apparaat. Je moet opnieuw inloggen om Expensify te gebruiken.',
+ signOutAllDevicesError: 'We konden je niet op alle apparaten uitloggen. Probeer het opnieuw.',
+ signOutAllDevicesSuccess: 'Je bent uitgelogd op alle apparaten.',
},
twoFactorAuth: {
headerTitle: 'Tweeledige verificatie',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index bde7e2dcdc00..87c829a93b2a 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -2711,6 +2711,10 @@ const translations: TranslationDeepObject = {
'Zarządzaj wszystkimi urządzeniami, na które zalogowałeś(-aś) się za pomocą konta Expensify. Dowiedz się więcej.',
revoke: 'Cofnąć',
unknownDevice: 'Nieznane Urządzenie',
+ signOutAllDevices: 'Wyloguj się ze wszystkich urządzeń',
+ signOutAllDevicesConfirmation: 'Wylogujesz się ze wszystkich urządzeń, w tym z tego. Aby korzystać z Expensify, trzeba będzie zalogować się ponownie.',
+ signOutAllDevicesError: 'Nie udało się wylogować Cię ze wszystkich urządzeń. Spróbuj ponownie.',
+ signOutAllDevicesSuccess: 'Wylogowano Cię ze wszystkich urządzeń.',
},
twoFactorAuth: {
headerTitle: 'Uwierzytelnianie dwuskładnikowe',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index c473b89593ae..19e2466856ff 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -2669,6 +2669,10 @@ const translations: TranslationDeepObject = {
'Gerencie todos os dispositivos nos quais você fez login com sua conta Expensify. Saiba mais.',
revoke: 'Revogar',
unknownDevice: 'Dispositivo Desconhecido',
+ signOutAllDevices: 'Sair em todos os dispositivos',
+ signOutAllDevicesConfirmation: 'Você sairá da sua conta em todos os dispositivos, incluindo este. Será necessário entrar novamente para usar o Expensify.',
+ signOutAllDevicesError: 'Não foi possível sair da sua conta em todos os dispositivos. Tente novamente.',
+ signOutAllDevicesSuccess: 'Você saiu da sua conta em todos os dispositivos.',
},
twoFactorAuth: {
headerTitle: 'Autenticação de dois fatores',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index d5c0f6085156..775eef7e0ba6 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -2569,6 +2569,10 @@ const translations: TranslationDeepObject = {
description: '管理所有已使用你的 Expensify 账户登录的设备。了解详情.',
revoke: '撤销',
unknownDevice: '未知设备',
+ signOutAllDevices: '退出所有设备上的登录',
+ signOutAllDevicesConfirmation: '你将退出所有设备上的登录,包括此设备。你需要重新登录才能使用 Expensify。',
+ signOutAllDevicesError: '我们无法退出你在所有设备上的登录。请重试。',
+ signOutAllDevicesSuccess: '你已退出所有设备上的登录。',
},
twoFactorAuth: {
headerTitle: '双重身份验证',
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 22369b5d4832..6271259c2845 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -1969,6 +1969,7 @@ type ReadCommandParameters = {
};
const SIDE_EFFECT_REQUEST_COMMANDS = {
+ REVOKE_ALL_SESSIONS: 'RevokeAllSessions',
AUTHENTICATE_PUSHER: 'AuthenticatePusher',
GENERATE_SPOTNANA_TOKEN: 'GenerateSpotnanaToken',
GET_MISSING_ONYX_MESSAGES: 'GetMissingOnyxMessages',
@@ -2028,6 +2029,7 @@ const SIDE_EFFECT_REQUEST_COMMANDS = {
type SideEffectRequestCommand = ValueOf;
type SideEffectRequestCommandParameters = {
+ [SIDE_EFFECT_REQUEST_COMMANDS.REVOKE_ALL_SESSIONS]: null;
[SIDE_EFFECT_REQUEST_COMMANDS.AUTHENTICATE_PUSHER]: Parameters.AuthenticatePusherParams;
[SIDE_EFFECT_REQUEST_COMMANDS.GET_SUPPORTAL_REASON]: Parameters.GetSupportalReasonParams;
[SIDE_EFFECT_REQUEST_COMMANDS.CREATE_SUPPORT_TICKET]: Parameters.CreateSupportTicketParams;
diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index fb69c6c8bcb8..dbb526bc8ab9 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -1,3 +1,5 @@
+import type {LocaleContextProps} from '@components/LocaleContextProvider';
+
import {getFreshMarketingAttribution} from '@libs/actions/MarketingAttribution';
import * as PersistedRequests from '@libs/actions/PersistedRequests';
import * as API from '@libs/API';
@@ -26,6 +28,7 @@ import {hasAuthToken} from '@libs/CurrentUserStore';
import * as ErrorUtils from '@libs/ErrorUtils';
import FraudProtection from '@libs/FraudProtection';
import getPlatform from '@libs/getPlatform';
+import Growl from '@libs/Growl';
import HttpUtils from '@libs/HttpUtils';
import Log from '@libs/Log';
import {findMatchingDynamicSuffix} from '@libs/Navigation/helpers/dynamicRoutesUtils/findAllMatchingDynamicSuffixes';
@@ -36,6 +39,7 @@ import * as MainQueue from '@libs/Network/MainQueue';
import * as NetworkStore from '@libs/Network/NetworkStore';
import {getCurrentUserEmail} from '@libs/Network/NetworkStore';
import * as SequentialQueue from '@libs/Network/SequentialQueue';
+import {getIsOffline} from '@libs/NetworkState';
import {rand64} from '@libs/NumberUtils';
import openExternalLink from '@libs/openExternalLink';
import {buildPersonalDetailsUpdate} from '@libs/PersonalDetailsUtils';
@@ -301,6 +305,28 @@ async function signOut(params: {autoGeneratedLogin?: string; signedInWithSAML?:
return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.LOG_OUT, logOutParams, {});
}
+async function revokeAllSessions(translate: LocaleContextProps['translate']): Promise {
+ if (getIsOffline()) {
+ return false;
+ }
+
+ try {
+ // eslint-disable-next-line rulesdir/no-api-side-effects-method -- Sign-out must wait for the server response and must never enter the persisted write queue.
+ const response = await API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REVOKE_ALL_SESSIONS, null);
+ if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) {
+ return false;
+ }
+
+ // Wait for session cleanup so the success toast appears on the sign-in screen.
+ await redirectToSignIn(CONST.SIGN_OUT_REASON.USER_SIGN_OUT);
+ Growl.success({bodyText: translate('deviceManagementPage.signOutAllDevicesSuccess'), position: CONST.GROWL.POSITION.BOTTOM_RIGHT});
+ return true;
+ } catch (error) {
+ Log.hmmm('Unable to sign out on all devices', {error});
+ return false;
+ }
+}
+
function callSAMLSignOut(params: LogOutParams, authToken: string): Promise> {
const isWeb = getPlatform() === CONST.PLATFORM.WEB;
const queryString = isWeb ? `referer=ecash&authToken=${authToken}` : `appversion=${pkg.version}&referer=ecash&authToken=${authToken}`;
@@ -1196,7 +1222,7 @@ function clearSignInData() {
}
/**
- * Reset navigation to a brand new state with Home as the initial screen.
+ * Reset navigation to the shared authenticated and sign-in root.
*/
function resetNavigationState() {
Navigation.isNavigationReady().then(() => {
@@ -1205,7 +1231,7 @@ function resetNavigationState() {
Log.hmmm('[src/libs/actions/Session/index.ts] NavigationRef is not ready. Returning undefined.');
return undefined;
}
- navigationRef.resetRoot({index: 0, routes: [{name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}]});
+ navigationRef.resetRoot({index: 0, routes: [{name: NAVIGATORS.TAB_NAVIGATOR}]});
});
}
@@ -1943,6 +1969,7 @@ export {
setIsAuthenticatingWithShortLivedToken,
cleanupSession,
signOut,
+ revokeAllSessions,
signOutAndRedirectToSignIn,
resendValidateCode,
requestUnlinkValidationLink,
diff --git a/src/pages/settings/Security/DeviceManagementPage.tsx b/src/pages/settings/Security/DeviceManagementPage.tsx
index 431250b487e5..cfb9200e8825 100644
--- a/src/pages/settings/Security/DeviceManagementPage.tsx
+++ b/src/pages/settings/Security/DeviceManagementPage.tsx
@@ -1,14 +1,18 @@
import Button from '@components/Button';
+import FixedFooter from '@components/FixedFooter';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import RenderHTML from '@components/RenderHTML';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
+import useConfirmModal from '@hooks/useConfirmModal';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import useThemeStyles from '@hooks/useThemeStyles';
+import {revokeAllSessions} from '@libs/actions/Session';
import {clearRevokeError, revokeDevice} from '@libs/actions/User';
import Navigation from '@libs/Navigation/Navigation';
import {getDeviceDisplayName, getDeviceLogins, getLastLogin, getLoginKey} from '@libs/UserUtils';
@@ -22,7 +26,7 @@ import type {ListRenderItemInfo} from '@shopify/flash-list';
import type {OnyxEntry} from 'react-native-onyx';
import {FlashList} from '@shopify/flash-list';
-import React from 'react';
+import React, {useRef} from 'react';
import {View} from 'react-native';
function autoGeneratedLoginSelector(credentials: OnyxEntry) {
@@ -32,10 +36,44 @@ function autoGeneratedLoginSelector(credentials: OnyxEntry) {
function DeviceManagementPage() {
const styles = useThemeStyles();
const {translate, datetimeToRelative} = useLocalize();
+ const {isOffline} = useNetwork();
+ const {showConfirmModal} = useConfirmModal();
+ const isConfirmationOpenRef = useRef(false);
const [logins] = useOnyx(ONYXKEYS.LOGINS, {selector: getDeviceLogins});
const [autoGeneratedLogin] = useOnyx(ONYXKEYS.CREDENTIALS, {selector: autoGeneratedLoginSelector});
+ const confirmSignOutAllDevices = () => {
+ if (isConfirmationOpenRef.current) {
+ return;
+ }
+ isConfirmationOpenRef.current = true;
+ const confirmation = isOffline
+ ? showConfirmModal({
+ title: translate('common.youAppearToBeOffline'),
+ prompt: translate('common.offlinePrompt'),
+ confirmText: translate('common.buttonConfirm'),
+ shouldShowCancelButton: false,
+ })
+ : showConfirmModal({
+ title: translate('deviceManagementPage.signOutAllDevices'),
+ prompt: translate('deviceManagementPage.signOutAllDevicesConfirmation'),
+ confirmText: translate('deviceManagementPage.signOutAllDevices'),
+ cancelText: translate('common.cancel'),
+ buttonVariant: CONST.BUTTON_VARIANT.DANGER,
+ shouldDisableConfirmButtonWhenOffline: true,
+ onConfirm: async () => {
+ const didSignOut = await revokeAllSessions(translate);
+ if (!didSignOut) {
+ return translate('deviceManagementPage.signOutAllDevicesError');
+ }
+ },
+ });
+ confirmation.finally(() => {
+ isConfirmationOpenRef.current = false;
+ });
+ };
+
const listHeader = (
@@ -77,14 +115,27 @@ function DeviceManagementPage() {
title={translate('deviceManagementPage.title')}
onBackButtonPress={Navigation.goBack}
/>
-
+
+
+
+
+
+
);
}
diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts
index 0bdce4b22c5a..4f7fdecee724 100644
--- a/tests/actions/SessionTest.ts
+++ b/tests/actions/SessionTest.ts
@@ -12,9 +12,11 @@ import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs
import asyncOpenURL from '@libs/asyncOpenURL';
import buildOldDotURL from '@libs/buildOldDotURL';
import getPlatform from '@libs/getPlatform';
+import Growl from '@libs/Growl';
import HttpUtils from '@libs/HttpUtils';
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
import * as NetworkStore from '@libs/Network/NetworkStore';
+import * as NetworkState from '@libs/NetworkState';
import {setHasRadio} from '@libs/NetworkState';
import PushNotification from '@libs/Notification/PushNotification';
import {isRecord} from '@libs/ObjectUtils';
@@ -101,6 +103,88 @@ beforeEach(() => {
});
describe('Session', () => {
+ describe('revokeAllSessions', () => {
+ const translate = jest.fn(() => 'You’ve been signed out on all devices.');
+ const mockAPI = jest.mocked(API);
+
+ beforeEach(() => {
+ jest.spyOn(NetworkState, 'getIsOffline').mockReturnValue(false);
+ jest.spyOn(Growl, 'success').mockImplementation(() => {});
+ jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined);
+ jest.spyOn(API, 'write').mockResolvedValue(undefined);
+ jest.spyOn(SignInRedirect, 'default').mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('waits for confirmed success and the sign-in redirect before showing success', async () => {
+ // Given the server response and local sign-out can each take time to finish
+ const response = Promise.withResolvers<{jsonCode: number}>();
+ const redirect = Promise.withResolvers();
+ mockAPI.makeRequestWithSideEffects.mockReturnValueOnce(response.promise);
+ jest.mocked(SignInRedirect.default).mockReturnValueOnce(redirect.promise);
+
+ // When the user signs out on all devices
+ const result = SessionUtil.revokeAllSessions(translate);
+ expect(SignInRedirect.default).not.toHaveBeenCalled();
+ expect(Growl.success).not.toHaveBeenCalled();
+ response.resolve({jsonCode: CONST.JSON_CODE.SUCCESS});
+ await waitForBatchedUpdates();
+
+ // Then local sign-out finishes before the success toast can be shown
+ expect(mockAPI.makeRequestWithSideEffects).toHaveBeenCalledWith(SIDE_EFFECT_REQUEST_COMMANDS.REVOKE_ALL_SESSIONS, null);
+ expect(mockAPI.write).not.toHaveBeenCalled();
+ expect(SignInRedirect.default).toHaveBeenCalledTimes(1);
+ expect(Growl.success).not.toHaveBeenCalled();
+ redirect.resolve();
+ await expect(result).resolves.toBe(true);
+ expect(Growl.success).toHaveBeenCalledWith({bodyText: 'You’ve been signed out on all devices.', position: CONST.GROWL.POSITION.BOTTOM_RIGHT});
+ });
+
+ it.each([undefined, {jsonCode: 500}])('keeps the current session after an unsuccessful response: %p', async (response) => {
+ // Given a response that does not confirm that sessions were revoked
+ mockAPI.makeRequestWithSideEffects.mockResolvedValueOnce(response);
+
+ // When the action finishes
+ const result = await SessionUtil.revokeAllSessions(translate);
+
+ // Then the caller can offer retry without signing out or claiming success
+ expect(result).toBe(false);
+ expect(SignInRedirect.default).not.toHaveBeenCalled();
+ expect(Growl.success).not.toHaveBeenCalled();
+ });
+
+ it('allows retry after a network failure without clearing the session', async () => {
+ // Given a request that fails before a confirmed server response
+ mockAPI.makeRequestWithSideEffects.mockRejectedValueOnce(new Error('Connection lost'));
+
+ // When the action handles the failure
+ const result = await SessionUtil.revokeAllSessions(translate);
+
+ // Then failure is returned to the confirmation without signing out
+ expect(result).toBe(false);
+ expect(SignInRedirect.default).not.toHaveBeenCalled();
+ expect(Growl.success).not.toHaveBeenCalled();
+ });
+
+ it('does not send or queue a destructive request while offline', async () => {
+ // Given connectivity was lost before the action could send its request
+ jest.mocked(NetworkState.getIsOffline).mockReturnValue(true);
+
+ // When the confirmation attempts to submit
+ const result = await SessionUtil.revokeAllSessions(translate);
+
+ // Then reconnecting cannot later execute an offline sign-out request
+ expect(result).toBe(false);
+ expect(mockAPI.makeRequestWithSideEffects).not.toHaveBeenCalled();
+ expect(mockAPI.write).not.toHaveBeenCalled();
+ expect(SignInRedirect.default).not.toHaveBeenCalled();
+ expect(Growl.success).not.toHaveBeenCalled();
+ });
+ });
+
describe('callIfAuthenticated', () => {
test('blocks a non-anonymous action for a passed anonymous session even when the stored session is signed in', async () => {
// Given a signed-in session stored in Onyx
diff --git a/tests/navigation/PublicSignInPageRootUrlTest.tsx b/tests/navigation/PublicSignInPageRootUrlTest.tsx
index 1032b05c50c8..5d7fa1af9e1c 100644
--- a/tests/navigation/PublicSignInPageRootUrlTest.tsx
+++ b/tests/navigation/PublicSignInPageRootUrlTest.tsx
@@ -1,16 +1,21 @@
-import {render, screen} from '@testing-library/react-native';
+import {act, render, screen} from '@testing-library/react-native';
+import {cleanupSession} from '@libs/actions/Session';
import PublicScreens from '@libs/Navigation/AppNavigator/PublicScreens';
import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath';
import getPathFromState from '@libs/Navigation/helpers/getPathFromState';
+import Navigation from '@libs/Navigation/Navigation';
import navigationRef from '@libs/Navigation/navigationRef';
import NAVIGATORS from '@src/NAVIGATORS';
+import SCREENS from '@src/SCREENS';
import {NavigationContainer} from '@react-navigation/native';
import React from 'react';
import {View} from 'react-native';
+import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';
+
// SignInPage is the initial (and only rendered) public route, so it gets a findable testID. The `mock`
// prefix is required for the hoisted jest.mock factory to reference it; a function declaration hoists too,
// so there's no temporal-dead-zone risk when the factory runs.
@@ -31,6 +36,35 @@ jest.mock('@hooks/useTheme', () => () => ({overlay: '#000000'}));
jest.mock('@hooks/useStyleUtils', () => () => ({getBackgroundColorWithOpacityStyle: () => ({})}));
describe('Logged-out sign-in page URL', () => {
+ it('returns to sign-in without an unhandled reset when session cleanup settles after public navigation mounts', async () => {
+ // Given session teardown waits for navigation while the logged-out navigator is already active
+ const ready = Promise.withResolvers();
+ const readiness = jest.spyOn(Navigation, 'isNavigationReady').mockReturnValueOnce(ready.promise);
+ const onUnhandledAction = jest.fn();
+ render(
+
+
+ ,
+ );
+ await waitForBatchedUpdatesWithAct();
+
+ // When the real session cleanup completes its queued navigation reset
+ cleanupSession();
+ await act(async () => ready.resolve());
+ await waitForBatchedUpdatesWithAct();
+ readiness.mockRestore();
+
+ // Then the registered sign-in root is shown at the canonical URL without dispatching an invalid reset
+ expect(onUnhandledAction).not.toHaveBeenCalled();
+ expect(screen.getByTestId('sign-in-page')).toBeOnTheScreen();
+ expect(navigationRef.getRootState().routes.at(0)?.name).toBe(NAVIGATORS.TAB_NAVIGATOR);
+ expect(getPathFromState(navigationRef.getRootState())).toBe('/');
+ });
+
it('renders the SignInPage at the root "/" (registered under TAB_NAVIGATOR, which has no path)', () => {
render(
diff --git a/tests/ui/DeviceManagementPageTest.tsx b/tests/ui/DeviceManagementPageTest.tsx
new file mode 100644
index 000000000000..f8a69d5b423b
--- /dev/null
+++ b/tests/ui/DeviceManagementPageTest.tsx
@@ -0,0 +1,271 @@
+import {act, fireEvent, render, screen, within} from '@testing-library/react-native';
+
+import ComposeProviders from '@components/ComposeProviders';
+import {LocaleContextProvider} from '@components/LocaleContextProvider';
+import {ModalProvider} from '@components/Modal/Global/ModalContext';
+import type BaseModalProps from '@components/Modal/types';
+import OnyxListItemProvider from '@components/OnyxListItemProvider';
+
+import * as API from '@libs/API';
+import Growl from '@libs/Growl';
+import * as NetworkState from '@libs/NetworkState';
+
+import DeviceManagementPage from '@pages/settings/Security/DeviceManagementPage';
+
+import redirectToSignIn from '@userActions/SignInRedirect';
+
+import CONST from '@src/CONST';
+import IntlStore from '@src/languages/IntlStore';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+import type {View as NativeView} from 'react-native';
+
+import React from 'react';
+import Onyx from 'react-native-onyx';
+
+import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';
+
+jest.mock('@libs/API');
+jest.mock('@userActions/SignInRedirect');
+jest.mock('@hooks/useResponsiveLayout');
+jest.mock('@components/RenderHTML', () => {
+ const {Text} = jest.requireActual<{Text: React.ComponentType<{children: React.ReactNode}>}>('react-native');
+ return {__esModule: true, default: ({html}: {html: string}) => {html}};
+});
+jest.mock('@components/ScreenWrapper', () => {
+ const {View} = jest.requireActual<{View: typeof NativeView}>('react-native');
+ return {__esModule: true, default: ({children}: {children: React.ReactNode}) => {children}};
+});
+jest.mock('@components/Modal', () => {
+ const {View} = jest.requireActual<{View: typeof NativeView}>('react-native');
+ const {useEffect} = jest.requireActual('react');
+ return {
+ __esModule: true,
+ default: function MockModal({children, isVisible, onModalHide}: BaseModalProps) {
+ useEffect(() => {
+ if (isVisible) {
+ return;
+ }
+ onModalHide?.();
+ }, [isVisible, onModalHide]);
+ return isVisible ? {children} : null;
+ },
+ };
+});
+
+const SIGN_OUT_TEXT = 'Sign out on all devices';
+const CONFIRMATION_TEXT = 'You’ll be signed out on all devices, including this one. You’ll need to sign in again to use Expensify.';
+const ERROR_TEXT = 'We couldn’t sign you out on all devices. Please try again.';
+const mockAPI = jest.mocked(API);
+
+function renderPage() {
+ render(
+
+
+ ,
+ );
+}
+
+async function openConfirmation() {
+ fireEvent.press(screen.getByRole(CONST.ROLE.BUTTON, {name: SIGN_OUT_TEXT}));
+ await waitForBatchedUpdatesWithAct();
+}
+
+describe('Device management sign out on all devices', () => {
+ beforeAll(() => {
+ IntlStore.load(CONST.LOCALES.EN);
+ });
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ jest.spyOn(Growl, 'success').mockImplementation(() => {});
+ NetworkState.setHasRadio(true);
+ await Onyx.clear();
+ await Onyx.set(ONYXKEYS.LOGINS, {
+ device: {
+ accountID: 1,
+ partnerID: CONST.PARTNER_ID.NEWDOT,
+ partnerUserID: 'device',
+ created: '2026-11-01 12:00:00',
+ lastLogin: '2026-11-01 12:00:00',
+ validatedDate: null,
+ additionalData: {
+ deviceName: 'Chrome',
+ os: 'macOS',
+ osVersion: '15',
+ // eslint-disable-next-line @typescript-eslint/naming-convention -- Device metadata uses the server's field name.
+ app_version: '1',
+ timestamp: '2026-11-01 12:00:00',
+ },
+ },
+ });
+ await waitForBatchedUpdatesWithAct();
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ NetworkState.setHasRadio(true);
+ });
+
+ it.each([1, 30])('keeps sign out available outside the scrolling device rows with %i devices', async (deviceCount) => {
+ // Given an account whose device list may be taller than the available screen
+ const device = (await Onyx.get(ONYXKEYS.LOGINS))?.device;
+ if (!device) {
+ throw new Error('Expected a device login in the test account');
+ }
+ await Onyx.set(ONYXKEYS.LOGINS, Object.fromEntries(Array.from({length: deviceCount}, (_, index) => [`device${index}`, {...device, partnerUserID: `device${index}`}])));
+ renderPage();
+ await waitForBatchedUpdatesWithAct();
+
+ // When the device list is scrolled
+ const deviceList = screen.getByTestId('DeviceManagementList');
+ fireEvent.scroll(deviceList, {
+ nativeEvent: {
+ contentOffset: {x: 0, y: 1200},
+ contentSize: {width: 300, height: 2400},
+ layoutMeasurement: {width: 300, height: 400},
+ },
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ // Then the bulk action stays available independently of the scrolling rows, whose revoke controls remain intact
+ expect(within(deviceList).queryByRole(CONST.ROLE.BUTTON, {name: SIGN_OUT_TEXT})).not.toBeOnTheScreen();
+ expect(screen.getByRole(CONST.ROLE.BUTTON, {name: SIGN_OUT_TEXT})).toBeOnTheScreen();
+ expect(within(deviceList).getAllByRole(CONST.ROLE.BUTTON, {name: 'Revoke'}).length).toBeGreaterThan(0);
+ });
+
+ it('explains that this device is included and cancels without submitting', async () => {
+ // Given the user has opened device management
+ renderPage();
+ await waitForBatchedUpdatesWithAct();
+
+ // When they open the confirmation and cancel
+ await openConfirmation();
+ const confirmation = within(screen.getByTestId('confirmation'));
+ expect(confirmation.getByText(CONFIRMATION_TEXT)).toBeOnTheScreen();
+ fireEvent.press(confirmation.getByRole(CONST.ROLE.BUTTON, {name: 'Cancel'}));
+ await waitForBatchedUpdatesWithAct();
+
+ // Then they remain on device management without a request or success message
+ expect(screen.queryByTestId('confirmation')).not.toBeOnTheScreen();
+ expect(screen.getByRole(CONST.ROLE.BUTTON, {name: SIGN_OUT_TEXT})).toBeOnTheScreen();
+ expect(mockAPI.makeRequestWithSideEffects).not.toHaveBeenCalled();
+ expect(redirectToSignIn).not.toHaveBeenCalled();
+ expect(Growl.success).not.toHaveBeenCalled();
+ });
+
+ it('shows the existing offline dialog instead of accepting an offline sign-out', async () => {
+ // Given device management was opened before connectivity was lost
+ renderPage();
+ await waitForBatchedUpdatesWithAct();
+ act(() => NetworkState.setHasRadio(false));
+
+ // When the user tries to sign out on all devices
+ await openConfirmation();
+ const confirmation = within(screen.getByTestId('confirmation'));
+
+ // Then only the informational offline dialog is offered, and reconnecting submits nothing
+ expect(confirmation.getByText('You appear to be offline.')).toBeOnTheScreen();
+ expect(confirmation.queryByRole(CONST.ROLE.BUTTON, {name: SIGN_OUT_TEXT})).not.toBeOnTheScreen();
+ fireEvent.press(confirmation.getByRole(CONST.ROLE.BUTTON, {name: 'Got it'}));
+ act(() => NetworkState.setHasRadio(true));
+ await waitForBatchedUpdatesWithAct();
+ expect(mockAPI.makeRequestWithSideEffects).not.toHaveBeenCalled();
+ expect(mockAPI.write).not.toHaveBeenCalled();
+ expect(Growl.success).not.toHaveBeenCalled();
+ });
+
+ it('disables confirmation on disconnect and never submits after cancellation and reconnect', async () => {
+ // Given the confirmation was opened while online
+ renderPage();
+ await waitForBatchedUpdatesWithAct();
+ await openConfirmation();
+ const confirmation = within(screen.getByTestId('confirmation'));
+
+ // When connectivity is lost while it is open
+ act(() => NetworkState.setHasRadio(false));
+ await waitForBatchedUpdatesWithAct();
+
+ // Then offline feedback explains the disabled action, and cancellation stays cancelled after reconnect
+ expect(confirmation.getByRole(CONST.ROLE.BUTTON, {name: SIGN_OUT_TEXT})).toBeDisabled();
+ expect(confirmation.getByText('You appear to be offline.')).toBeOnTheScreen();
+ fireEvent.press(confirmation.getByRole(CONST.ROLE.BUTTON, {name: SIGN_OUT_TEXT}));
+ fireEvent.press(confirmation.getByRole(CONST.ROLE.BUTTON, {name: 'Cancel'}));
+ await waitForBatchedUpdatesWithAct();
+ act(() => NetworkState.setHasRadio(true));
+ await waitForBatchedUpdatesWithAct();
+ expect(screen.queryByTestId('confirmation')).not.toBeOnTheScreen();
+ expect(mockAPI.makeRequestWithSideEffects).not.toHaveBeenCalled();
+ expect(mockAPI.write).not.toHaveBeenCalled();
+ expect(Growl.success).not.toHaveBeenCalled();
+ });
+
+ it('announces pending submissions as busy, prevents duplicates, and clears busy after failure before retrying', async () => {
+ // Given the server holds the first request before reporting a failure
+ const response = Promise.withResolvers<{jsonCode: number}>();
+ mockAPI.makeRequestWithSideEffects.mockReturnValueOnce(response.promise);
+ renderPage();
+ await waitForBatchedUpdatesWithAct();
+ await openConfirmation();
+ const confirmation = within(screen.getByTestId('confirmation'));
+ const confirmButton = confirmation.getByRole(CONST.ROLE.BUTTON, {name: SIGN_OUT_TEXT});
+
+ // When the user confirms repeatedly before the response arrives
+ // eslint-disable-next-line testing-library/no-unnecessary-act -- Both presses must share a React batch to exercise the guard before the loading state renders.
+ act(() => {
+ fireEvent.press(confirmButton);
+ fireEvent.press(confirmButton);
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ // Then one request is sent, and failure keeps the confirmation available for retry or cancel
+ expect(mockAPI.makeRequestWithSideEffects).toHaveBeenCalledTimes(1);
+ expect(confirmButton).toBeDisabled();
+ expect(confirmation.getByRole(CONST.ROLE.BUTTON, {name: SIGN_OUT_TEXT, busy: true})).toBeDisabled();
+ await act(async () => response.resolve({jsonCode: 500}));
+ await waitForBatchedUpdatesWithAct();
+ expect(confirmation.getByText(ERROR_TEXT)).toBeOnTheScreen();
+ expect(confirmation.getByRole(CONST.ROLE.BUTTON, {name: SIGN_OUT_TEXT, busy: false})).toBeEnabled();
+ expect(confirmation.getByRole(CONST.ROLE.BUTTON, {name: 'Cancel'})).toBeEnabled();
+ expect(redirectToSignIn).not.toHaveBeenCalled();
+ expect(Growl.success).not.toHaveBeenCalled();
+
+ // When retry succeeds
+ const retryResponse = Promise.withResolvers<{jsonCode: number}>();
+ mockAPI.makeRequestWithSideEffects.mockReturnValueOnce(retryResponse.promise);
+ jest.mocked(redirectToSignIn).mockResolvedValueOnce();
+ fireEvent.press(confirmation.getByRole(CONST.ROLE.BUTTON, {name: SIGN_OUT_TEXT}));
+ await waitForBatchedUpdatesWithAct();
+ expect(confirmation.getByRole(CONST.ROLE.BUTTON, {name: SIGN_OUT_TEXT, busy: true})).toBeDisabled();
+ await act(async () => retryResponse.resolve({jsonCode: CONST.JSON_CODE.SUCCESS}));
+ await waitForBatchedUpdatesWithAct();
+
+ // Then sign-out succeeds without requiring the user to reopen the confirmation
+ expect(mockAPI.makeRequestWithSideEffects).toHaveBeenCalledTimes(2);
+ expect(redirectToSignIn).toHaveBeenCalledTimes(1);
+ expect(Growl.success).toHaveBeenCalledTimes(1);
+ expect(screen.queryByTestId('confirmation')).not.toBeOnTheScreen();
+ });
+
+ it('lets the user cancel after a failed sign-out without clearing their session', async () => {
+ // Given the server cannot revoke sessions
+ mockAPI.makeRequestWithSideEffects.mockResolvedValueOnce({jsonCode: 500});
+ renderPage();
+ await waitForBatchedUpdatesWithAct();
+ await openConfirmation();
+ const confirmation = within(screen.getByTestId('confirmation'));
+
+ // When the user submits and then cancels the failed confirmation
+ fireEvent.press(confirmation.getByRole(CONST.ROLE.BUTTON, {name: SIGN_OUT_TEXT}));
+ await waitForBatchedUpdatesWithAct();
+ expect(confirmation.getByText(ERROR_TEXT)).toBeOnTheScreen();
+ fireEvent.press(confirmation.getByRole(CONST.ROLE.BUTTON, {name: 'Cancel'}));
+ await waitForBatchedUpdatesWithAct();
+
+ // Then they remain signed in and can still use device management
+ expect(screen.queryByTestId('confirmation')).not.toBeOnTheScreen();
+ expect(screen.getByText('Chrome (macOS 15)')).toBeOnTheScreen();
+ expect(redirectToSignIn).not.toHaveBeenCalled();
+ expect(Growl.success).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/ui/components/ButtonWebAccessibilityTest.tsx b/tests/ui/components/ButtonWebAccessibilityTest.tsx
new file mode 100644
index 000000000000..9fb2f595994e
--- /dev/null
+++ b/tests/ui/components/ButtonWebAccessibilityTest.tsx
@@ -0,0 +1,124 @@
+/* eslint-disable testing-library/no-unnecessary-act -- This suite drives a React DOM root directly. */
+import {screen} from '@testing-library/dom';
+
+import type ButtonComponent from '@components/Button/Button';
+import type WebGenericPressableComponent from '@components/Pressable/GenericPressable/implementation/index';
+
+import CONST from '@src/CONST';
+
+import React, {act} from 'react';
+import {createRoot} from 'react-dom/client';
+import * as Reanimated from 'react-native-reanimated';
+
+// The native Jest setup caches native components, so reload the real Button with the web adapter and the same React instance.
+jest.resetModules();
+jest.doMock('react', () => React);
+jest.doMock('react-native-reanimated', () => Reanimated);
+jest.mock('react-native', () => jest.requireActual>('react-native-web'));
+jest.mock('@components/Pressable/GenericPressable/implementation', () => jest.requireActual>('@components/Pressable/GenericPressable/implementation/index.tsx'));
+jest.mock('@components/OpacityView', () => ({
+ __esModule: true,
+ default: ({children}: {children: React.ReactNode}) => {children}
,
+}));
+jest.mock('@hooks/useKeyboardShortcut', () => ({__esModule: true, default: () => {}}));
+jest.mock('@hooks/useRouteKey', () => ({__esModule: true, default: () => undefined}));
+jest.mock('@libs/Accessibility', () => ({
+ __esModule: true,
+ default: {
+ useScreenReaderState: () => 'disabled',
+ useAutoHitSlop: () => [undefined, jest.fn()],
+ },
+}));
+jest.mock('@libs/NavigationFocusReturn', () => ({
+ registerPressable: () => () => {},
+ notifyPressedTrigger: jest.fn(),
+}));
+jest.mock('@libs/AppState', () => ({__esModule: true, default: () => {}}));
+
+const Button = jest.requireActual<{default: typeof ButtonComponent}>('@components/Button/Button').default;
+const WebGenericPressable = jest.requireActual<{default: typeof WebGenericPressableComponent}>('@components/Pressable/GenericPressable/implementation/index.tsx').default;
+const CONFIRM_TEXT = 'Sign out on all devices';
+
+describe('Button web accessibility', () => {
+ let container: HTMLDivElement;
+ let root: ReturnType;
+
+ beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ jest.clearAllMocks();
+ });
+
+ const renderButton = async (isPending: boolean) => {
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ };
+
+ it('exposes pending and retry busy states on the actual DOM button and clears them when loading ends', async () => {
+ // Given the same loading and accessibility state supplied by the confirmation, through the real Button and web Pressable
+ await renderButton(false);
+ expect(screen.getByRole(CONST.ROLE.BUTTON, {name: CONFIRM_TEXT, busy: false}).getAttribute('aria-disabled')).not.toBe('true');
+
+ // When the confirmation begins submitting
+ await renderButton(true);
+
+ // Then the actual accessible DOM control announces that it is busy, not merely disabled
+ const pendingButton = screen.getByRole(CONST.ROLE.BUTTON, {name: CONFIRM_TEXT, busy: true});
+ expect(pendingButton.getAttribute('aria-busy')).toBe('true');
+ expect(pendingButton.getAttribute('aria-disabled')).toBe('true');
+
+ // When a failed attempt clears the loading state
+ await renderButton(false);
+
+ // Then the same DOM button is no longer busy or disabled, so retry is accessible
+ const retryButton = screen.getByRole(CONST.ROLE.BUTTON, {name: CONFIRM_TEXT, busy: false});
+ expect(retryButton).toBe(pendingButton);
+ expect(retryButton.getAttribute('aria-busy')).toBe('false');
+ expect(retryButton.getAttribute('aria-disabled')).not.toBe('true');
+
+ // When a retry begins and then finishes
+ await renderButton(true);
+ expect(screen.getByRole(CONST.ROLE.BUTTON, {name: CONFIRM_TEXT, busy: true}).getAttribute('aria-busy')).toBe('true');
+ await renderButton(false);
+
+ // Then busy clears again rather than remaining stuck after retry
+ expect(screen.getByRole(CONST.ROLE.BUTTON, {name: CONFIRM_TEXT, busy: false}).getAttribute('aria-busy')).toBe('false');
+ });
+
+ it.each([true, false])('preserves an explicit aria-busy value of %p from an existing pressable consumer', async (busy) => {
+ // Given a consumer such as Switch supplies aria-busy directly, even when another state value disagrees
+ // When it renders through the real web Pressable
+ await act(async () => {
+ root.render(
+
+ {CONFIRM_TEXT}
+ ,
+ );
+ });
+
+ // Then the explicit ARIA state still reaches the accessible DOM control
+ expect(screen.getByRole(CONST.ROLE.BUTTON, {name: CONFIRM_TEXT, busy}).getAttribute('aria-busy')).toBe(String(busy));
+ });
+});
diff --git a/tests/ui/components/ConfirmModalWrapperOfflineTest.tsx b/tests/ui/components/ConfirmModalWrapperOfflineTest.tsx
index 41a08c461569..4280e53f2bd6 100644
--- a/tests/ui/components/ConfirmModalWrapperOfflineTest.tsx
+++ b/tests/ui/components/ConfirmModalWrapperOfflineTest.tsx
@@ -1,4 +1,4 @@
-import {fireEvent, render, screen} from '@testing-library/react-native';
+import {act, fireEvent, render, screen} from '@testing-library/react-native';
import ComposeProviders from '@components/ComposeProviders';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
@@ -31,7 +31,7 @@ function Wrapper({children}: {children: React.ReactNode}) {
return {children};
}
-function renderConfirmModalWrapper() {
+function renderConfirmModalWrapper(onConfirm?: () => Promise, shouldStackButtons = true) {
const closeModal = jest.fn();
const resolveModal = jest.fn();
@@ -48,6 +48,8 @@ function renderConfirmModalWrapper() {
shouldDisableConfirmButtonWhenOffline
// Routing confirm through the async resolve path lets us assert the confirm handler ran without depending on the modal-hide animation.
isConfirmLoading={false}
+ onConfirm={onConfirm}
+ shouldStackButtons={shouldStackButtons}
/>
,
);
@@ -103,4 +105,36 @@ describe('ConfirmModalWrapper (locked BA offline)', () => {
await waitForBatchedUpdatesWithAct();
expect(resolveModal).toHaveBeenCalledWith({action: 'CONFIRM'});
});
+
+ it.each([true, false])('announces busy and offers safe retry after rejection with stacked buttons: %p', async (shouldStackButtons) => {
+ // Given an async action rejects without supplying user-facing copy
+ const pendingConfirmation = Promise.withResolvers();
+ const confirm = jest.fn, []>().mockReturnValueOnce(pendingConfirmation.promise).mockResolvedValueOnce('Please try again.');
+ const {closeModal, resolveModal} = renderConfirmModalWrapper(confirm, shouldStackButtons);
+ await waitForBatchedUpdatesWithAct();
+
+ // When the user confirms
+ fireEvent.press(screen.getByRole(CONST.ROLE.BUTTON, {name: CONFIRM_TEXT}));
+ await waitForBatchedUpdatesWithAct();
+ expect(screen.getByRole(CONST.ROLE.BUTTON, {name: CONFIRM_TEXT, busy: true})).toBeDisabled();
+ await act(async () => pendingConfirmation.reject(new Error('Private server details')));
+ await waitForBatchedUpdatesWithAct();
+
+ // Then the modal offers retry and cancel, without exposing private error details or resolving as success
+ expect(screen.getByText('Oops... something went wrong and your request could not be completed. Please try again later.')).toBeOnTheScreen();
+ expect(screen.queryByText('Private server details')).not.toBeOnTheScreen();
+ expect(screen.getByRole(CONST.ROLE.BUTTON, {name: CANCEL_TEXT})).toBeEnabled();
+ expect(screen.getByRole(CONST.ROLE.BUTTON, {name: CONFIRM_TEXT, busy: false})).toBeEnabled();
+ expect(closeModal).not.toHaveBeenCalled();
+ expect(resolveModal).not.toHaveBeenCalled();
+
+ // When the user retries
+ fireEvent.press(screen.getByRole(CONST.ROLE.BUTTON, {name: CONFIRM_TEXT}));
+ await waitForBatchedUpdatesWithAct();
+
+ // Then another attempt runs and its localized feedback replaces the previous error
+ expect(confirm).toHaveBeenCalledTimes(2);
+ expect(screen.getByText('Please try again.')).toBeOnTheScreen();
+ expect(screen.getByRole(CONST.ROLE.BUTTON, {name: CONFIRM_TEXT})).toBeEnabled();
+ });
});
diff --git a/tests/ui/components/GrowlNotificationContentTest.tsx b/tests/ui/components/GrowlNotificationContentTest.tsx
new file mode 100644
index 000000000000..38c4f51e440d
--- /dev/null
+++ b/tests/ui/components/GrowlNotificationContentTest.tsx
@@ -0,0 +1,171 @@
+import {act, fireEvent, render, screen} from '@testing-library/react-native';
+
+import GrowlNotificationContent from '@components/GrowlNotification/GrowlNotificationContent';
+import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
+
+import type {GrowlPosition} from '@libs/Growl';
+
+import variables from '@styles/variables';
+
+import CONST from '@src/CONST';
+
+import React from 'react';
+import {StyleSheet, View} from 'react-native';
+import * as Reanimated from 'react-native-reanimated';
+
+jest.mock('@hooks/useResponsiveLayout');
+jest.mock('@hooks/useSafeAreaInsets', () => ({__esModule: true, default: () => ({top: 44, bottom: 34, left: 0, right: 0})}));
+jest.mock('react-native-gesture-handler', () => ({
+ ...jest.requireActual>('react-native-gesture-handler'),
+ GestureDetector: ({children}: {children: React.ReactNode}) => children,
+ useFlingGesture: jest.fn(() => ({})),
+}));
+
+const BODY_TEXT = 'You’ve been signed out on all devices.';
+
+function getAnchoredContainer() {
+ const container = screen.UNSAFE_getAllByType(View).find((view) => {
+ const style = StyleSheet.flatten(view.props.style);
+ return (
+ !!style &&
+ typeof style === 'object' &&
+ (('top' in style && style.top === variables.growlNotificationInset) || ('bottom' in style && style.bottom === variables.growlNotificationInset))
+ );
+ });
+ if (!container) {
+ throw new Error('Expected a growl anchored to a screen edge');
+ }
+ return container;
+}
+
+function getGrowlWrapper() {
+ const wrapper = screen.UNSAFE_getAllByType(View).find((view) => {
+ const style = StyleSheet.flatten(view.props.style);
+ return !!style && typeof style === 'object' && 'zIndex' in style && style.zIndex === variables.growlNotificationZIndex;
+ });
+ if (!wrapper) {
+ throw new Error('Expected the growl overlay wrapper');
+ }
+ return wrapper;
+}
+
+describe('Native Growl placement', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.spyOn(Reanimated, 'withSpring').mockImplementation((value, _config, onComplete) => {
+ onComplete?.(true);
+ return value;
+ });
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ jest.useRealTimers();
+ });
+
+ it.each([undefined, CONST.GROWL.POSITION.BOTTOM_RIGHT])('anchors position %p to the requested edge and keeps safe-area clearance', (position: GrowlPosition | undefined) => {
+ // Given an ordinary toast or a toast that explicitly requests the bottom edge
+ render(
+ ,
+ );
+
+ // When the native toast becomes visible
+ const container = getAnchoredContainer();
+ const style = StyleSheet.flatten(container.props.style);
+
+ // Then its rendered native anchor honors the request without changing the ordinary top default
+ expect(style).toMatchObject({position: 'absolute', [position ? 'bottom' : 'top']: variables.growlNotificationInset});
+ expect(style).not.toHaveProperty(position ? 'top' : 'bottom');
+ expect(style).toMatchObject({paddingTop: 44, paddingBottom: 34 * variables.iosSafeAreaInsetsPercentage});
+
+ const wrapperStyle = StyleSheet.flatten(getGrowlWrapper().props.style);
+ if (position) {
+ expect(wrapperStyle).toMatchObject({position: 'absolute', top: 0, bottom: 0, left: 0, right: 0});
+ } else {
+ expect(wrapperStyle).not.toHaveProperty('bottom');
+ }
+ });
+
+ it('keeps the fullscreen bottom overlay transparent to touches outside the toast', () => {
+ // Given a sign-in control and the growl share the app root
+ const onSignIn = jest.fn();
+ const onDismissed = jest.fn();
+ render(
+
+
+
+ ,
+ );
+
+ // When the toast is onscreen, only its children may receive touches through the overlay
+ expect(getGrowlWrapper().props.pointerEvents).toBe('box-none');
+ expect(getAnchoredContainer().props.pointerEvents).toBe('box-none');
+ fireEvent.press(screen.getByRole(CONST.ROLE.BUTTON, {name: 'Sign in'}));
+
+ // Then the underlying control remains independently usable without dismissing the toast
+ expect(onSignIn).toHaveBeenCalledTimes(1);
+ expect(onDismissed).not.toHaveBeenCalled();
+ });
+
+ it.each([undefined, CONST.GROWL.POSITION.BOTTOM_RIGHT])('dismisses position %p by tapping the existing toast', (position: GrowlPosition | undefined) => {
+ // Given a visible toast using either supported anchor
+ const onDismissed = jest.fn();
+ render(
+ ,
+ );
+
+ // When the user taps its text
+ fireEvent.press(screen.getByLabelText(BODY_TEXT));
+ act(() => jest.advanceTimersByTime(0));
+
+ // Then the existing dismissal callback is called for that toast
+ expect(onDismissed).toHaveBeenCalledWith(7);
+ });
+
+ it('automatically dismisses an explicitly bottom-anchored toast after its duration', () => {
+ // Given a bottom toast with a finite display duration
+ const onDismissed = jest.fn();
+ render(
+ ,
+ );
+
+ // When the display duration elapses
+ act(() => jest.advanceTimersByTime(5000));
+
+ // Then the same dismissal path completes without leaving the toast stuck onscreen
+ expect(onDismissed).toHaveBeenCalledWith(9);
+ });
+});