Compare commits

...

9 Commits

20 changed files with 860 additions and 517 deletions

View File

@ -1,7 +1,7 @@
{ {
"private": true, "private": true,
"name": "@gnu-taler/demobank-ui", "name": "@gnu-taler/demobank-ui",
"version": "0.9.3-dev.27", "version": "0.9.3-dev.29",
"license": "AGPL-3.0-OR-LATER", "license": "AGPL-3.0-OR-LATER",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -1652,9 +1652,6 @@ export class MerchantService implements MerchantServiceInterface {
const body: MerchantInstanceConfig = { const body: MerchantInstanceConfig = {
auth, auth,
accounts: instanceConfig.paytoUris.map((x) => ({
payto_uri: x,
})),
id: instanceConfig.id, id: instanceConfig.id,
name: instanceConfig.name, name: instanceConfig.name,
address: instanceConfig.address ?? {}, address: instanceConfig.address ?? {},

View File

@ -28,9 +28,17 @@ import {
MerchantApiClient, MerchantApiClient,
rsaBlind, rsaBlind,
setGlobalLogLevelFromString, setGlobalLogLevelFromString,
RegisterAccountRequest,
HttpStatusCode,
MerchantInstanceConfig,
Duration,
generateIban,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { clk } from "@gnu-taler/taler-util/clk"; 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 { import {
CryptoDispatcher, CryptoDispatcher,
downloadExchangeInfo, downloadExchangeInfo,
@ -46,7 +54,11 @@ import { runBench2 } from "./bench2.js";
import { runBench3 } from "./bench3.js"; import { runBench3 } from "./bench3.js";
import { runEnvFull } from "./env-full.js"; import { runEnvFull } from "./env-full.js";
import { runEnv1 } from "./env1.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 { getTestInfo, runTests } from "./integrationtests/testrunner.js";
import { lintExchangeDeployment } from "./lint.js"; import { lintExchangeDeployment } from "./lint.js";
@ -312,8 +324,7 @@ deploymentCli
const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http); const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http);
await topupReserveWithDemobank({ await topupReserveWithDemobank({
amount: "KUDOS:10", amount: "KUDOS:10",
corebankApiBaseUrl: corebankApiBaseUrl: "https://bank.demo.taler.net/",
"https://bank.demo.taler.net/",
exchangeInfo, exchangeInfo,
http, http,
reservePub: reserveKeyPair.pub, reservePub: reserveKeyPair.pub,
@ -341,8 +352,7 @@ deploymentCli
const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http); const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http);
await topupReserveWithDemobank({ await topupReserveWithDemobank({
amount: "TESTKUDOS:10", amount: "TESTKUDOS:10",
corebankApiBaseUrl: corebankApiBaseUrl: "https://bank.test.taler.net/",
"https://bank.test.taler.net/",
exchangeInfo, exchangeInfo,
http, http,
reservePub: reserveKeyPair.pub, 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 deploymentCli
.subcommand("coincfg", "gen-coin-config", { .subcommand("coincfg", "gen-coin-config", {
help: "Generate a coin/denomination configuration for the exchange.", help: "Generate a coin/denomination configuration for the exchange.",

View File

@ -72,11 +72,6 @@ export async function runMerchantInstancesUrlsTest(t: GlobalTestState) {
), ),
jurisdiction: {}, jurisdiction: {},
name: "My Default Instance", name: "My Default Instance",
accounts: [
{
payto_uri: generateRandomPayto("bar"),
},
],
auth: { auth: {
method: "token", method: "token",
token: "secret-token:i-am-default", token: "secret-token:i-am-default",
@ -95,11 +90,6 @@ export async function runMerchantInstancesUrlsTest(t: GlobalTestState) {
), ),
jurisdiction: {}, jurisdiction: {},
name: "My Second Instance", name: "My Second Instance",
accounts: [
{
payto_uri: generateRandomPayto("bar"),
},
],
auth: { auth: {
method: "token", method: "token",
token: "secret-token:i-am-myinst", token: "secret-token:i-am-myinst",

View File

@ -74,18 +74,6 @@ export interface DeleteTippingReserveArgs {
purge?: boolean; 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 { interface MerchantBankAccount {
// The payto:// URI where the wallet will send coins. // The payto:// URI where the wallet will send coins.
payto_uri: string; payto_uri: string;
@ -102,7 +90,6 @@ interface MerchantBankAccount {
} }
export interface MerchantInstanceConfig { export interface MerchantInstanceConfig {
accounts: MerchantBankAccount[];
auth: MerchantAuthConfiguration; auth: MerchantAuthConfiguration;
id: string; id: string;
name: string; name: string;

View File

@ -184,7 +184,7 @@ export function pathsub(
defaultValue = undefined; defaultValue = undefined;
} }
const r = lookup(inner, depth + 1); const r = lookup(varname, depth + 1);
if (r !== undefined) { if (r !== undefined) {
s = s.substring(0, start) + r + s.substring(p + 1); s = s.substring(0, start) + r + s.substring(p + 1);
l = start + r.length; l = start + r.length;

View File

@ -1,6 +1,6 @@
{ {
"name": "@gnu-taler/taler-wallet-webextension", "name": "@gnu-taler/taler-wallet-webextension",
"version": "0.9.3-dev.27", "version": "0.9.3-dev.31",
"description": "GNU Taler Wallet browser extension", "description": "GNU Taler Wallet browser extension",
"main": "./build/index.js", "main": "./build/index.js",
"types": "./build/index.d.ts", "types": "./build/index.d.ts",

View File

@ -34,8 +34,10 @@ export type SafeHandler<T> = {
[__safe_handler]: true; [__safe_handler]: true;
}; };
type UnsafeHandler<T> = ((p: T) => Promise<void>) | ((p: T) => void);
export function withSafe<T>( export function withSafe<T>(
handler: (p: T) => Promise<void>, handler: UnsafeHandler<T>,
onError: (e: Error) => void, onError: (e: Error) => void,
): SafeHandler<T> { ): SafeHandler<T> {
const sh = async function (p: T): Promise<void> { const sh = async function (p: T): Promise<void> {

View File

@ -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 <http://www.gnu.org/licenses/>
*/
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<void>;
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<void>;
onConfirm: () => Promise<void>;
error: undefined;
}
export interface Verify extends BaseInfo {
status: "verify";
error: undefined;
onCancel: () => Promise<void>;
onAccept: () => Promise<void>;
url: TextFieldHandler,
knownExchanges: URL[],
result: HttpResponse<TalerConfigResponse, unknown> | undefined,
expectedCurrency: string | undefined,
}
}
const viewMapping: StateViewMap<State> = {
loading: Loading,
error: ErrorAlertView,
confirm: ConfirmView,
verify: VerifyView,
};
export const AddExchange = compose(
"AddExchange",
(p: Props) => useComponentState(p),
viewMapping,
);

View File

@ -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 <http://www.gnu.org/licenses/>
*/
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<State> {
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<TalerConfigResponse>(c, "/keys")
return result
}, [used])
const { result, value: url, update, error: requestError } = useDebounce<HttpResponse<TalerConfigResponse, unknown>>(ccc, noDebounce ?? false)
const [inputError, setInputError] = useState<string>()
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<T>(
onTrigger: (v: string) => Promise<T>,
disabled: boolean,
): {
loading: boolean;
error?: string;
value: string | undefined;
result: T | undefined;
update: (s: string) => void;
} {
const [value, setValue] = useState<string>();
const [dirty, setDirty] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<T | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const [handler, setHandler] = useState<any | undefined>(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 ,
};
}

View File

@ -20,26 +20,10 @@
*/ */
import * as tests from "@gnu-taler/web-util/testing"; import * as tests from "@gnu-taler/web-util/testing";
import { ExchangeAddConfirmPage as TestedComponent } from "./ExchangeAddConfirm.js"; import { ConfirmView, VerifyView } from "./views.js";
export default { export default {
title: "exchange add confirm", title: "example",
component: TestedComponent,
argTypes: {
onRetry: { action: "onRetry" },
onDelete: { action: "onDelete" },
onBack: { action: "onBack" },
},
}; };
export const TermsNotFound = tests.createExample(TestedComponent, { // export const Ready = tests.createExample(ReadyView, {});
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/",
});

View File

@ -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 <http://www.gnu.org/licenses/>
*/
/**
*
* @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");
});
});

View File

@ -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 <http://www.gnu.org/licenses/>
*/
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 (
<Fragment>
<section>
{!expectedCurrency ? (
<Title>
<i18n.Translate>Add new exchange</i18n.Translate>
</Title>
) : (
<SubTitle>
<i18n.Translate>Add exchange for {expectedCurrency}</i18n.Translate>
</SubTitle>
)}
{!result && (
<LightText>
<i18n.Translate>
Enter the URL of an exchange you trust.
</i18n.Translate>
</LightText>
)}
{result && (
<LightText>
<i18n.Translate>
An exchange has been found! Review the information and click next
</i18n.Translate>
</LightText>
)}
{result && result.ok && expectedCurrency && expectedCurrency !== result.data.currency && (
<WarningBox>
<i18n.Translate>
This exchange doesn&apos;t match the expected currency
<b>{expectedCurrency}</b>
</i18n.Translate>
</WarningBox>
)}
{result && !result.ok && !result.loading && (
<ErrorMessage
title={i18n.str`Unable to verify this exchange`}
description={result.message}
/>
)}
<p>
<Input invalid={result && !result.ok} >
<label>URL</label>
<input
type="text"
placeholder="https://"
value={url.value}
onInput={(e) => {
if (url.onInput) {
url.onInput(e.currentTarget.value)
}
}}
/>
</Input>
{result && result.loading && (
<div>
<i18n.Translate>loading</i18n.Translate>...
</div>
)}
{result && result.ok && !result.loading && (
<Fragment>
<Input>
<label>
<i18n.Translate>Version</i18n.Translate>
</label>
<input type="text" disabled value={result.data.version} />
</Input>
<Input>
<label>
<i18n.Translate>Currency</i18n.Translate>
</label>
<input type="text" disabled value={result.data.currency} />
</Input>
</Fragment>
)}
</p>
{url.error && (
<ErrorMessage
title={i18n.str`Can't use this URL`}
description={url.error}
/>
)}
</section>
<footer>
<Button variant="contained" color="secondary" onClick={onCancel}>
<i18n.Translate>Cancel</i18n.Translate>
</Button>
<Button
variant="contained"
disabled={
!result ||
result.loading ||
!result.ok ||
(!!expectedCurrency && expectedCurrency !== result.data.currency)
}
onClick={onAccept}
>
<i18n.Translate>Next</i18n.Translate>
</Button>
</footer>
<section>
<ul>
{knownExchanges.map(ex => {
return <li><a href="#" onClick={(e) => {
if (url.onInput) {
url.onInput(ex.href)
}
e.preventDefault()
}}>
{ex.href}</a></li>
})}
</ul>
</section>
</Fragment>
);
}
export function ConfirmView({
url,
onCancel,
onConfirm,
}: State.Confirm): VNode {
const { i18n } = useTranslationContext();
const [accepted, setAccepted] = useState(false);
return (
<Fragment>
<section>
<Title>
<i18n.Translate>Review terms of service</i18n.Translate>
</Title>
<div>
<i18n.Translate>Exchange URL</i18n.Translate>:
<a href={url} target="_blank" rel="noreferrer">
{url}
</a>
</div>
</section>
<TermsOfService key="terms" exchangeUrl={url} onChange={setAccepted} />
<footer>
<Button
key="cancel"
variant="contained"
color="secondary"
onClick={onCancel}
>
<i18n.Translate>Cancel</i18n.Translate>
</Button>
<Button
key="add"
variant="contained"
color="success"
disabled={!accepted}
onClick={onConfirm}
>
<i18n.Translate>Add exchange</i18n.Translate>
</Button>
</footer>
</Fragment>
);
}

View File

@ -65,15 +65,15 @@ import {
WithdrawPageFromParams, WithdrawPageFromParams,
WithdrawPageFromURI, WithdrawPageFromURI,
} from "../cta/Withdraw/index.js"; } from "../cta/Withdraw/index.js";
import { useIsOnline } from "../hooks/useIsOnline.js";
import { strings } from "../i18n/strings.js"; import { strings } from "../i18n/strings.js";
import { platform } from "../platform/foreground.js";
import CloseIcon from "../svg/close_24px.inline.svg"; import CloseIcon from "../svg/close_24px.inline.svg";
import { AddBackupProviderPage } from "./AddBackupProvider/index.js"; import { AddBackupProviderPage } from "./AddBackupProvider/index.js";
import { AddExchange } from "./AddExchange/index.js";
import { BackupPage } from "./BackupPage.js"; import { BackupPage } from "./BackupPage.js";
import { DepositPage } from "./DepositPage/index.js"; import { DepositPage } from "./DepositPage/index.js";
import { DestinationSelectionPage } from "./DestinationSelection/index.js"; import { DestinationSelectionPage } from "./DestinationSelection/index.js";
import { DeveloperPage } from "./DeveloperPage.js"; import { DeveloperPage } from "./DeveloperPage.js";
import { ExchangeAddPage } from "./ExchangeAddPage.js";
import { HistoryPage } from "./History.js"; import { HistoryPage } from "./History.js";
import { NotificationsPage } from "./Notifications/index.js"; import { NotificationsPage } from "./Notifications/index.js";
import { ProviderDetailPage } from "./ProviderDetailPage.js"; import { ProviderDetailPage } from "./ProviderDetailPage.js";
@ -81,7 +81,6 @@ import { QrReaderPage } from "./QrReader.js";
import { SettingsPage } from "./Settings.js"; import { SettingsPage } from "./Settings.js";
import { TransactionPage } from "./Transaction.js"; import { TransactionPage } from "./Transaction.js";
import { WelcomePage } from "./Welcome.js"; import { WelcomePage } from "./Welcome.js";
import { useIsOnline } from "../hooks/useIsOnline.js";
export function Application(): VNode { export function Application(): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
@ -143,7 +142,7 @@ export function Application(): VNode {
path={Pages.settingsExchangeAdd.pattern} path={Pages.settingsExchangeAdd.pattern}
component={() => ( component={() => (
<WalletTemplate> <WalletTemplate>
<ExchangeAddPage onBack={() => redirectTo(Pages.balance)} /> <AddExchange onBack={() => redirectTo(Pages.balance)} />
</WalletTemplate> </WalletTemplate>
)} )}
/> />

View File

@ -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 <http://www.gnu.org/licenses/>
*/
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<void>;
onConfirm: () => Promise<void>;
}
export function ExchangeAddConfirmPage({
url,
onCancel,
onConfirm,
}: Props): VNode {
const { i18n } = useTranslationContext();
const [accepted, setAccepted] = useState(false);
return (
<Fragment>
<section>
<Title>
<i18n.Translate>Review terms of service</i18n.Translate>
</Title>
<div>
<i18n.Translate>Exchange URL</i18n.Translate>:
<a href={url} target="_blank" rel="noreferrer">
{url}
</a>
</div>
</section>
<TermsOfService key="terms" exchangeUrl={url} onChange={setAccepted} />
<footer>
<Button
key="cancel"
variant="contained"
color="secondary"
onClick={onCancel}
>
<i18n.Translate>Cancel</i18n.Translate>
</Button>
<Button
key="add"
variant="contained"
color="success"
disabled={!accepted}
onClick={onConfirm}
>
<i18n.Translate>Add exchange</i18n.Translate>
</Button>
</footer>
</Fragment>
);
}

View File

@ -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 <http://www.gnu.org/licenses/>
*/
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<void>;
}
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 (
<ExchangeSetUrlPage
onCancel={onBack}
expectedCurrency={currency}
onVerify={async (url) => {
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<TalerConfigResponse>(url)
// .then((config) => {
// setVerifying({ url, config });
// })
// .catch((e) => e.message)
}
/>
);
}
return (
<ExchangeAddConfirmPage
url={verifying.url}
onCancel={onBack}
onConfirm={async () => {
await api.wallet.call(WalletApiOperation.AddExchange, {
exchangeBaseUrl: canonicalizeBaseUrl(verifying.url),
forceUpdate: true,
});
onBack();
}}
/>
);
}

View File

@ -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 <http://www.gnu.org/licenses/>
*/
/**
*
* @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);
},
});

View File

@ -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 <http://www.gnu.org/licenses/>
*/
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<void>;
onVerify: (s: string) => Promise<TalerConfigResponse | undefined>;
onConfirm: (url: string) => Promise<string | undefined>;
withError?: string;
}
function useEndpointStatus<T>(
endpoint: string,
onVerify: (e: string) => Promise<T>,
): {
loading: boolean;
error?: string;
endpoint: string;
result: T | undefined;
updateEndpoint: (s: string) => void;
} {
const [value, setValue] = useState<string>(endpoint);
const [dirty, setDirty] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<T | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const [handler, setHandler] = useState<number | undefined>(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 (
<Fragment>
<section>
{!expectedCurrency ? (
<Title>
<i18n.Translate>Add new exchange</i18n.Translate>
</Title>
) : (
<SubTitle>
<i18n.Translate>Add exchange for {expectedCurrency}</i18n.Translate>
</SubTitle>
)}
{!result && (
<LightText>
<i18n.Translate>
Enter the URL of an exchange you trust.
</i18n.Translate>
</LightText>
)}
{result && (
<LightText>
<i18n.Translate>
An exchange has been found! Review the information and click next
</i18n.Translate>
</LightText>
)}
{result && expectedCurrency && expectedCurrency !== result.currency && (
<WarningBox>
<i18n.Translate>
This exchange doesn&apos;t match the expected currency
<b>{expectedCurrency}</b>
</i18n.Translate>
</WarningBox>
)}
{error && (
<ErrorMessage
title={i18n.str`Unable to verify this exchange`}
description={error}
/>
)}
{confirmationError && (
<ErrorMessage
title={i18n.str`Unable to add this exchange`}
description={confirmationError}
/>
)}
<p>
<Input invalid={!!error}>
<label>URL</label>
<input
type="text"
placeholder="https://"
value={endpoint}
onInput={(e) => updateEndpoint(e.currentTarget.value)}
/>
</Input>
{loading && (
<div>
<i18n.Translate>loading</i18n.Translate>...
</div>
)}
{result && !loading && (
<Fragment>
<Input>
<label>
<i18n.Translate>Version</i18n.Translate>
</label>
<input type="text" disabled value={result.version} />
</Input>
<Input>
<label>
<i18n.Translate>Currency</i18n.Translate>
</label>
<input type="text" disabled value={result.currency} />
</Input>
</Fragment>
)}
</p>
</section>
<footer>
<Button variant="contained" color="secondary" onClick={onCancel}>
<i18n.Translate>Cancel</i18n.Translate>
</Button>
<Button
variant="contained"
disabled={
!result ||
!!error ||
(!!expectedCurrency && expectedCurrency !== result.currency)
}
onClick={() => {
const url = canonicalizeBaseUrl(endpoint);
return onConfirm(url).then((r) =>
r ? setConfirmationError(r) : undefined,
);
}}
>
<i18n.Translate>Next</i18n.Translate>
</Button>
</footer>
</Fragment>
);
}

View File

@ -21,8 +21,6 @@
export * as a1 from "./Backup.stories.js"; export * as a1 from "./Backup.stories.js";
export * as a4 from "./DepositPage/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 a7 from "./History.stories.js";
export * as a8 from "./AddBackupProvider/stories.js"; export * as a8 from "./AddBackupProvider/stories.js";
export * as a10 from "./ProviderDetail.stories.js"; export * as a10 from "./ProviderDetail.stories.js";

View File

@ -76,7 +76,7 @@ export async function defaultRequestHandler<T>(
type: ErrorType.UNEXPECTED, type: ErrorType.UNEXPECTED,
exception: undefined, exception: undefined,
loading: false, loading: false,
message: `invalid URL: "${validURL}"`, message: `invalid URL: "${baseUrl}${endpoint}"`,
}; };
throw new RequestError(error) throw new RequestError(error)
} }