aboutsummaryrefslogtreecommitdiff
path: root/packages/taler-wallet-webextension/src/wallet/AddBackupProvider
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2022-11-16 16:04:52 -0300
committerSebastian <sebasjm@gmail.com>2022-11-16 16:05:13 -0300
commit1a63d56bfdd091cc7aefdf1e25f3a074bfdf5e0e (patch)
tree7255cf4a5b51af4807e2a01a370497413a78968f /packages/taler-wallet-webextension/src/wallet/AddBackupProvider
parent53164dc47b1138235a0c797affaa6fb37ea43239 (diff)
downloadwallet-core-1a63d56bfdd091cc7aefdf1e25f3a074bfdf5e0e.tar.xz
fix #7411, also making the backup payment visible
Diffstat (limited to 'packages/taler-wallet-webextension/src/wallet/AddBackupProvider')
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddBackupProvider/index.ts95
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddBackupProvider/state.ts260
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddBackupProvider/stories.tsx109
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddBackupProvider/test.ts79
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddBackupProvider/views.tsx172
5 files changed, 715 insertions, 0 deletions
diff --git a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/index.ts b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/index.ts
new file mode 100644
index 000000000..3205588af
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/index.ts
@@ -0,0 +1,95 @@
+/*
+ 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 {
+ AmountJson,
+ BackupBackupProviderTerms,
+ TalerErrorDetail,
+} from "@gnu-taler/taler-util";
+import { SyncTermsOfServiceResponse } from "@gnu-taler/taler-wallet-core";
+import { Loading } from "../../components/Loading.js";
+import { HookError } from "../../hooks/useAsyncAsHook.js";
+import {
+ ButtonHandler,
+ TextFieldHandler,
+ ToggleHandler,
+} from "../../mui/handlers.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { wxApi } from "../../wxApi.js";
+import { useComponentState } from "./state.js";
+import {
+ LoadingUriView,
+ SelectProviderView,
+ ConfirmProviderView,
+} from "./views.js";
+
+export interface Props {
+ currency: string;
+ onBack: () => Promise<void>;
+ onComplete: (pid: string) => Promise<void>;
+ onPaymentRequired: (uri: string) => Promise<void>;
+}
+
+export type State =
+ | State.Loading
+ | State.LoadingUriError
+ | State.ConfirmProvider
+ | State.SelectProvider;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "loading-error";
+ error: HookError;
+ }
+
+ export interface ConfirmProvider {
+ status: "confirm-provider";
+ error: undefined | TalerErrorDetail;
+ url: string;
+ provider: SyncTermsOfServiceResponse;
+ tos: ToggleHandler;
+ onCancel: ButtonHandler;
+ onAccept: ButtonHandler;
+ }
+
+ export interface SelectProvider {
+ status: "select-provider";
+ url: TextFieldHandler;
+ urlOk: boolean;
+ name: TextFieldHandler;
+ onConfirm: ButtonHandler;
+ onCancel: ButtonHandler;
+ error: undefined | TalerErrorDetail;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ "loading-error": LoadingUriView,
+ "select-provider": SelectProviderView,
+ "confirm-provider": ConfirmProviderView,
+};
+
+export const AddBackupProviderPage = compose(
+ "AddBackupProvider",
+ (p: Props) => useComponentState(p, wxApi),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/state.ts b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/state.ts
new file mode 100644
index 000000000..0b3c17902
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/state.ts
@@ -0,0 +1,260 @@
+/*
+ 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,
+ Codec,
+ TalerErrorDetail,
+} from "@gnu-taler/taler-util";
+import {
+ codecForSyncTermsOfServiceResponse,
+ WalletApiOperation,
+} from "@gnu-taler/taler-wallet-core";
+import { useEffect, useState } from "preact/hooks";
+import { assertUnreachable } from "../../utils/index.js";
+import { wxApi } from "../../wxApi.js";
+import { Props, State } from "./index.js";
+
+type UrlState<T> = UrlOk<T> | UrlError;
+
+interface UrlOk<T> {
+ status: "ok";
+ result: T;
+}
+type UrlError =
+ | UrlNetworkError
+ | UrlClientError
+ | UrlServerError
+ | UrlParsingError
+ | UrlReadError;
+
+interface UrlNetworkError {
+ status: "network-error";
+ href: string;
+}
+interface UrlClientError {
+ status: "client-error";
+ code: number;
+}
+interface UrlServerError {
+ status: "server-error";
+ code: number;
+}
+interface UrlParsingError {
+ status: "parsing-error";
+ json: any;
+}
+interface UrlReadError {
+ status: "url-error";
+}
+
+function useDebounceEffect(
+ time: number,
+ cb: undefined | (() => Promise<void>),
+ deps: Array<any>,
+): void {
+ const [currentTimer, setCurrentTimer] = useState<any>();
+ useEffect(() => {
+ if (currentTimer !== undefined) clearTimeout(currentTimer);
+ if (cb !== undefined) {
+ const tid = setTimeout(cb, time);
+ setCurrentTimer(tid);
+ }
+ }, deps);
+}
+
+function useUrlState<T>(
+ host: string | undefined,
+ path: string,
+ codec: Codec<T>,
+): UrlState<T> | undefined {
+ const [state, setState] = useState<UrlState<T> | undefined>();
+
+ let href: string | undefined;
+ try {
+ if (host) {
+ const isHttps =
+ host.startsWith("https://") && host.length > "https://".length;
+ const isHttp =
+ host.startsWith("http://") && host.length > "http://".length;
+ const withProto = isHttp || isHttps ? host : `https://${host}`;
+ const baseUrl = canonicalizeBaseUrl(withProto);
+ href = new URL(path, baseUrl).href;
+ }
+ } catch (e) {
+ setState({
+ status: "url-error",
+ });
+ }
+ const constHref = href;
+
+ useDebounceEffect(
+ 500,
+ constHref == undefined
+ ? undefined
+ : async () => {
+ const req = await fetch(constHref).catch((e) => {
+ return setState({
+ status: "network-error",
+ href: constHref,
+ });
+ });
+ if (!req) return;
+
+ if (req.status >= 400 && req.status < 500) {
+ setState({
+ status: "client-error",
+ code: req.status,
+ });
+ return;
+ }
+ if (req.status > 500) {
+ setState({
+ status: "server-error",
+ code: req.status,
+ });
+ return;
+ }
+
+ const json = await req.json();
+ try {
+ const result = codec.decode(json);
+ setState({ status: "ok", result });
+ } catch (e: any) {
+ setState({ status: "parsing-error", json });
+ }
+ },
+ [host, path],
+ );
+
+ return state;
+}
+
+export function useComponentState(
+ { currency, onBack, onComplete, onPaymentRequired }: Props,
+ api: typeof wxApi,
+): State {
+ const [url, setHost] = useState<string | undefined>();
+ const [name, setName] = useState<string | undefined>();
+ const [tos, setTos] = useState(false);
+ const urlState = useUrlState(
+ url,
+ "config",
+ codecForSyncTermsOfServiceResponse(),
+ );
+ const [operationError, setOperationError] = useState<
+ TalerErrorDetail | undefined
+ >();
+ const [showConfirm, setShowConfirm] = useState(false);
+
+ async function addBackupProvider() {
+ if (!url || !name) return;
+
+ const resp = await api.wallet.call(WalletApiOperation.AddBackupProvider, {
+ backupProviderBaseUrl: url,
+ name: name,
+ activate: true,
+ });
+
+ switch (resp.status) {
+ case "payment-required":
+ return onPaymentRequired(resp.talerUri);
+ case "error":
+ return setOperationError(resp.error);
+ case "ok":
+ return onComplete(url);
+ default:
+ assertUnreachable(resp);
+ }
+ }
+
+ if (showConfirm && urlState && urlState.status === "ok") {
+ return {
+ status: "confirm-provider",
+ error: operationError,
+ onAccept: {
+ onClick: !tos ? undefined : addBackupProvider,
+ },
+ onCancel: {
+ onClick: onBack,
+ },
+ provider: urlState.result,
+ tos: {
+ value: tos,
+ button: {
+ onClick: async () => setTos(!tos),
+ },
+ },
+ url: url ?? "",
+ };
+ }
+
+ return {
+ status: "select-provider",
+ error: undefined,
+ name: {
+ value: name || "",
+ onInput: async (e) => setName(e),
+ error:
+ name === undefined ? undefined : !name ? "Can't be empty" : undefined,
+ },
+ onCancel: {
+ onClick: onBack,
+ },
+ onConfirm: {
+ onClick:
+ !urlState || urlState.status !== "ok" || !name
+ ? undefined
+ : async () => {
+ setShowConfirm(true);
+ },
+ },
+ urlOk: urlState?.status === "ok",
+ url: {
+ value: url || "",
+ onInput: async (e) => setHost(e),
+ error: errorString(urlState),
+ },
+ };
+}
+
+function errorString(state: undefined | UrlState<any>): string | undefined {
+ if (!state) return state;
+ switch (state.status) {
+ case "ok":
+ return undefined;
+ case "client-error": {
+ switch (state.code) {
+ case 404:
+ return "Not found";
+ case 401:
+ return "Unauthorized";
+ case 403:
+ return "Forbidden";
+ default:
+ return `Server says it a client error: ${state.code}.`;
+ }
+ }
+ case "server-error":
+ return `Server had a problem ${state.code}.`;
+ case "parsing-error":
+ return `Server response doesn't have the right format.`;
+ case "network-error":
+ return `Unable to connect to ${state.href}.`;
+ case "url-error":
+ return "URL is not complete";
+ }
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/stories.tsx b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/stories.tsx
new file mode 100644
index 000000000..ae3e1b091
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/stories.tsx
@@ -0,0 +1,109 @@
+/*
+ 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 { createExample } from "../../test-utils.js";
+import { ConfirmProviderView, SelectProviderView } from "./views.js";
+
+export default {
+ title: "wallet/backup/confirm",
+};
+
+export const DemoService = createExample(ConfirmProviderView, {
+ url: "https://sync.demo.taler.net/",
+ provider: {
+ annual_fee: "KUDOS:0.1",
+ storage_limit_in_megabytes: 20,
+ version: "1",
+ },
+ tos: {
+ button: {},
+ },
+ onAccept: {},
+ onCancel: {},
+});
+
+export const FreeService = createExample(ConfirmProviderView, {
+ url: "https://sync.taler:9667/",
+ provider: {
+ annual_fee: "ARS:0",
+ storage_limit_in_megabytes: 20,
+ version: "1",
+ },
+ tos: {
+ button: {},
+ },
+ onAccept: {},
+ onCancel: {},
+});
+
+export const Initial = createExample(SelectProviderView, {
+ url: { value: "" },
+ name: { value: "" },
+ onCancel: {},
+ onConfirm: {},
+});
+
+export const WithValue = createExample(SelectProviderView, {
+ url: {
+ value: "sync.demo.taler.net",
+ },
+ name: {
+ value: "Demo backup service",
+ },
+ onCancel: {},
+ onConfirm: {},
+});
+
+export const WithConnectionError = createExample(SelectProviderView, {
+ url: {
+ value: "sync.demo.taler.net",
+ error: "Network error",
+ },
+ name: {
+ value: "Demo backup service",
+ },
+ onCancel: {},
+ onConfirm: {},
+});
+
+export const WithClientError = createExample(SelectProviderView, {
+ url: {
+ value: "sync.demo.taler.net",
+ error: "URL may not be right: (404) Not Found",
+ },
+ name: {
+ value: "Demo backup service",
+ },
+ onCancel: {},
+ onConfirm: {},
+});
+
+export const WithServerError = createExample(SelectProviderView, {
+ url: {
+ value: "sync.demo.taler.net",
+ error: "Try another server: (500) Internal Server Error",
+ },
+ name: {
+ value: "Demo backup service",
+ },
+ onCancel: {},
+ onConfirm: {},
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/test.ts b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/test.ts
new file mode 100644
index 000000000..1143853f8
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/test.ts
@@ -0,0 +1,79 @@
+/*
+ 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 { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { expect } from "chai";
+import {
+ createWalletApiMock,
+ mountHook,
+ nullFunction,
+} from "../../test-utils.js";
+import { Props } from "./index.js";
+import { useComponentState } from "./state.js";
+
+const props: Props = {
+ currency: "KUDOS",
+ onBack: nullFunction,
+ onComplete: nullFunction,
+ onPaymentRequired: nullFunction,
+};
+describe("AddBackupProvider states", () => {
+ it("should start in 'select-provider' state", async () => {
+ const { handler, mock } = createWalletApiMock();
+
+ // handler.addWalletCallResponse(
+ // WalletApiOperation.ListKnownBankAccounts,
+ // undefined,
+ // {
+ // accounts: [],
+ // },
+ // );
+
+ const { pullLastResultOrThrow, waitForStateUpdate, assertNoPendingUpdate } =
+ mountHook(() => useComponentState(props, mock));
+
+ {
+ const state = pullLastResultOrThrow();
+ expect(state.status).equal("select-provider");
+ if (state.status !== "select-provider") return;
+ expect(state.name.value).eq("");
+ expect(state.url.value).eq("");
+ }
+
+ //FIXME: this should not make an extra update
+ /**
+ * this may be due to useUrlState because is using an effect over
+ * a dependency with a timeout
+ */
+ // NOTE: do not remove this comment, keeping as an example
+ // await waitForStateUpdate()
+ // {
+ // const state = pullLastResultOrThrow();
+ // expect(state.status).equal("select-provider");
+ // if (state.status !== "select-provider") return;
+ // expect(state.name.value).eq("")
+ // expect(state.url.value).eq("")
+ // }
+
+ await assertNoPendingUpdate();
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/views.tsx b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/views.tsx
new file mode 100644
index 000000000..b633a595f
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/views.tsx
@@ -0,0 +1,172 @@
+/*
+ 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 { Amounts } from "@gnu-taler/taler-util";
+import { Fragment, h, VNode } from "preact";
+import { Checkbox } from "../../components/Checkbox.js";
+import { LoadingError } from "../../components/LoadingError.js";
+import {
+ LightText,
+ SmallLightText,
+ SubTitle,
+ TermsOfService,
+ Title,
+} from "../../components/styled/index.js";
+import { useTranslationContext } from "../../context/translation.js";
+import { Button } from "../../mui/Button.js";
+import { TextField } from "../../mui/TextField.js";
+import { State } from "./index.js";
+
+export function LoadingUriView({ error }: State.LoadingUriError): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <LoadingError
+ title={<i18n.Translate>Could not load</i18n.Translate>}
+ error={error}
+ />
+ );
+}
+
+export function ConfirmProviderView({
+ url,
+ provider,
+ tos,
+ onCancel,
+ onAccept,
+}: State.ConfirmProvider): VNode {
+ const { i18n } = useTranslationContext();
+ const noFee = Amounts.isZero(provider.annual_fee);
+ return (
+ <Fragment>
+ <section>
+ <Title>
+ <i18n.Translate>Review terms of service</i18n.Translate>
+ </Title>
+ <div>
+ <i18n.Translate>Provider URL</i18n.Translate>:{" "}
+ <a href={url} target="_blank" rel="noreferrer">
+ {url}
+ </a>
+ </div>
+ <SmallLightText>
+ <i18n.Translate>
+ Please review and accept this provider&apos;s terms of service
+ </i18n.Translate>
+ </SmallLightText>
+ <SubTitle>
+ 1. <i18n.Translate>Pricing</i18n.Translate>
+ </SubTitle>
+ <p>
+ {noFee ? (
+ <i18n.Translate>free of charge</i18n.Translate>
+ ) : (
+ <i18n.Translate>
+ {provider.annual_fee} per year of service
+ </i18n.Translate>
+ )}
+ </p>
+ <SubTitle>
+ 2. <i18n.Translate>Storage</i18n.Translate>
+ </SubTitle>
+ <p>
+ <i18n.Translate>
+ {provider.storage_limit_in_megabytes} megabytes of storage per year
+ of service
+ </i18n.Translate>
+ </p>
+ {/* replace with <TermsOfService /> */}
+ <Checkbox
+ label={<i18n.Translate>Accept terms of service</i18n.Translate>}
+ name="terms"
+ onToggle={tos.button.onClick}
+ enabled={tos.value}
+ />
+ </section>
+ <footer>
+ <Button
+ variant="contained"
+ color="secondary"
+ onClick={onCancel.onClick}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Button>
+ <Button variant="contained" color="primary" onClick={onAccept.onClick}>
+ {noFee ? (
+ <i18n.Translate>Add provider</i18n.Translate>
+ ) : (
+ <i18n.Translate>Pay</i18n.Translate>
+ )}
+ </Button>
+ </footer>
+ </Fragment>
+ );
+}
+
+export function SelectProviderView({
+ url,
+ name,
+ urlOk,
+ onCancel,
+ onConfirm,
+}: State.SelectProvider): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ <section>
+ <Title>
+ <i18n.Translate>Add backup provider</i18n.Translate>
+ </Title>
+ <LightText>
+ <i18n.Translate>
+ Backup providers may charge for their service
+ </i18n.Translate>
+ </LightText>
+ <p>
+ <TextField
+ label={<i18n.Translate>URL</i18n.Translate>}
+ placeholder="https://"
+ color={urlOk ? "success" : undefined}
+ value={url.value}
+ error={url.error}
+ onChange={url.onInput}
+ />
+ </p>
+ <p>
+ <TextField
+ label={<i18n.Translate>Name</i18n.Translate>}
+ placeholder="provider name"
+ value={name.value}
+ error={name.error}
+ onChange={name.onInput}
+ />
+ </p>
+ </section>
+ <footer>
+ <Button
+ variant="contained"
+ color="secondary"
+ onClick={onCancel.onClick}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Button>
+ <Button variant="contained" color="primary" onClick={onConfirm.onClick}>
+ <i18n.Translate>Next</i18n.Translate>
+ </Button>
+ </footer>
+ </Fragment>
+ );
+}