diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index ba090918a24d..9910d6b6679e 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -38,7 +38,6 @@ import ConnectionCompletePage from '@pages/ConnectionCompletePage'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import {AttachmentModalContextProvider} from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; import RequireTwoFactorAuthenticationOverlay from '@pages/RequireTwoFactorAuthenticationOverlay'; -import ExpensifyCardContextProvider from '@pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardContextProvider'; import TravelCVVContextProvider from '@pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider'; import * as Modal from '@userActions/Modal'; @@ -174,7 +173,6 @@ function AuthScreens() { FullScreenContextProvider, SearchRouterContextProvider, ProductTrainingContextProvider, - ExpensifyCardContextProvider, TravelCVVContextProvider, KYCWallContextProvider, WideRHPContextProvider, diff --git a/src/libs/RevealedCardSecretsStore.ts b/src/libs/RevealedCardSecretsStore.ts index 44905af581dd..c086b3d9ecec 100644 --- a/src/libs/RevealedCardSecretsStore.ts +++ b/src/libs/RevealedCardSecretsStore.ts @@ -9,14 +9,20 @@ import {useSyncExternalStore} from 'react'; * compliance, so we use module-level stores with useSyncExternalStore for React * integration instead of Onyx. * - * Two independent stores live here: + * Two secret stores live here: * - Physical card PIN — surfaced by the reveal-PIN flow on physical cards. - * - Virtual card details (PAN/expiration/CVV) — surfaced by the SCA reveal - * flow on virtual cards, where the MFA scenario callback runs outside React - * and needs a place to drop the details for the card page to pick up. + * - Virtual card details (PAN/expiration/CVV) — surfaced when revealing a + * virtual card, either through the SCA reveal flow (UK/EU cards, where the + * MFA scenario callback runs outside React) or the magic-code reveal flow + * (US cards). Both drop the details here for the card page to pick up. + * + * A third store tracks the virtual-card reveal loading flag. It is not a secret, + * but it is colocated here because the magic-code reveal request runs on a + * separate screen (the confirm-magic-code RHP) from the card page that renders + * the loading state, so both screens need a shared, non-persisted place for it. * * Each store keeps only one entry at a time so at most one card's secret is in - * memory. The two stores are independent: revealing a PIN does not clear the + * memory. The stores are independent: revealing a PIN does not clear the * virtual-card details and vice versa. */ @@ -65,6 +71,7 @@ function createRevealedSecretStore() { const physicalCardPinStore = createRevealedSecretStore(); const virtualCardDetailsStore = createRevealedSecretStore(); +const virtualCardDetailsLoadingStore = createRevealedSecretStore(); const setRevealedPhysicalCardPin = physicalCardPinStore.set; const clearRevealedPhysicalCardPin = physicalCardPinStore.clear; @@ -81,6 +88,14 @@ const useRevealedVirtualCardDetails = virtualCardDetailsStore.useValue; */ const useAllRevealedVirtualCardDetails = virtualCardDetailsStore.useAll; +const setVirtualCardDetailsLoading = virtualCardDetailsLoadingStore.set; +const clearVirtualCardDetailsLoading = virtualCardDetailsLoadingStore.clear; +/** + * Returns the entire virtual-card reveal-loading map, keyed by cardID. Read inside a + * `.map()` over virtual cards, where calling a per-card hook would violate the Rules of Hooks. + */ +const useAllVirtualCardDetailsLoading = virtualCardDetailsLoadingStore.useAll; + export { setRevealedPhysicalCardPin, clearRevealedPhysicalCardPin, @@ -89,4 +104,7 @@ export { clearRevealedVirtualCardDetails, useRevealedVirtualCardDetails, useAllRevealedVirtualCardDetails, + setVirtualCardDetailsLoading, + clearVirtualCardDetailsLoading, + useAllVirtualCardDetailsLoading, }; diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardContextProvider.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardContextProvider.tsx deleted file mode 100644 index 7fd026cdee15..000000000000 --- a/src/pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardContextProvider.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import useNonPersonalCardList from '@hooks/useNonPersonalCardList'; - -import type {CardList, ExpensifyCardDetails} from '@src/types/onyx/Card'; -import type {Errors} from '@src/types/onyx/OnyxCommon'; -import {getEmptyObject} from '@src/types/utils/EmptyObject'; - -import type {PropsWithChildren} from 'react'; - -import React, {createContext, useContext, useState} from 'react'; - -type ExpensifyCardStateContextType = { - cardsDetails: Record; - isCardDetailsLoading: Record; - cardsDetailsErrors: Record; -}; - -type ExpensifyCardActionsContextType = { - setCardsDetails: React.Dispatch>>; - setIsCardDetailsLoading: React.Dispatch>>; - setCardsDetailsErrors: React.Dispatch>>; -}; - -const defaultActionsContext: ExpensifyCardActionsContextType = { - setCardsDetails: () => {}, - setIsCardDetailsLoading: () => {}, - setCardsDetailsErrors: () => {}, -}; - -const ExpensifyCardStateContext = createContext({ - cardsDetails: {}, - isCardDetailsLoading: {}, - cardsDetailsErrors: {}, -}); - -const ExpensifyCardActionsContext = createContext(defaultActionsContext); - -/** - * Context to display revealed expensify card data and pass it between screens. - */ -function ExpensifyCardContextProvider({children}: PropsWithChildren) { - const cardList = useNonPersonalCardList() ?? getEmptyObject(); - const [cardsDetails, setCardsDetails] = useState>({}); - const [isCardDetailsLoading, setIsCardDetailsLoading] = useState>({}); - const [cardsDetailsErrors, setCardsDetailsErrors] = useState>({}); - - const cardListErrors: Record = {}; - if (cardList) { - for (const cardID of Object.keys(cardList)) { - cardListErrors[cardID] = cardList[cardID]?.errors; - } - } - - // Only show detail errors for cards that still have card list errors. - // When card list errors clear, we stop showing the corresponding detail error without mutating state. - const effectiveCardsDetailsErrors: Record = {}; - for (const cardID of Object.keys(cardsDetailsErrors)) { - const numID = Number(cardID); - const listErrors = cardListErrors[cardID]; - if (listErrors && Object.keys(listErrors).length > 0) { - effectiveCardsDetailsErrors[numID] = cardsDetailsErrors[numID]; - } - } - - // Because of the React Compiler we don't need to memoize it manually - const actionsContextValue: ExpensifyCardActionsContextType = { - setCardsDetails, - setIsCardDetailsLoading, - setCardsDetailsErrors, - }; - - // Because of the React Compiler we don't need to memoize it manually - const stateContextValue: ExpensifyCardStateContextType = { - cardsDetails, - isCardDetailsLoading, - cardsDetailsErrors: effectiveCardsDetailsErrors, - }; - - return ( - - {children} - - ); -} -function useExpensifyCardState(): ExpensifyCardStateContextType { - return useContext(ExpensifyCardStateContext); -} - -function useExpensifyCardActions(): ExpensifyCardActionsContextType { - return useContext(ExpensifyCardActionsContext); -} - -export default ExpensifyCardContextProvider; -export {useExpensifyCardState, useExpensifyCardActions}; diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardVerifyAccountPage.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardVerifyAccountPage.tsx index a3adb6074808..28eb428b00d3 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardVerifyAccountPage.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardVerifyAccountPage.tsx @@ -10,6 +10,7 @@ import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {DomainCardNavigatorParamList, SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {setRevealedVirtualCardDetails, setVirtualCardDetailsLoading} from '@libs/RevealedCardSecretsStore'; import {getNormalizedSubPageValues} from '@pages/MissingPersonalDetails/utils'; @@ -18,13 +19,10 @@ import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {ExpensifyCardDetails} from '@src/types/onyx/Card'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import React, {useState} from 'react'; -import {useExpensifyCardActions} from './ExpensifyCardContextProvider'; - type ExpensifyCardVerifyAccountPageProps = | PlatformStackScreenProps | PlatformStackScreenProps; @@ -34,7 +32,6 @@ function ExpensifyCardVerifyAccountPage({route}: ExpensifyCardVerifyAccountPageP const {translate} = useLocalize(); const [validateError, setValidateError] = useState({}); const primaryLogin = usePrimaryContactMethod(); - const {setIsCardDetailsLoading, setCardsDetails, setCardsDetailsErrors} = useExpensifyCardActions(); const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); @@ -47,31 +44,23 @@ function ExpensifyCardVerifyAccountPage({route}: ExpensifyCardVerifyAccountPageP }; const handleRevealCardDetails = (validateCode: string) => { - setIsCardDetailsLoading((prevState: Record) => ({ - ...prevState, - [cardID]: true, - })); - // We can't store the response in Onyx for security reasons. - // That is why this action is handled manually and the response is stored in a local state. - // Hence eslint disable here. + setVirtualCardDetailsLoading(cardID, true); + // Card secrets (PAN/expiration/CVV) must NOT be persisted to disk for PCI compliance, + // so the revealed details are kept in the in-memory RevealedCardSecretsStore instead of Onyx. const personalDetailsForm = getNormalizedSubPageValues(privatePersonalDetails); const personalDetailsParams = buildSetPersonalDetailsAndShipExpensifyCardsParams(personalDetailsForm, countryCode); setPersonalDetailsAndRevealExpensifyCard(personalDetailsParams, Number.parseInt(cardID, 10), validateCode) .then((value) => { - setCardsDetails((prevState: Record) => ({...prevState, [cardID]: value})); - setCardsDetailsErrors((prevState) => ({ - ...prevState, - [cardID]: '', - })); + setRevealedVirtualCardDetails(cardID, value); navigateBack(); }) .catch((error: TranslationPaths) => { setValidateError(getMicroSecondOnyxErrorWithTranslationKey(error)); }) .finally(() => { - setIsCardDetailsLoading((prevState: Record) => ({...prevState, [cardID]: false})); + setVirtualCardDetailsLoading(cardID, false); }); }; diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx index 3cf189905127..3c921c0efc67 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx @@ -48,7 +48,14 @@ import type {DomainCardNavigatorParamList, SettingsNavigatorParamList} from '@li import {areAddressAndPersonalDetailsMissing} from '@libs/PersonalDetailsUtils'; import {isPolicyAdmin} from '@libs/PolicyUtils'; import {getPolicyExpenseChat} from '@libs/ReportUtils'; -import {clearRevealedPhysicalCardPin, clearRevealedVirtualCardDetails, useAllRevealedVirtualCardDetails, useRevealedPhysicalCardPin} from '@libs/RevealedCardSecretsStore'; +import { + clearRevealedPhysicalCardPin, + clearRevealedVirtualCardDetails, + clearVirtualCardDetailsLoading, + useAllRevealedVirtualCardDetails, + useAllVirtualCardDetailsLoading, + useRevealedPhysicalCardPin, +} from '@libs/RevealedCardSecretsStore'; import {getSpendRuleByCardID, getSpendRuleSummaryText} from '@libs/SpendRulesUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -62,7 +69,6 @@ import variables from '@styles/variables'; import {openOldDotLink} from '@userActions/Link'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; @@ -77,8 +83,6 @@ import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; -import {useExpensifyCardActions, useExpensifyCardState} from './ExpensifyCardContextProvider'; - type ExpensifyCardPageProps = | PlatformStackScreenProps | PlatformStackScreenProps; @@ -141,21 +145,20 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { return virtualCards?.at(0); }, [virtualCards]); - const {cardsDetails, isCardDetailsLoading, cardsDetailsErrors} = useExpensifyCardState(); - const {setCardsDetails} = useExpensifyCardActions(); + const isCardDetailsLoading = useAllVirtualCardDetailsLoading(); const currentPhysicalCard = useMemo(() => physicalCards?.find((card) => String(card?.cardID) === cardID) ?? physicalCards?.at(0), [physicalCards, cardID]); const revealedPIN = useRevealedPhysicalCardPin(String(currentPhysicalCard?.cardID)); - const scaRevealedCardDetails = useAllRevealedVirtualCardDetails(); + const revealedCardDetails = useAllRevealedVirtualCardDetails(); - // Resets card details and revealed PIN when navigating away from the page. + // Resets revealed card secrets (PIN and virtual card details) when navigating away from the page. useFocusEffect( useCallback(() => { return () => { - setCardsDetails((oldCardDetails) => ({...oldCardDetails, [cardID]: null})); clearRevealedPhysicalCardPin(); clearRevealedVirtualCardDetails(); + clearVirtualCardDetailsLoading(); }; - }, [cardID, setCardsDetails]), + }, []), ); const {isAccountLocked} = useLockedAccountState(); @@ -476,9 +479,7 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { )} {virtualCards.map((card) => { - const detailsFromSCA = scaRevealedCardDetails[String(card.cardID)]; - const detailsFromMagicCode = cardsDetails[card.cardID]; - const revealedDetails = detailsFromSCA ?? detailsFromMagicCode; + const revealedDetails = revealedCardDetails[String(card.cardID)]; return ( {!!revealedDetails && revealedDetails.pan ? ( @@ -509,58 +510,51 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { } /> ) : ( - <> - { - if (isAccountLocked) { - showLockedAccountModal(); - return; - } - - if (areAddressAndPersonalDetailsMissing(privatePersonalDetails)) { - Navigation.navigate(ROUTES.MISSING_PERSONAL_DETAILS.getRoute(String(card.cardID))); - return; - } - - if (isUkEuExpensifyCard(card)) { - const personalDetailsForm = getNormalizedSubPageValues(privatePersonalDetails); - const personalDetailsParams = buildSetPersonalDetailsAndShipExpensifyCardsParams(personalDetailsForm, countryCode); - executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.SET_PERSONAL_DETAILS_AND_REVEAL_CARD_DETAILS, { - ...personalDetailsParams, - cardID: String(card.cardID), - }); - return; - } - - if (route.name === SCREENS.DOMAIN_CARD.DOMAIN_CARD_DETAIL) { - Navigation.navigate(ROUTES.SETTINGS_DOMAIN_CARD_CONFIRM_MAGIC_CODE.getRoute(String(card.cardID))); - return; - } - Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAIN_CARD_CONFIRM_MAGIC_CODE.getRoute(String(card.cardID))); - }} - isDisabled={isCardDetailsLoading[card.cardID] || isOffline} - isLoading={isCardDetailsLoading[card.cardID]} - small - /> - ) : undefined - } - /> - - + { + if (isAccountLocked) { + showLockedAccountModal(); + return; + } + + if (areAddressAndPersonalDetailsMissing(privatePersonalDetails)) { + Navigation.navigate(ROUTES.MISSING_PERSONAL_DETAILS.getRoute(String(card.cardID))); + return; + } + + if (isUkEuExpensifyCard(card)) { + const personalDetailsForm = getNormalizedSubPageValues(privatePersonalDetails); + const personalDetailsParams = buildSetPersonalDetailsAndShipExpensifyCardsParams(personalDetailsForm, countryCode); + executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.SET_PERSONAL_DETAILS_AND_REVEAL_CARD_DETAILS, { + ...personalDetailsParams, + cardID: String(card.cardID), + }); + return; + } + + if (route.name === SCREENS.DOMAIN_CARD.DOMAIN_CARD_DETAIL) { + Navigation.navigate(ROUTES.SETTINGS_DOMAIN_CARD_CONFIRM_MAGIC_CODE.getRoute(String(card.cardID))); + return; + } + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAIN_CARD_CONFIRM_MAGIC_CODE.getRoute(String(card.cardID))); + }} + isDisabled={isCardDetailsLoading[card.cardID] || isOffline} + isLoading={isCardDetailsLoading[card.cardID]} + small + /> + ) : undefined + } + /> )} ); @@ -568,34 +562,27 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { {isTravelCard(cardList?.[cardID]) && travelCards.map((card) => ( - {!!cardsDetails[card.cardID] && cardsDetails[card.cardID]?.cvv ? ( - + {revealedCardDetails[String(card.cardID)]?.cvv ? ( + ) : ( - <> - Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAIN_CARD_CONFIRM_MAGIC_CODE.getRoute(String(card.cardID)))} - isDisabled={isCardDetailsLoading[card.cardID] || isOffline} - isLoading={isCardDetailsLoading[card.cardID]} - /> - ) : undefined - } - /> - - + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAIN_CARD_CONFIRM_MAGIC_CODE.getRoute(String(card.cardID)))} + isDisabled={isCardDetailsLoading[card.cardID] || isOffline} + isLoading={isCardDetailsLoading[card.cardID]} + /> + ) : undefined + } + /> )} ))}