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); + }); +});