diff options
Diffstat (limited to 'packages/taler-wallet-webextension/src/wallet/AddExchange')
5 files changed, 641 insertions, 0 deletions
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 <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, +); 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 <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 , + }; +} + diff --git a/packages/taler-wallet-webextension/src/wallet/AddExchange/stories.tsx b/packages/taler-wallet-webextension/src/wallet/AddExchange/stories.tsx new file mode 100644 index 000000000..4e2610743 --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/stories.tsx @@ -0,0 +1,29 @@ +/* + 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 { ConfirmView, VerifyView } from "./views.js"; + +export default { + title: "example", +}; + +// 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 <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"); + }); +}); 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 <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'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> + ); +} |