diff --git a/packages/demobank-ui/package.json b/packages/demobank-ui/package.json index 17059afeb..316c816e7 100644 --- a/packages/demobank-ui/package.json +++ b/packages/demobank-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/demobank-ui", - "version": "0.9.3-dev.27", + "version": "0.9.3-dev.29", "license": "AGPL-3.0-OR-LATER", "type": "module", "scripts": { diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts index e30cbcb54..65a19959a 100644 --- a/packages/taler-harness/src/harness/harness.ts +++ b/packages/taler-harness/src/harness/harness.ts @@ -1652,9 +1652,6 @@ export class MerchantService implements MerchantServiceInterface { const body: MerchantInstanceConfig = { auth, - accounts: instanceConfig.paytoUris.map((x) => ({ - payto_uri: x, - })), id: instanceConfig.id, name: instanceConfig.name, address: instanceConfig.address ?? {}, diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts index 82d8e4326..f06a66a21 100644 --- a/packages/taler-harness/src/index.ts +++ b/packages/taler-harness/src/index.ts @@ -28,9 +28,17 @@ import { MerchantApiClient, rsaBlind, setGlobalLogLevelFromString, + RegisterAccountRequest, + HttpStatusCode, + MerchantInstanceConfig, + Duration, + generateIban, } from "@gnu-taler/taler-util"; import { clk } from "@gnu-taler/taler-util/clk"; -import { createPlatformHttpLib } from "@gnu-taler/taler-util/http"; +import { + HttpResponse, + createPlatformHttpLib, +} from "@gnu-taler/taler-util/http"; import { CryptoDispatcher, downloadExchangeInfo, @@ -46,7 +54,11 @@ import { runBench2 } from "./bench2.js"; import { runBench3 } from "./bench3.js"; import { runEnvFull } from "./env-full.js"; import { runEnv1 } from "./env1.js"; -import { GlobalTestState, runTestWithState } from "./harness/harness.js"; +import { + GlobalTestState, + delayMs, + runTestWithState, +} from "./harness/harness.js"; import { getTestInfo, runTests } from "./integrationtests/testrunner.js"; import { lintExchangeDeployment } from "./lint.js"; @@ -312,8 +324,7 @@ deploymentCli const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http); await topupReserveWithDemobank({ amount: "KUDOS:10", - corebankApiBaseUrl: - "https://bank.demo.taler.net/", + corebankApiBaseUrl: "https://bank.demo.taler.net/", exchangeInfo, http, reservePub: reserveKeyPair.pub, @@ -341,8 +352,7 @@ deploymentCli const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http); await topupReserveWithDemobank({ amount: "TESTKUDOS:10", - corebankApiBaseUrl: - "https://bank.test.taler.net/", + corebankApiBaseUrl: "https://bank.test.taler.net/", exchangeInfo, http, reservePub: reserveKeyPair.pub, @@ -422,6 +432,224 @@ deploymentCli ); }); +deploymentCli + .subcommand("waitService", "wait-taler-service", { + help: "Wait for the config endpoint of a Taler-style service to be available", + }) + .requiredArgument("serviceName", clk.STRING) + .requiredArgument("serviceConfigUrl", clk.STRING) + .action(async (args) => { + const serviceName = args.waitService.serviceName; + const serviceUrl = args.waitService.serviceConfigUrl; + console.log( + `Waiting for service ${serviceName} to be ready at ${serviceUrl}`, + ); + const httpLib = createPlatformHttpLib(); + while (1) { + console.log(`Fetching ${serviceUrl}`); + let resp: HttpResponse; + try { + resp = await httpLib.fetch(serviceUrl); + } catch (e) { + console.log( + `Got network error for service ${serviceName} at ${serviceUrl}`, + ); + await delayMs(1000); + continue; + } + if (resp.status != 200) { + console.log( + `Got unexpected status ${resp.status} for service at ${serviceUrl}`, + ); + await delayMs(1000); + continue; + } + let respJson: any; + try { + respJson = await resp.json(); + } catch (e) { + console.log( + `Got json error for service ${serviceName} at ${serviceUrl}`, + ); + await delayMs(1000); + continue; + } + const recServiceName = respJson.name; + console.log(`Got name ${recServiceName}`); + if (recServiceName != serviceName) { + console.log(`A different service is still running at ${serviceUrl}`); + await delayMs(1000); + continue; + } + console.log(`service ${serviceName} at ${serviceUrl} is now available`); + return; + } + }); + +deploymentCli + .subcommand("waitEndpoint", "wait-endpoint", { + help: "Wait for an endpoint to return an HTTP 200 Ok status with JSON body", + }) + .requiredArgument("serviceEndpoint", clk.STRING) + .action(async (args) => { + const serviceUrl = args.waitEndpoint.serviceEndpoint; + console.log(`Waiting for endpoint ${serviceUrl} to be ready`); + const httpLib = createPlatformHttpLib(); + while (1) { + console.log(`Fetching ${serviceUrl}`); + let resp: HttpResponse; + try { + resp = await httpLib.fetch(serviceUrl); + } catch (e) { + console.log(`Got network error for service at ${serviceUrl}`); + await delayMs(1000); + continue; + } + if (resp.status != 200) { + console.log( + `Got unexpected status ${resp.status} for service at ${serviceUrl}`, + ); + await delayMs(1000); + continue; + } + let respJson: any; + try { + respJson = await resp.json(); + } catch (e) { + console.log(`Got json error for service at ${serviceUrl}`); + await delayMs(1000); + continue; + } + return; + } + }); + +deploymentCli + .subcommand("genIban", "gen-iban", { + help: "Generate a random IBAN.", + }) + .requiredArgument("countryCode", clk.STRING) + .requiredArgument("length", clk.INT) + .action(async (args) => { + console.log(generateIban(args.genIban.countryCode, args.genIban.length)); + }); + +deploymentCli + .subcommand("provisionMerchantInstance", "provision-merchant-instance", { + help: "Provision a merchant backend instance.", + }) + .requiredArgument("merchantApiBaseUrl", clk.STRING) + .requiredOption("managementToken", ["--management-token"], clk.STRING) + .requiredOption("instanceToken", ["--instance-token"], clk.STRING) + .requiredOption("name", ["--name"], clk.STRING) + .requiredOption("id", ["--id"], clk.STRING) + .requiredOption("payto", ["--payto"], clk.STRING) + .action(async (args) => { + const httpLib = createPlatformHttpLib(); + const baseUrl = args.provisionMerchantInstance.merchantApiBaseUrl; + const managementToken = args.provisionMerchantInstance.managementToken; + const instanceToken = args.provisionMerchantInstance.instanceToken; + const instanceId = args.provisionMerchantInstance.id; + const body: MerchantInstanceConfig = { + address: {}, + auth: { + method: "token", + token: args.provisionMerchantInstance.instanceToken, + }, + default_pay_delay: Duration.toTalerProtocolDuration( + Duration.fromSpec({ hours: 1 }), + ), + default_wire_transfer_delay: { d_us: 1 }, + id: instanceId, + jurisdiction: {}, + name: args.provisionMerchantInstance.name, + use_stefan: true, + }; + const url = new URL("management/instances", baseUrl); + const createResp = await httpLib.fetch(url.href, { + method: "POST", + body, + headers: { + Authorization: `Bearer ${managementToken}`, + }, + }); + if (createResp.status >= 200 && createResp.status <= 299) { + logger.info(`instance ${instanceId} created successfully`); + } else if (createResp.status === HttpStatusCode.Conflict) { + logger.info(`instance ${instanceId} already exists`); + } else { + logger.error( + `unable to create instance ${instanceId}, HTTP status ${createResp.status}`, + ); + } + + const accountsUrl = new URL( + `instances/${instanceId}/private/accounts`, + baseUrl, + ); + const accountBody = { + payto_uri: args.provisionMerchantInstance.payto, + }; + const createAccountResp = await httpLib.fetch(accountsUrl.href, { + method: "POST", + body: accountBody, + headers: { + Authorization: `Bearer ${instanceToken}`, + }, + }); + if (createAccountResp.status != 200) { + console.error( + `unable to configure bank account for instance ${instanceId}, status ${createAccountResp.status}`, + ); + const resp = await createAccountResp.json(); + console.error(j2s(resp)); + process.exit(2); + } + logger.info(`successfully configured bank account for ${instanceId}`); + }); + +deploymentCli + .subcommand("provisionBankAccount", "provision-bank-account", { + help: "Provision a corebank account.", + }) + .requiredArgument("corebankApiBaseUrl", clk.STRING) + .flag("exchange", ["--exchange"]) + .flag("public", ["--public"]) + .requiredOption("login", ["--login"], clk.STRING) + .requiredOption("name", ["--name"], clk.STRING) + .requiredOption("password", ["--password"], clk.STRING) + .maybeOption("internalPayto", ["--payto"], clk.STRING) + .action(async (args) => { + const httpLib = createPlatformHttpLib(); + const corebankApiBaseUrl = args.provisionBankAccount.corebankApiBaseUrl; + const url = new URL("accounts", corebankApiBaseUrl); + const accountLogin = args.provisionBankAccount.login; + const body: RegisterAccountRequest = { + name: args.provisionBankAccount.name, + password: args.provisionBankAccount.password, + username: accountLogin, + is_public: !!args.provisionBankAccount.public, + is_taler_exchange: !!args.provisionBankAccount.exchange, + internal_payto_uri: args.provisionBankAccount.internalPayto, + }; + const resp = await httpLib.fetch(url.href, { + method: "POST", + body, + }); + if (resp.status >= 200 && resp.status <= 299) { + logger.info(`account ${accountLogin} successfully provisioned`); + return; + } + if (resp.status === HttpStatusCode.Conflict) { + logger.info(`account ${accountLogin} already provisioned`); + return; + } + logger.error( + `unable to provision bank account, HTTP response status ${resp.status}`, + ); + process.exit(2); + }); + deploymentCli .subcommand("coincfg", "gen-coin-config", { help: "Generate a coin/denomination configuration for the exchange.", diff --git a/packages/taler-harness/src/integrationtests/test-merchant-instances-urls.ts b/packages/taler-harness/src/integrationtests/test-merchant-instances-urls.ts index a037a01c5..7236436ac 100644 --- a/packages/taler-harness/src/integrationtests/test-merchant-instances-urls.ts +++ b/packages/taler-harness/src/integrationtests/test-merchant-instances-urls.ts @@ -72,11 +72,6 @@ export async function runMerchantInstancesUrlsTest(t: GlobalTestState) { ), jurisdiction: {}, name: "My Default Instance", - accounts: [ - { - payto_uri: generateRandomPayto("bar"), - }, - ], auth: { method: "token", token: "secret-token:i-am-default", @@ -95,11 +90,6 @@ export async function runMerchantInstancesUrlsTest(t: GlobalTestState) { ), jurisdiction: {}, name: "My Second Instance", - accounts: [ - { - payto_uri: generateRandomPayto("bar"), - }, - ], auth: { method: "token", token: "secret-token:i-am-myinst", diff --git a/packages/taler-util/src/MerchantApiClient.ts b/packages/taler-util/src/MerchantApiClient.ts index 988872ae7..2e10e394a 100644 --- a/packages/taler-util/src/MerchantApiClient.ts +++ b/packages/taler-util/src/MerchantApiClient.ts @@ -74,18 +74,6 @@ export interface DeleteTippingReserveArgs { purge?: boolean; } -export interface MerchantInstanceConfig { - accounts: MerchantBankAccount[]; - auth: MerchantAuthConfiguration; - id: string; - name: string; - address: unknown; - jurisdiction: unknown; - use_stefan: boolean; - default_wire_transfer_delay: TalerProtocolDuration; - default_pay_delay: TalerProtocolDuration; -} - interface MerchantBankAccount { // The payto:// URI where the wallet will send coins. payto_uri: string; @@ -102,7 +90,6 @@ interface MerchantBankAccount { } export interface MerchantInstanceConfig { - accounts: MerchantBankAccount[]; auth: MerchantAuthConfiguration; id: string; name: string; diff --git a/packages/taler-wallet-webextension/package.json b/packages/taler-wallet-webextension/package.json index fc91ed4cc..65784bd46 100644 --- a/packages/taler-wallet-webextension/package.json +++ b/packages/taler-wallet-webextension/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-webextension", - "version": "0.9.3-dev.27", + "version": "0.9.3-dev.31", "description": "GNU Taler Wallet browser extension", "main": "./build/index.js", "types": "./build/index.d.ts", @@ -75,4 +75,4 @@ "pogen": { "domain": "taler-wallet-webex" } -} \ No newline at end of file +} diff --git a/packages/taler-wallet-webextension/src/mui/handlers.ts b/packages/taler-wallet-webextension/src/mui/handlers.ts index ece1b3d85..735e8523f 100644 --- a/packages/taler-wallet-webextension/src/mui/handlers.ts +++ b/packages/taler-wallet-webextension/src/mui/handlers.ts @@ -34,8 +34,10 @@ export type SafeHandler = { [__safe_handler]: true; }; +type UnsafeHandler = ((p: T) => Promise) | ((p: T) => void); + export function withSafe( - handler: (p: T) => Promise, + handler: UnsafeHandler, onError: (e: Error) => void, ): SafeHandler { const sh = async function (p: T): Promise { diff --git a/packages/taler-wallet-webextension/src/wallet/AddExchange/index.ts b/packages/taler-wallet-webextension/src/wallet/AddExchange/index.ts new file mode 100644 index 000000000..cece582e9 --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/index.ts @@ -0,0 +1,84 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { TalerConfigResponse } from "@gnu-taler/taler-util"; +import { ErrorAlertView } from "../../components/CurrentAlerts.js"; +import { Loading } from "../../components/Loading.js"; +import { ErrorAlert } from "../../context/alert.js"; +import { compose, StateViewMap } from "../../utils/index.js"; +import { useComponentState } from "./state.js"; +import { ConfirmView, VerifyView } from "./views.js"; +import { HttpResponse, InputFieldHandler } from "@gnu-taler/web-util/browser"; +import { TextFieldHandler } from "../../mui/handlers.js"; + +export interface Props { + currency?: string; + onBack: () => Promise; + noDebounce?: boolean; +} + +export type State = State.Loading + | State.LoadingUriError + | State.Confirm + | State.Verify; + +export namespace State { + export interface Loading { + status: "loading"; + error: undefined; + } + + export interface LoadingUriError { + status: "error"; + error: ErrorAlert; + } + + export interface BaseInfo { + error: undefined; + } + export interface Confirm extends BaseInfo { + status: "confirm"; + url: string; + onCancel: () => Promise; + onConfirm: () => Promise; + error: undefined; + } + export interface Verify extends BaseInfo { + status: "verify"; + error: undefined; + + onCancel: () => Promise; + onAccept: () => Promise; + + url: TextFieldHandler, + knownExchanges: URL[], + result: HttpResponse | undefined, + expectedCurrency: string | undefined, + } +} + +const viewMapping: StateViewMap = { + loading: Loading, + error: ErrorAlertView, + confirm: ConfirmView, + verify: VerifyView, +}; + +export const AddExchange = compose( + "AddExchange", + (p: Props) => useComponentState(p), + viewMapping, +); diff --git a/packages/taler-wallet-webextension/src/wallet/AddExchange/state.ts b/packages/taler-wallet-webextension/src/wallet/AddExchange/state.ts new file mode 100644 index 000000000..fc1762331 --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/state.ts @@ -0,0 +1,149 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { useState, useEffect, useCallback } from "preact/hooks"; +import { Props, State } from "./index.js"; +import { ExchangeEntryStatus, TalerConfigResponse, TranslatedString, canonicalizeBaseUrl } from "@gnu-taler/taler-util"; +import { useBackendContext } from "../../context/backend.js"; +import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { RecursiveState } from "../../utils/index.js"; +import { HttpResponse, useApiContext } from "@gnu-taler/web-util/browser"; +import { alertFromError } from "../../context/alert.js"; +import { withSafe } from "../../mui/handlers.js"; + +export function useComponentState({ onBack, currency, noDebounce }: Props): RecursiveState { + const [verified, setVerified] = useState< + { url: string; config: TalerConfigResponse } | undefined + >(undefined); + + const api = useBackendContext(); + const hook = useAsyncAsHook(() => + api.wallet.call(WalletApiOperation.ListExchanges, {}), + ); + const walletExchanges = !hook ? [] : hook.hasError ? [] : hook.response.exchanges + const used = walletExchanges.filter(e => e.exchangeEntryStatus === ExchangeEntryStatus.Used); + const preset = walletExchanges.filter(e => e.exchangeEntryStatus === ExchangeEntryStatus.Preset); + + + if (!verified) { + return (): State => { + const { request } = useApiContext(); + const ccc = useCallback(async (str: string) => { + const c = canonicalizeBaseUrl(str) + const found = used.findIndex((e) => e.exchangeBaseUrl === c); + if (found !== -1) { + throw Error("This exchange is already active") + } + const result = await request(c, "/keys") + return result + }, [used]) + const { result, value: url, update, error: requestError } = useDebounce>(ccc, noDebounce ?? false) + const [inputError, setInputError] = useState() + + return { + status: "verify", + error: undefined, + onCancel: onBack, + expectedCurrency: currency, + onAccept: async () => { + if (!url || !result || !result.ok) return; + setVerified({ url, config: result.data }) + }, + result, + knownExchanges: preset.map(e => new URL(e.exchangeBaseUrl)), + url: { + value: url ?? "", + error: inputError ?? requestError, + onInput: withSafe(update, (e) => { + setInputError(e.message) + }) + }, + }; + } + } + + async function onConfirm() { + if (!verified) return; + await api.wallet.call(WalletApiOperation.AddExchange, { + exchangeBaseUrl: canonicalizeBaseUrl(verified.url), + forceUpdate: true, + }); + onBack(); + } + + return { + status: "confirm", + error: undefined, + onCancel: onBack, + onConfirm, + url: verified.url + }; +} + + + +function useDebounce( + onTrigger: (v: string) => Promise, + disabled: boolean, +): { + loading: boolean; + error?: string; + value: string | undefined; + result: T | undefined; + update: (s: string) => void; +} { + const [value, setValue] = useState(); + const [dirty, setDirty] = useState(false); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(undefined); + const [error, setError] = useState(undefined); + + const [handler, setHandler] = useState(undefined); + + if (!disabled) { + useEffect(() => { + if (!value) return; + clearTimeout(handler); + const h = setTimeout(async () => { + setDirty(true); + setLoading(true); + try { + const result = await onTrigger(value); + setResult(result); + setError(undefined); + setLoading(false); + } catch (e) { + const errorMessage = + e instanceof Error ? e.message : `unknown error: ${e}`; + setError(errorMessage); + setLoading(false); + setResult(undefined); + } + }, 500); + setHandler(h); + }, [value, setHandler, onTrigger]); + } + + return { + error: dirty ? error : undefined, + loading: loading, + result: result, + value: value, + update: disabled ? onTrigger : setValue , + }; +} + diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeAddConfirm.stories.tsx b/packages/taler-wallet-webextension/src/wallet/AddExchange/stories.tsx similarity index 57% rename from packages/taler-wallet-webextension/src/wallet/ExchangeAddConfirm.stories.tsx rename to packages/taler-wallet-webextension/src/wallet/AddExchange/stories.tsx index 140fc58dc..4e2610743 100644 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeAddConfirm.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/stories.tsx @@ -20,26 +20,10 @@ */ import * as tests from "@gnu-taler/web-util/testing"; -import { ExchangeAddConfirmPage as TestedComponent } from "./ExchangeAddConfirm.js"; +import { ConfirmView, VerifyView } from "./views.js"; export default { - title: "exchange add confirm", - component: TestedComponent, - argTypes: { - onRetry: { action: "onRetry" }, - onDelete: { action: "onDelete" }, - onBack: { action: "onBack" }, - }, + title: "example", }; -export const TermsNotFound = tests.createExample(TestedComponent, { - url: "https://exchange.demo.taler.net/", -}); - -export const NewTerms = tests.createExample(TestedComponent, { - url: "https://exchange.demo.taler.net/", -}); - -export const TermsChanged = tests.createExample(TestedComponent, { - url: "https://exchange.demo.taler.net/", -}); +// export const Ready = tests.createExample(ReadyView, {}); diff --git a/packages/taler-wallet-webextension/src/wallet/AddExchange/test.ts b/packages/taler-wallet-webextension/src/wallet/AddExchange/test.ts new file mode 100644 index 000000000..c9ae58afd --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/test.ts @@ -0,0 +1,178 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { expect } from "chai"; +import { createWalletApiMock } from "../../test-utils.js"; +import * as tests from "@gnu-taler/web-util/testing"; +import { Props } from "./index.js"; +import { useComponentState } from "./state.js"; +import { nullFunction } from "../../mui/handlers.js"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { ExchangeEntryStatus, ExchangeTosStatus, ExchangeUpdateStatus } from "@gnu-taler/taler-util"; +const props: Props = { + onBack: nullFunction, + noDebounce: true, +}; + +describe("AddExchange states", () => { + it("should start in 'verify' state", async () => { + const { handler, TestingContext } = createWalletApiMock(); + + handler.addWalletCallResponse(WalletApiOperation.ListExchanges, {}, { + exchanges:[{ + exchangeBaseUrl: "http://exchange.local/", + ageRestrictionOptions: [], + currency: "ARS", + exchangeEntryStatus: ExchangeEntryStatus.Ephemeral, + tosStatus: ExchangeTosStatus.Pending, + exchangeUpdateStatus: ExchangeUpdateStatus.Failed, + paytoUris: [], + }] + }) + + const hookBehavior = await tests.hookBehaveLikeThis( + useComponentState, + props, + [ + (state) => { + expect(state.status).equal("verify"); + if (state.status !== "verify") return; + expect(state.url.value).eq(""); + expect(state.expectedCurrency).is.undefined; + expect(state.result).is.undefined; + }, + (state) => { + expect(state.status).equal("verify"); + if (state.status !== "verify") return; + expect(state.url.value).eq(""); + expect(state.expectedCurrency).is.undefined; + expect(state.result).is.undefined; + }, + ], + TestingContext, + ); + + expect(hookBehavior).deep.equal({ result: "ok" }); + expect(handler.getCallingQueueState()).eq("empty"); + }); + + + + it("should not be able to add a known exchange", async () => { + const { handler, TestingContext } = createWalletApiMock(); + + handler.addWalletCallResponse(WalletApiOperation.ListExchanges, {}, { + exchanges:[{ + exchangeBaseUrl: "http://exchange.local/", + ageRestrictionOptions: [], + currency: "ARS", + exchangeEntryStatus: ExchangeEntryStatus.Used, + tosStatus: ExchangeTosStatus.Pending, + exchangeUpdateStatus: ExchangeUpdateStatus.Ready, + paytoUris: [], + }] + }) + + const hookBehavior = await tests.hookBehaveLikeThis( + useComponentState, + props, + [ + (state) => { + expect(state.status).equal("verify"); + if (state.status !== "verify") return; + expect(state.url.value).eq(""); + expect(state.expectedCurrency).is.undefined; + expect(state.result).is.undefined; + }, + (state) => { + expect(state.status).equal("verify"); + if (state.status !== "verify") return; + expect(state.url.value).eq(""); + expect(state.expectedCurrency).is.undefined; + expect(state.result).is.undefined; + expect(state.error).is.undefined; + expect(state.url.onInput).is.not.undefined; + if (!state.url.onInput) return; + state.url.onInput("http://exchange.local/") + }, + (state) => { + expect(state.status).equal("verify"); + if (state.status !== "verify") return; + expect(state.url.value).eq(""); + expect(state.expectedCurrency).is.undefined; + expect(state.result).is.undefined; + expect(state.url.error).eq("This exchange is already active"); + expect(state.url.onInput).is.not.undefined; + }, + ], + TestingContext, + ); + + expect(hookBehavior).deep.equal({ result: "ok" }); + expect(handler.getCallingQueueState()).eq("empty"); + }); + + + it("should be able to add a preset exchange", async () => { + const { handler, TestingContext } = createWalletApiMock(); + + handler.addWalletCallResponse(WalletApiOperation.ListExchanges, {}, { + exchanges:[{ + exchangeBaseUrl: "http://exchange.local/", + ageRestrictionOptions: [], + currency: "ARS", + exchangeEntryStatus: ExchangeEntryStatus.Preset, + tosStatus: ExchangeTosStatus.Pending, + exchangeUpdateStatus: ExchangeUpdateStatus.Ready, + paytoUris: [], + }] + }) + + const hookBehavior = await tests.hookBehaveLikeThis( + useComponentState, + props, + [ + (state) => { + expect(state.status).equal("verify"); + if (state.status !== "verify") return; + expect(state.url.value).eq(""); + expect(state.expectedCurrency).is.undefined; + expect(state.result).is.undefined; + }, + (state) => { + expect(state.status).equal("verify"); + if (state.status !== "verify") return; + expect(state.url.value).eq(""); + expect(state.expectedCurrency).is.undefined; + expect(state.result).is.undefined; + expect(state.error).is.undefined; + expect(state.url.onInput).is.not.undefined; + if (!state.url.onInput) return; + state.url.onInput("http://exchange.local/") + }, + ], + TestingContext, + ); + + expect(hookBehavior).deep.equal({ result: "ok" }); + expect(handler.getCallingQueueState()).eq("empty"); + }); +}); diff --git a/packages/taler-wallet-webextension/src/wallet/AddExchange/views.tsx b/packages/taler-wallet-webextension/src/wallet/AddExchange/views.tsx new file mode 100644 index 000000000..e1bc7f0f6 --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/views.tsx @@ -0,0 +1,201 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { ErrorMessage } from "../../components/ErrorMessage.js"; +import { Input, LightText, SubTitle, Title, WarningBox } from "../../components/styled/index.js"; +import { TermsOfService } from "../../components/TermsOfService/index.js"; +import { Button } from "../../mui/Button.js"; +import { State } from "./index.js"; + + +export function VerifyView({ + expectedCurrency, + onCancel, + onAccept, + result, + knownExchanges, + url, +}: State.Verify): VNode { + const { i18n } = useTranslationContext(); + + return ( + +
+ {!expectedCurrency ? ( + + <i18n.Translate>Add new exchange</i18n.Translate> + + ) : ( + + Add exchange for {expectedCurrency} + + )} + {!result && ( + + + Enter the URL of an exchange you trust. + + + )} + {result && ( + + + An exchange has been found! Review the information and click next + + + )} + {result && result.ok && expectedCurrency && expectedCurrency !== result.data.currency && ( + + + This exchange doesn't match the expected currency + {expectedCurrency} + + + )} + {result && !result.ok && !result.loading && ( + + )} +

+ + + { + if (url.onInput) { + url.onInput(e.currentTarget.value) + } + }} + /> + + {result && result.loading && ( +

+ loading... +
+ )} + {result && result.ok && !result.loading && ( + + + + + + + + + + + )} +

+ {url.error && ( + + )} +
+
+ + +
+
+ +
+
+ ); +} + + +export function ConfirmView({ + url, + onCancel, + onConfirm, +}: State.Confirm): VNode { + const { i18n } = useTranslationContext(); + + const [accepted, setAccepted] = useState(false); + + return ( + +
+ + <i18n.Translate>Review terms of service</i18n.Translate> + +
+ Exchange URL: + + {url} + +
+
+ + + +
+ + +
+
+ ); +} diff --git a/packages/taler-wallet-webextension/src/wallet/Application.tsx b/packages/taler-wallet-webextension/src/wallet/Application.tsx index 98515aac0..18291a25a 100644 --- a/packages/taler-wallet-webextension/src/wallet/Application.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Application.tsx @@ -65,15 +65,15 @@ import { WithdrawPageFromParams, WithdrawPageFromURI, } from "../cta/Withdraw/index.js"; +import { useIsOnline } from "../hooks/useIsOnline.js"; import { strings } from "../i18n/strings.js"; -import { platform } from "../platform/foreground.js"; import CloseIcon from "../svg/close_24px.inline.svg"; import { AddBackupProviderPage } from "./AddBackupProvider/index.js"; +import { AddExchange } from "./AddExchange/index.js"; import { BackupPage } from "./BackupPage.js"; import { DepositPage } from "./DepositPage/index.js"; import { DestinationSelectionPage } from "./DestinationSelection/index.js"; import { DeveloperPage } from "./DeveloperPage.js"; -import { ExchangeAddPage } from "./ExchangeAddPage.js"; import { HistoryPage } from "./History.js"; import { NotificationsPage } from "./Notifications/index.js"; import { ProviderDetailPage } from "./ProviderDetailPage.js"; @@ -81,7 +81,6 @@ import { QrReaderPage } from "./QrReader.js"; import { SettingsPage } from "./Settings.js"; import { TransactionPage } from "./Transaction.js"; import { WelcomePage } from "./Welcome.js"; -import { useIsOnline } from "../hooks/useIsOnline.js"; export function Application(): VNode { const { i18n } = useTranslationContext(); @@ -143,7 +142,7 @@ export function Application(): VNode { path={Pages.settingsExchangeAdd.pattern} component={() => ( - redirectTo(Pages.balance)} /> + redirectTo(Pages.balance)} /> )} /> diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeAddConfirm.tsx b/packages/taler-wallet-webextension/src/wallet/ExchangeAddConfirm.tsx deleted file mode 100644 index 0d4f234b3..000000000 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeAddConfirm.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ -import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { Title } from "../components/styled/index.js"; -import { TermsOfService } from "../components/TermsOfService/index.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Button } from "../mui/Button.js"; - -export interface Props { - url: string; - onCancel: () => Promise; - onConfirm: () => Promise; -} - -export function ExchangeAddConfirmPage({ - url, - onCancel, - onConfirm, -}: Props): VNode { - const { i18n } = useTranslationContext(); - - const [accepted, setAccepted] = useState(false); - - return ( - -
- - <i18n.Translate>Review terms of service</i18n.Translate> - -
- Exchange URL: - - {url} - -
-
- - - -
- - -
-
- ); -} diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeAddPage.tsx b/packages/taler-wallet-webextension/src/wallet/ExchangeAddPage.tsx deleted file mode 100644 index 9be12fb28..000000000 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeAddPage.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -import { - canonicalizeBaseUrl, - TalerConfigResponse, -} from "@gnu-taler/taler-util"; -import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { useBackendContext } from "../context/backend.js"; -import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; -import { queryToSlashKeys } from "../utils/index.js"; -import { ExchangeAddConfirmPage } from "./ExchangeAddConfirm.js"; -import { ExchangeSetUrlPage } from "./ExchangeSetUrl.js"; - -interface Props { - currency?: string; - onBack: () => Promise; -} - -export function ExchangeAddPage({ currency, onBack }: Props): VNode { - const [verifying, setVerifying] = useState< - { url: string; config: TalerConfigResponse } | undefined - >(undefined); - - const api = useBackendContext(); - const knownExchangesResponse = useAsyncAsHook(() => - api.wallet.call(WalletApiOperation.ListExchanges, {}), - ); - const knownExchanges = !knownExchangesResponse - ? [] - : knownExchangesResponse.hasError - ? [] - : knownExchangesResponse.response.exchanges; - - if (!verifying) { - return ( - { - const found = - knownExchanges.findIndex((e) => e.exchangeBaseUrl === url) !== -1; - - if (found) { - throw Error("This exchange is already known"); - } - return { - name: "1", - version: "15:0:0", - currency: "ARS", - }; - }} - onConfirm={ - async (url) => { - setVerifying({ - url, - config: { - name: "1", - version: "15:0:0", - currency: "ARS", - }, - }); - return undefined; - } - // queryToSlashKeys(url) - // .then((config) => { - // setVerifying({ url, config }); - // }) - // .catch((e) => e.message) - } - /> - ); - } - return ( - { - await api.wallet.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: canonicalizeBaseUrl(verifying.url), - forceUpdate: true, - }); - onBack(); - }} - /> - ); -} diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeAddSetUrl.stories.tsx b/packages/taler-wallet-webextension/src/wallet/ExchangeAddSetUrl.stories.tsx deleted file mode 100644 index e69268b08..000000000 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeAddSetUrl.stories.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import * as tests from "@gnu-taler/web-util/testing"; -import { queryToSlashKeys } from "../utils/index.js"; -import { ExchangeSetUrlPage as TestedComponent } from "./ExchangeSetUrl.js"; - -export default { - title: "exchange add set url", -}; - -export const ExpectedUSD = tests.createExample(TestedComponent, { - expectedCurrency: "USD", - onVerify: queryToSlashKeys, -}); - -export const ExpectedKUDOS = tests.createExample(TestedComponent, { - expectedCurrency: "KUDOS", - onVerify: queryToSlashKeys, -}); - -export const InitialState = tests.createExample(TestedComponent, { - onVerify: queryToSlashKeys, -}); - -const knownExchanges = [ - { - currency: "TESTKUDOS", - exchangeBaseUrl: "https://exchange.demo.taler.net/", - tos: { - currentVersion: "1", - acceptedVersion: "1", - content: "content of tos", - contentType: "text/plain", - }, - paytoUris: [], - }, -]; - -export const WithDemoAsKnownExchange = tests.createExample(TestedComponent, { - onVerify: async (url) => { - const found = - knownExchanges.findIndex((e) => e.exchangeBaseUrl === url) !== -1; - - if (found) { - throw Error("This exchange is already known"); - } - return queryToSlashKeys(url); - }, -}); diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSetUrl.tsx b/packages/taler-wallet-webextension/src/wallet/ExchangeSetUrl.tsx deleted file mode 100644 index 4fea3bc98..000000000 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeSetUrl.tsx +++ /dev/null @@ -1,209 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ -import { - canonicalizeBaseUrl, - TalerConfigResponse, -} from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { ErrorMessage } from "../components/ErrorMessage.js"; -import { - Input, - LightText, - SubTitle, - Title, - WarningBox, -} from "../components/styled/index.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Button } from "../mui/Button.js"; - -export interface Props { - initialValue?: string; - expectedCurrency?: string; - onCancel: () => Promise; - onVerify: (s: string) => Promise; - onConfirm: (url: string) => Promise; - withError?: string; -} - -function useEndpointStatus( - endpoint: string, - onVerify: (e: string) => Promise, -): { - loading: boolean; - error?: string; - endpoint: string; - result: T | undefined; - updateEndpoint: (s: string) => void; -} { - const [value, setValue] = useState(endpoint); - const [dirty, setDirty] = useState(false); - const [loading, setLoading] = useState(false); - const [result, setResult] = useState(undefined); - const [error, setError] = useState(undefined); - - const [handler, setHandler] = useState(undefined); - - useEffect(() => { - if (!value) return; - window.clearTimeout(handler); - const h = window.setTimeout(async () => { - setDirty(true); - setLoading(true); - try { - const url = canonicalizeBaseUrl(value); - const result = await onVerify(url); - setResult(result); - setError(undefined); - setLoading(false); - } catch (e) { - const errorMessage = - e instanceof Error ? e.message : `unknown error: ${e}`; - setError(errorMessage); - setLoading(false); - setResult(undefined); - } - }, 500); - setHandler(h); - }, [value, setHandler, onVerify]); - - return { - error: dirty ? error : undefined, - loading: loading, - result: result, - endpoint: value, - updateEndpoint: setValue, - }; -} - -export function ExchangeSetUrlPage({ - initialValue, - expectedCurrency, - onCancel, - onVerify, - onConfirm, -}: Props): VNode { - const { i18n } = useTranslationContext(); - const { loading, result, endpoint, updateEndpoint, error } = - useEndpointStatus(initialValue ?? "", onVerify); - - const [confirmationError, setConfirmationError] = useState< - string | undefined - >(undefined); - - return ( - -
- {!expectedCurrency ? ( - - <i18n.Translate>Add new exchange</i18n.Translate> - - ) : ( - - Add exchange for {expectedCurrency} - - )} - {!result && ( - - - Enter the URL of an exchange you trust. - - - )} - {result && ( - - - An exchange has been found! Review the information and click next - - - )} - {result && expectedCurrency && expectedCurrency !== result.currency && ( - - - This exchange doesn't match the expected currency - {expectedCurrency} - - - )} - {error && ( - - )} - {confirmationError && ( - - )} -

- - - updateEndpoint(e.currentTarget.value)} - /> - - {loading && ( -

- loading... -
- )} - {result && !loading && ( - - - - - - - - - - - )} -

-
-
- - -
-
- ); -} diff --git a/packages/taler-wallet-webextension/src/wallet/index.stories.tsx b/packages/taler-wallet-webextension/src/wallet/index.stories.tsx index 05e141dc6..989292326 100644 --- a/packages/taler-wallet-webextension/src/wallet/index.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/index.stories.tsx @@ -21,8 +21,6 @@ export * as a1 from "./Backup.stories.js"; export * as a4 from "./DepositPage/stories.js"; -export * as a5 from "./ExchangeAddConfirm.stories.js"; -export * as a6 from "./ExchangeAddSetUrl.stories.js"; export * as a7 from "./History.stories.js"; export * as a8 from "./AddBackupProvider/stories.js"; export * as a10 from "./ProviderDetail.stories.js"; diff --git a/packages/web-util/src/utils/request.ts b/packages/web-util/src/utils/request.ts index ef4d8e847..f8a892d99 100644 --- a/packages/web-util/src/utils/request.ts +++ b/packages/web-util/src/utils/request.ts @@ -76,7 +76,7 @@ export async function defaultRequestHandler( type: ErrorType.UNEXPECTED, exception: undefined, loading: false, - message: `invalid URL: "${validURL}"`, + message: `invalid URL: "${baseUrl}${endpoint}"`, }; throw new RequestError(error) }