diff --git a/backend/handlers/handlers.go b/backend/handlers/handlers.go index 3da4d5877a..2f22e89b16 100644 --- a/backend/handlers/handlers.go +++ b/backend/handlers/handlers.go @@ -171,7 +171,7 @@ func NewHandlers( WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { return true }, }, - log: logging.Get().WithGroup("handlers"), + log: log, } getAPIRouter := func(subrouter *mux.Router) func(string, func(*http.Request) (interface{}, error)) *mux.Route { @@ -200,10 +200,10 @@ func NewHandlers( getAPIRouterNoError(apiRouter)("/qr", handlers.getQRCode).Methods("GET") getAPIRouterNoError(apiRouter)("/config", handlers.getAppConfig).Methods("GET") getAPIRouterNoError(apiRouter)("/config/default", handlers.getDefaultConfig).Methods("GET") - getAPIRouter(apiRouter)("/config", handlers.postAppConfig).Methods("POST") + getAPIRouterNoError(apiRouter)("/config", handlers.postAppConfig).Methods("POST") getAPIRouterNoError(apiRouter)("/native-locale", handlers.getNativeLocale).Methods("GET") getAPIRouter(apiRouter)("/notify-user", handlers.postNotify).Methods("POST") - getAPIRouter(apiRouter)("/open", handlers.postOpen).Methods("POST") + getAPIRouterNoError(apiRouter)("/open", handlers.postOpen).Methods("POST") getAPIRouterNoError(apiRouter)("/update", handlers.getUpdate).Methods("GET") getAPIRouterNoError(apiRouter)("/banners/{key}", handlers.getBanners).Methods("GET") getAPIRouterNoError(apiRouter)("/using-mobile-data", handlers.getUsingMobileData).Methods("GET") @@ -220,7 +220,7 @@ func NewHandlers( getAPIRouterNoError(apiRouter)("/accounts", handlers.getAccounts).Methods("GET") getAPIRouterNoError(apiRouter)("/swap/accounts", handlers.getSwapAccounts).Methods("GET") getAPIRouterNoError(apiRouter)("/swap/status", handlers.getSwapStatus).Methods("GET") - getAPIRouter(apiRouter)("/accounts/balance-summary", handlers.getAccountsBalanceSummary).Methods("GET") + getAPIRouterNoError(apiRouter)("/accounts/balance-summary", handlers.getAccountsBalanceSummary).Methods("GET") getAPIRouterNoError(apiRouter)("/set-account-active", handlers.postSetAccountActive).Methods("POST") getAPIRouterNoError(apiRouter)("/set-token-active", handlers.postSetTokenActive).Methods("POST") getAPIRouterNoError(apiRouter)("/set-account-receive-script-type", handlers.postSetAccountReceiveScriptType).Methods("POST") @@ -248,7 +248,7 @@ func NewHandlers( getAPIRouterNoError(apiRouter)("/market/btcdirect/info/{action}/{code}", handlers.getMarketBtcDirectInfo).Methods("GET") getAPIRouterNoError(apiRouter)("/swap/quote", handlers.postSwapkitQuote).Methods("POST") getAPIRouterNoError(apiRouter)("/swap/sign", handlers.postSwapSign).Methods("POST") - getAPIRouter(apiRouter)("/market/moonpay/buy-info/{code}", handlers.getMarketMoonpayBuyInfo).Methods("GET") + getAPIRouterNoError(apiRouter)("/market/moonpay/buy-info/{code}", handlers.getMarketMoonpayBuyInfo).Methods("GET") getAPIRouterNoError(apiRouter)("/market/pocket/api-url/{action}", handlers.getMarketPocketURL).Methods("GET") getAPIRouterNoError(apiRouter)("/market/pocket/verify-address", handlers.postPocketWidgetVerifyAddress).Methods("POST") getAPIRouterNoError(apiRouter)("/market/bitrefill/info/{action}/{code}", handlers.getMarketBitrefillInfo).Methods("GET") @@ -541,12 +541,22 @@ func (handlers *Handlers) getDefaultConfig(*http.Request) interface{} { return handlers.backend.DefaultAppConfig() } -func (handlers *Handlers) postAppConfig(r *http.Request) (interface{}, error) { +func (handlers *Handlers) postAppConfig(r *http.Request) interface{} { + type response struct { + Success bool `json:"success"` + ErrorMessage string `json:"errorMessage,omitempty"` + } + appConfig := config.AppConfig{} if err := json.NewDecoder(r.Body).Decode(&appConfig); err != nil { - return nil, errp.WithStack(err) + handlers.log.WithField("handler", "postAppConfig").WithError(err).Error("handler failed") + return response{Success: false, ErrorMessage: err.Error()} + } + if err := handlers.backend.Config().SetAppConfig(appConfig); err != nil { + handlers.log.WithField("handler", "postAppConfig").WithError(err).Error("handler failed") + return response{Success: false, ErrorMessage: err.Error()} } - return nil, handlers.backend.Config().SetAppConfig(appConfig) + return response{Success: true} } // getNativeLocaleHandler returns user preferred UI language as reported @@ -567,12 +577,22 @@ func (handlers *Handlers) postNotify(r *http.Request) (interface{}, error) { return nil, nil } -func (handlers *Handlers) postOpen(r *http.Request) (interface{}, error) { +func (handlers *Handlers) postOpen(r *http.Request) interface{} { + type response struct { + Success bool `json:"success"` + ErrorMessage string `json:"errorMessage,omitempty"` + } + var url string if err := json.NewDecoder(r.Body).Decode(&url); err != nil { - return nil, errp.WithStack(err) + handlers.log.WithField("handler", "postOpen").WithError(err).Error("handler failed") + return response{Success: false, ErrorMessage: err.Error()} } - return nil, handlers.backend.SystemOpen(url) + if err := handlers.backend.SystemOpen(url); err != nil { + handlers.log.WithField("handler", "postOpen").WithError(err).Error("handler failed") + return response{Success: false, ErrorMessage: err.Error()} + } + return response{Success: true} } func (handlers *Handlers) getUpdate(*http.Request) interface{} { @@ -870,7 +890,7 @@ func (handlers *Handlers) postBtcFormatUnit(r *http.Request) interface{} { } // getAccountsBalanceSummary returns the total balance summary of all coins and accounts. -func (handlers *Handlers) getAccountsBalanceSummary(*http.Request) (interface{}, error) { +func (handlers *Handlers) getAccountsBalanceSummary(*http.Request) interface{} { type response struct { Success bool `json:"success"` TotalBalance *backend.AccountsBalanceSummary `json:"accountsBalanceSummary"` @@ -878,9 +898,10 @@ func (handlers *Handlers) getAccountsBalanceSummary(*http.Request) (interface{}, totalBalance, err := handlers.backend.AccountsBalanceSummary() if err != nil { - return response{Success: false}, nil + handlers.log.WithField("handler", "getAccountsBalanceSummary").WithError(err).Error("handler failed") + return response{Success: false} } - return response{Success: true, TotalBalance: totalBalance}, nil + return response{Success: true, TotalBalance: totalBalance} } func (handlers *Handlers) postSetAccountActive(r *http.Request) interface{} { @@ -1452,10 +1473,18 @@ func (handlers *Handlers) getMarketVendors(r *http.Request) interface{} { return supported } -func (handlers *Handlers) getMarketMoonpayBuyInfo(r *http.Request) (interface{}, error) { +func (handlers *Handlers) getMarketMoonpayBuyInfo(r *http.Request) interface{} { + type result struct { + Success bool `json:"success"` + ErrorMessage string `json:"errorMessage,omitempty"` + URL string `json:"url,omitempty"` + Address string `json:"address,omitempty"` + } + acct, err := handlers.backend.GetAccountFromCode(accountsTypes.Code(mux.Vars(r)["code"])) if err != nil { - return nil, err + handlers.log.WithField("handler", "getMarketMoonpayBuyInfo").WithError(err).Error("handler failed") + return result{Success: false, ErrorMessage: err.Error()} } lang := handlers.backend.Config().AppConfig().Backend.UserLanguage @@ -1470,16 +1499,14 @@ func (handlers *Handlers) getMarketMoonpayBuyInfo(r *http.Request) (interface{}, } buy, err := market.MoonpayInfo(acct, params) if err != nil { - return nil, err + handlers.log.WithField("handler", "getMarketMoonpayBuyInfo").WithError(err).Error("handler failed") + return result{Success: false, ErrorMessage: err.Error()} } - resp := struct { - URL string `json:"url"` - Address string `json:"address"` - }{ + return result{ + Success: true, URL: buy.URL, Address: buy.Address, } - return resp, nil } func (handlers *Handlers) getMarketBtcDirectInfo(r *http.Request) interface{} { diff --git a/frontends/web/src/api/market.ts b/frontends/web/src/api/market.ts index 96831b2e70..9f431b2c72 100644 --- a/frontends/web/src/api/market.ts +++ b/frontends/web/src/api/market.ts @@ -48,8 +48,12 @@ export const getMarketDeals = ( }; export type MoonpayBuyInfo = { + success: true; url: string; address: string; +} | { + success: false; + errorMessage?: string; }; export const getMoonpayBuyInfo = (code: AccountCode) => { diff --git a/frontends/web/src/api/system.ts b/frontends/web/src/api/system.ts index 4a4ae0166e..b24347cc43 100644 --- a/frontends/web/src/api/system.ts +++ b/frontends/web/src/api/system.ts @@ -6,6 +6,13 @@ export const notifyUser = (text: string) => { return apiPost('notify-user', { text }); }; -export const open = (href: string) => { +type TOpenResponse = { + success: true; +} | { + success: false; + errorMessage?: string; +}; + +export const open = (href: string): Promise => { return apiPost('open', href); }; diff --git a/frontends/web/src/components/anchor/anchor.tsx b/frontends/web/src/components/anchor/anchor.tsx index 8d5363217e..f3dac6b265 100644 --- a/frontends/web/src/components/anchor/anchor.tsx +++ b/frontends/web/src/components/anchor/anchor.tsx @@ -1,7 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 import { ReactNode, SyntheticEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { open } from '@/api/system'; +import { alertUser } from '@/components/alert/Alert'; import { runningInIOS } from '@/utils/env'; import style from './anchor.module.css'; @@ -32,6 +34,8 @@ export const A = ({ children, ...props }: TProps) => { + const { t } = useTranslation(); + return ( { e.preventDefault(); - open(href).catch(console.error); + open(href) + .then(response => { + if (!response.success) { + alertUser(response.errorMessage + ? t('unknownError', { errorMessage: response.errorMessage }) + : t('genericError')); + } + }) + .catch(console.error); }} tabIndex={0} {...props}> diff --git a/frontends/web/src/routes/market/moonpay.test.tsx b/frontends/web/src/routes/market/moonpay.test.tsx new file mode 100644 index 0000000000..284af6c677 --- /dev/null +++ b/frontends/web/src/routes/market/moonpay.test.tsx @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 + +import '../../../__mocks__/i18n'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/components/layout', () => ({ + Header: ({ title }: { title: ReactNode }) =>
{title}
, +})); +vi.mock('@/components/spinner/Spinner', () => ({ + Spinner: ({ text }: { text?: string }) =>
{text}
, +})); +vi.mock('@/hooks/backbutton', () => ({ + UseDisableBackButton: () => null, +})); +vi.mock('@/hooks/darkmode', () => ({ + useDarkmode: () => ({ isDarkMode: false, toggleDarkmode: vi.fn() }), +})); +vi.mock('@/hooks/vendor-iframe', () => ({ + useVendorIframeResizeHeight: () => ({ + containerRef: { current: null }, + height: 480, + iframeLoaded: false, + onIframeLoad: vi.fn(), + }), + useVendorTerms: () => ({ + agreedTerms: true, + setAgreedTerms: vi.fn(), + }), +})); +vi.mock('./guide', () => ({ + MarketGuide: () => null, +})); +vi.mock('@/api/market', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getMoonpayBuyInfo: vi.fn(), + }; +}); +vi.mock('@/utils/config', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getConfig: vi.fn(), + }; +}); + +import { render, screen } from '@testing-library/react'; +import type { TAccount } from '@/api/account'; +import * as marketApi from '@/api/market'; +import * as config from '@/utils/config'; +import { Moonpay } from './moonpay'; + +const account: TAccount = { + keystore: { + connected: true, + lastConnected: '', + name: 'BitBox02', + rootFingerprint: 'f23ab988', + watchonly: false, + }, + active: true, + blockExplorerTxPrefix: '', + code: 'btc-account', + coinCode: 'btc', + coinName: 'Bitcoin', + coinUnit: 'BTC', + isToken: false, + name: 'Bitcoin Account', +}; + +describe('routes/market/moonpay', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(config.getConfig).mockResolvedValue({ + frontend: { + skipMoonpayDisclaimer: true, + }, + }); + }); + + it('renders the MoonPay iframe on success', async () => { + vi.mocked(marketApi.getMoonpayBuyInfo).mockReturnValue(() => Promise.resolve({ + success: true, + url: 'https://buy.moonpay.com?walletAddress=bc1qexample', + address: 'bc1qexample', + })); + + render(); + + const iframe = await screen.findByTitle('Moonpay'); + expect(iframe).toHaveAttribute( + 'src', + 'https://buy.moonpay.com?walletAddress=bc1qexample&colorCode=%235E94BF&theme=light', + ); + }); + + it('renders an error message on failure', async () => { + vi.mocked(marketApi.getMoonpayBuyInfo).mockReturnValue(() => Promise.resolve({ + success: false, + errorMessage: 'Account is not valid.', + })); + + render(); + + expect(await screen.findByText('Account is not valid.')).toBeInTheDocument(); + expect(screen.queryByTitle('Moonpay')).not.toBeInTheDocument(); + }); +}); diff --git a/frontends/web/src/routes/market/moonpay.tsx b/frontends/web/src/routes/market/moonpay.tsx index fb22077083..e617e712f5 100644 --- a/frontends/web/src/routes/market/moonpay.tsx +++ b/frontends/web/src/routes/market/moonpay.tsx @@ -9,6 +9,7 @@ import { getConfig } from '@/utils/config'; import { getMoonpayBuyInfo } from '@/api/market'; import { MarketGuide } from './guide'; import { Header } from '@/components/layout'; +import { Message } from '@/components/message/message'; import { Spinner } from '@/components/spinner/Spinner'; import { findAccount, isBitcoinOnly } from '@/routes/account/utils'; import { MoonpayTerms } from '@/components/terms/moonpay-terms'; @@ -58,8 +59,8 @@ export const Moonpay = ({ accounts, code }: TProps) => { ) : (
- {!iframeLoaded && } - { moonpay && ( + {(!moonpay || (moonpay.success && !iframeLoaded)) && } + { moonpay?.success && ( )} + { moonpay?.success === false && ( + + {moonpay.errorMessage || t('genericError')} + + )}
)} diff --git a/frontends/web/src/utils/config.ts b/frontends/web/src/utils/config.ts index 71e2cb2afc..5fb3acedb4 100644 --- a/frontends/web/src/utils/config.ts +++ b/frontends/web/src/utils/config.ts @@ -1,12 +1,20 @@ // SPDX-License-Identifier: Apache-2.0 import { apiGet, apiPost } from '@/utils/request'; +import { runningInQtWebEngine, runningOnMobile } from '@/utils/env'; type TConfig = { backend?: unknown; frontend?: unknown; }; +type TSetConfigResponse = null | undefined | { + success: true; +} | { + success: false; + errorMessage?: string; +}; + let pendingConfig: TConfig = {}; /** @@ -32,7 +40,10 @@ export const setConfig = (object: TConfig) => { }); pendingConfig = nextConfig; return apiPost('config', nextConfig) - .then(() => { + .then((response: TSetConfigResponse) => { + if (response?.success === false && !runningInQtWebEngine() && !runningOnMobile()) { + throw new Error(response.errorMessage || 'Failed to update configuration'); + } pendingConfig = {}; return nextConfig; });