diff --git a/CHANGELOG.md b/CHANGELOG.md index d619f314a08..e26ab09eaac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased (develop) +- fixed: The info server's `giftCardInfo` config now reaches the app. It was read from the rollup after `asInfoRollup` had already dropped the field, so remotely disabling a gift card provider did nothing at all. +- fixed: Remotely disabling the Phaze gift card provider now drops the Spend flow straight to Bitrefill instead of into the Phaze scenes. The app no longer builds the Phaze provider, registers a Phaze identity, or fetches its catalog while the provider is disabled. + ## 4.51.0 (staging) - added: Push info-server attestation tokens into edge-core-js via `setAttestationToken` so the login server can skip CAPTCHA for attested devices, and allow `LOGIN_SERVER` / `INFO_SERVER` env overrides for local E2E stacks. diff --git a/src/__tests__/GiftCardActions.test.ts b/src/__tests__/GiftCardActions.test.ts new file mode 100644 index 00000000000..92533b0142e --- /dev/null +++ b/src/__tests__/GiftCardActions.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from '@jest/globals' + +import { pickGiftCardDestination } from '../actions/GiftCardActions' + +describe('pickGiftCardDestination', () => { + test('opens the market when Phaze is available and unused', () => { + expect( + pickGiftCardDestination({ + disablePlugins: {}, + hasPhazeApiKey: true, + hasPhazeOrders: false + }) + ).toBe('giftCardMarket') + }) + + test('opens the list when the account already holds Phaze orders', () => { + expect( + pickGiftCardDestination({ + disablePlugins: {}, + hasPhazeApiKey: true, + hasPhazeOrders: true + }) + ).toBe('giftCardList') + }) + + test('falls back to Bitrefill when Phaze is remotely disabled', () => { + expect( + pickGiftCardDestination({ + disablePlugins: { phaze: true }, + hasPhazeApiKey: true, + hasPhazeOrders: false + }) + ).toBe('bitrefill') + }) + + test('falls back to Bitrefill when there is no Phaze API key', () => { + expect( + pickGiftCardDestination({ + disablePlugins: {}, + hasPhazeApiKey: false, + hasPhazeOrders: false + }) + ).toBe('bitrefill') + }) + + test('a disabled Phaze skips the list scene even with past orders', () => { + expect( + pickGiftCardDestination({ + disablePlugins: { phaze: true }, + hasPhazeApiKey: true, + hasPhazeOrders: true + }) + ).toBe('bitrefill') + }) + + test('a per-brand Phaze disable leaves the provider usable', () => { + expect( + pickGiftCardDestination({ + disablePlugins: { phaze: { '12345': true } }, + hasPhazeApiKey: true, + hasPhazeOrders: false + }) + ).toBe('giftCardMarket') + }) + + test('falls back to the market when both providers are disabled', () => { + expect( + pickGiftCardDestination({ + disablePlugins: { bitrefill: true, phaze: true }, + hasPhazeApiKey: true, + hasPhazeOrders: false + }) + ).toBe('giftCardMarket') + }) +}) diff --git a/src/actions/GiftCardActions.tsx b/src/actions/GiftCardActions.tsx index 0780e78866f..c962c6199a7 100644 --- a/src/actions/GiftCardActions.tsx +++ b/src/actions/GiftCardActions.tsx @@ -1,12 +1,60 @@ +import { guiPlugins } from '../constants/plugins/GuiPlugins' +import { ENV } from '../env' import { hasStoredPhazeIdentity } from '../plugins/gift-cards/phazeGiftCardProvider' import type { ThunkAction } from '../types/reduxTypes' import type { NavigationBase } from '../types/routerTypes' import { showCountrySelectionModal } from './CountryListActions' +import type { NestedDisableMap } from './ExchangeInfoActions' +import { + BITREFILL_PLUGIN_ID, + isGiftCardProviderDisabled, + PHAZE_PLUGIN_ID +} from './GiftCardInfoActions' import { readSyncedSettings } from './SettingsActions' +export type GiftCardDestination = + | 'bitrefill' + | 'giftCardList' + | 'giftCardMarket' + +interface GiftCardDestinationParams { + disablePlugins: NestedDisableMap + hasPhazeApiKey: boolean + /** The account already holds Phaze identities, so it has purchase history. */ + hasPhazeOrders: boolean +} + +/** + * Picks which gift card destination the Spend entry points open. + * + * Phaze backs every gift card scene, so when it is unavailable — no API key, or + * remotely disabled through the info server — Bitrefill is the whole offering + * and the Phaze scenes are skipped entirely. That includes the list scene: it + * polls the Phaze API every ten seconds, which is exactly the traffic a remote + * disable exists to stop. + */ +export const pickGiftCardDestination = ( + params: GiftCardDestinationParams +): GiftCardDestination => { + const { disablePlugins, hasPhazeApiKey, hasPhazeOrders } = params + + if ( + !hasPhazeApiKey || + isGiftCardProviderDisabled(disablePlugins, PHAZE_PLUGIN_ID) + ) { + // Bitrefill can be remotely disabled too. With both providers off there is + // nothing to open, so the market scene shows its unavailable state. + return isGiftCardProviderDisabled(disablePlugins, BITREFILL_PLUGIN_ID) + ? 'giftCardMarket' + : 'bitrefill' + } + + return hasPhazeOrders ? 'giftCardList' : 'giftCardMarket' +} + /** - * Navigates to the appropriate gift card scene (list or market) after ensuring - * a country is selected. Shows a country selection modal if needed. + * Navigates to the appropriate gift card destination after ensuring a country is + * selected. Shows a country selection modal if needed. * * @returns true if navigation occurred, false if user cancelled country selection */ @@ -15,8 +63,27 @@ export const navigateToGiftCards = async (dispatch, getState) => { const state = getState() const { account } = state.core + const { disablePlugins } = state.ui.giftCardInfo let { countryCode } = state.ui.settings + const hasPhazeApiKey = ENV.PLUGIN_API_KEYS?.phaze?.apiKey != null + const destination = pickGiftCardDestination({ + disablePlugins, + hasPhazeApiKey, + hasPhazeOrders: hasPhazeApiKey && (await hasStoredPhazeIdentity(account)) + }) + + // Going through the Phaze scenes to reach Bitrefill would register a Phaze + // identity and fetch a catalog that is thrown away, which is what surfaced + // an error to the user once the provider was remotely disabled. + if (destination === 'bitrefill') { + navigation.navigate('edgeAppStack', { + screen: 'pluginView', + params: { plugin: guiPlugins.bitrefill } + }) + return true + } + // Ensure country is set before proceeding if (countryCode === '') { await dispatch( @@ -36,11 +103,7 @@ export const navigateToGiftCards = return false } - // Navigate to list if user has purchased before, otherwise market - const hasIdentity = await hasStoredPhazeIdentity(account) - navigation.navigate('edgeAppStack', { - screen: hasIdentity ? 'giftCardList' : 'giftCardMarket' - }) + navigation.navigate('edgeAppStack', { screen: destination }) return true } diff --git a/src/actions/GiftCardInfoActions.ts b/src/actions/GiftCardInfoActions.ts index 64fc44ec6c2..0ba84cbc90f 100644 --- a/src/actions/GiftCardInfoActions.ts +++ b/src/actions/GiftCardInfoActions.ts @@ -17,15 +17,25 @@ export const asGiftCardInfo = asObject({ disablePlugins: asMaybe(asNestedDisableMap, () => ({})) }) +// Provider IDs used as keys in the info-server giftCardInfo.disablePlugins map. +// Phaze supports per-brand granularity (keyed by productId); Bitrefill is a +// webview, so only whole-provider disabling applies. +export const PHAZE_PLUGIN_ID = 'phaze' +export const BITREFILL_PLUGIN_ID = 'bitrefill' + export type GiftCardInfo = ReturnType export function updateGiftCardInfo(): ThunkAction> { return async dispatch => { try { - // `giftCardInfo` is a forward-compatible read: the field arrives once the - // edge-info-server dependency that defines it is published and bumped. - // Until then the rollup omits it and we fall back to an empty config. - const rollup = infoServerData.rollup as + // Read `giftCardInfo` from the RAW rollup, not the cleaned one: + // `asInfoRollup` in edge-info-server 3.12.0 has no such key and drops it, + // so the cleaned rollup reports every provider enabled no matter what the + // info server serves. 3.13.0 does define the field, but it also exports an + // attestation module that pulls `jose`'s node build, which Metro cannot + // resolve, so the bump is blocked. This cleaner is ours, so parsing the + // raw payload here needs neither. + const rollup = infoServerData.rollupRaw as | { giftCardInfo?: unknown } | undefined const data = asGiftCardInfo(rollup?.giftCardInfo ?? {}) diff --git a/src/components/scenes/GiftCardMarketScene.tsx b/src/components/scenes/GiftCardMarketScene.tsx index 54eec5f82cb..0eed406cfca 100644 --- a/src/components/scenes/GiftCardMarketScene.tsx +++ b/src/components/scenes/GiftCardMarketScene.tsx @@ -7,8 +7,10 @@ import Animated from 'react-native-reanimated' import { showCountrySelectionModal } from '../../actions/CountryListActions' import { + BITREFILL_PLUGIN_ID, isGiftCardBrandDisabled, - isGiftCardProviderDisabled + isGiftCardProviderDisabled, + PHAZE_PLUGIN_ID } from '../../actions/GiftCardInfoActions' import { readSyncedSettings } from '../../actions/SettingsActions' import { EDGE_CONTENT_SERVER_URI } from '../../constants/CdnConstants' @@ -47,12 +49,6 @@ type ViewMode = 'grid' | 'list' // Internal constant for "All" category comparison - display uses lstrings.string_all const CATEGORY_ALL = 'All' -// Provider IDs used as keys in the info-server giftCardInfo.disablePlugins map. -// Phaze supports per-brand granularity (keyed by productId); Bitrefill is a -// webview, so only whole-provider disabling applies. -const PHAZE_PLUGIN_ID = 'phaze' -const BITREFILL_PLUGIN_ID = 'bitrefill' - /** * Formats a normalized category for display: * - Replaces dashes with " & " @@ -125,12 +121,27 @@ export const GiftCardMarketScene: React.FC = props => { state => state.ui.giftCardInfo.disablePlugins ) - // Provider (requires API key configured) + // Provider config (the Phaze scenes need an API key to do anything) const phazeConfig = ENV.PLUGIN_API_KEYS?.phaze + + // Phaze is the catalog behind this scene. While it is off the scene is a + // Bitrefill shortcut, so skip the provider (which would register a Phaze + // identity) and every query that feeds off it. This matches the condition + // pickGiftCardDestination routes on, so a build with no key lands here in the + // same state a remote disable produces rather than on a permanent loader. + const isPhazeOff = + phazeConfig?.apiKey == null || + isGiftCardProviderDisabled(giftCardDisablePlugins, PHAZE_PLUGIN_ID) + const isBitrefillDisabled = isGiftCardProviderDisabled( + giftCardDisablePlugins, + BITREFILL_PLUGIN_ID + ) + const { provider, isReady } = useGiftCardProvider({ account, apiKey: phazeConfig?.apiKey ?? '', - baseUrl: phazeConfig?.baseUrl ?? '' + baseUrl: phazeConfig?.baseUrl ?? '', + enabled: !isPhazeOff }) // Cache for gift card brands (accessed via provider) @@ -269,7 +280,12 @@ export const GiftCardMarketScene: React.FC = props => { return allBrands }, - enabled: isConnected && isReady && provider != null && countryCode !== '', + enabled: + isConnected && + isReady && + provider != null && + countryCode !== '' && + !isPhazeOff, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, retry: 1 @@ -282,11 +298,18 @@ export const GiftCardMarketScene: React.FC = props => { } }, [apiBrands, updateFromBrands]) + // A disabled Phaze is never queried, so its half of the market is a settled + // empty list rather than one that is still loading or failed: + const phazeItems = React.useMemo( + () => (isPhazeOff ? [] : items), + [isPhazeOff, items] + ) + // Remove phaze brands disabled by the info server, either because the whole // phaze provider is disabled or because the brand's productId is listed. const enabledItems = React.useMemo(() => { - if (items == null) return null - return items.filter( + if (phazeItems == null) return null + return phazeItems.filter( item => !isGiftCardBrandDisabled( giftCardDisablePlugins, @@ -294,7 +317,7 @@ export const GiftCardMarketScene: React.FC = props => { String(item.productId) ) ) - }, [items, giftCardDisablePlugins]) + }, [phazeItems, giftCardDisablePlugins]) // Build the category list from the enabled items only, so a category whose // brands are all disabled by the info server does not show a chip that leads @@ -498,13 +521,14 @@ export const GiftCardMarketScene: React.FC = props => { // Bitrefill provider is remotely disabled) const listData = React.useMemo(() => { const base = filteredItems ?? [] - if ( - isGiftCardProviderDisabled(giftCardDisablePlugins, BITREFILL_PLUGIN_ID) - ) { - return base - } + if (isBitrefillDisabled) return base return [...base, BITREFILL_ITEM] - }, [filteredItems, giftCardDisablePlugins]) + }, [filteredItems, isBitrefillDisabled]) + + // Both providers off leaves nothing to browse, which is a different state + // from a search that matched nothing: + const hasNoProviders = + isBitrefillDisabled && enabledItems != null && enabledItems.length === 0 return ( = props => { headerTitle={lstrings.title_gift_card_market} headerTitleChildren={} > - {items == null && isBrandsError ? ( + {phazeItems == null && isBrandsError ? ( = props => { : lstrings.gift_card_network_error } /> - ) : items == null ? ( + ) : phazeItems == null ? ( + ) : hasNoProviders ? ( + ) : ( <> diff --git a/src/components/scenes/HomeScene.tsx b/src/components/scenes/HomeScene.tsx index e8b141579e3..e9e31a7ba13 100644 --- a/src/components/scenes/HomeScene.tsx +++ b/src/components/scenes/HomeScene.tsx @@ -6,8 +6,11 @@ import Animated from 'react-native-reanimated' import { useSafeAreaFrame } from 'react-native-safe-area-context' import { navigateToGiftCards } from '../../actions/GiftCardActions' +import { + isGiftCardProviderDisabled, + PHAZE_PLUGIN_ID +} from '../../actions/GiftCardInfoActions' import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' -import { guiPlugins } from '../../constants/plugins/GuiPlugins' import { ENV } from '../../env' import { useHandler } from '../../hooks/useHandler' import { lstrings } from '../../locales/strings' @@ -87,6 +90,15 @@ export const HomeScene: React.FC = props => { const dispatch = useDispatch() const countryCode = useSelector(state => state.ui.countryCode) + const giftCardDisablePlugins = useSelector( + state => state.ui.giftCardInfo.disablePlugins + ) + + // The Phaze catalog is what makes this tile more than a Bitrefill shortcut, + // so the footer describes Bitrefill alone whenever Phaze is unavailable: + const isPhazeAvailable = + ENV.PLUGIN_API_KEYS?.phaze?.apiKey != null && + !isGiftCardProviderDisabled(giftCardDisablePlugins, PHAZE_PLUGIN_ID) const { width: screenWidth } = useSafeAreaFrame() @@ -115,11 +127,7 @@ export const HomeScene: React.FC = props => { navigation.navigate('swapTab') }) const handleSpendPress = useHandler(async () => { - // If Phaze API key is not configured, go directly to Bitrefill - if (ENV.PLUGIN_API_KEYS?.phaze?.apiKey == null) { - navigation.navigate('pluginView', { plugin: guiPlugins.bitrefill }) - return - } + // navigateToGiftCards owns the Bitrefill fallback for an unavailable Phaze await dispatch(navigateToGiftCards(navigation as NavigationBase)) }) const handleViewAssetsPress = useHandler(() => { @@ -281,9 +289,9 @@ export const HomeScene: React.FC = props => { => { // Start the background attestation engine at boot (best-effort, non-blocking) @@ -160,6 +171,7 @@ export const initInfoServer = async (): Promise => { ) } else { const infoData = await response.json() + infoServerData.rollupRaw = infoData infoServerData.rollup = asInfoRollup(infoData) await runOnce('checkAppVersion', checkAppVersion) }