summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2023-02-08 17:41:19 -0300
committerSebastian <sebasjm@gmail.com>2023-02-08 17:41:19 -0300
commita8c5a9696c1735a178158cbc9ac4f9bb4b6f013d (patch)
treefc24dbf06b548925dbc065a49060473fdd220c94
parent9b0d887a1bc292f652352c1dba4ed4243a88bbbe (diff)
downloadwallet-core-a8c5a9696c1735a178158cbc9ac4f9bb4b6f013d.tar.gz
wallet-core-a8c5a9696c1735a178158cbc9ac4f9bb4b6f013d.tar.bz2
wallet-core-a8c5a9696c1735a178158cbc9ac4f9bb4b6f013d.zip
impl accout management and refactor
-rw-r--r--packages/demobank-ui/package.json4
-rw-r--r--packages/demobank-ui/src/components/Cashouts/index.ts69
-rw-r--r--packages/demobank-ui/src/components/Cashouts/state.ts44
-rw-r--r--packages/demobank-ui/src/components/Cashouts/stories.tsx45
-rw-r--r--packages/demobank-ui/src/components/Cashouts/test.ts179
-rw-r--r--packages/demobank-ui/src/components/Cashouts/views.tsx66
-rw-r--r--packages/demobank-ui/src/components/Loading.tsx24
-rw-r--r--packages/demobank-ui/src/components/Transactions/index.ts10
-rw-r--r--packages/demobank-ui/src/components/Transactions/state.ts105
-rw-r--r--packages/demobank-ui/src/components/Transactions/test.ts9
-rw-r--r--packages/demobank-ui/src/components/app.tsx23
-rw-r--r--packages/demobank-ui/src/context/backend.ts4
-rw-r--r--packages/demobank-ui/src/context/pageState.ts21
-rw-r--r--packages/demobank-ui/src/declaration.d.ts362
-rw-r--r--packages/demobank-ui/src/hooks/access.ts330
-rw-r--r--packages/demobank-ui/src/hooks/async.ts1
-rw-r--r--packages/demobank-ui/src/hooks/backend.ts195
-rw-r--r--packages/demobank-ui/src/hooks/circuit.ts317
-rw-r--r--packages/demobank-ui/src/pages/AccountPage.tsx283
-rw-r--r--packages/demobank-ui/src/pages/AdminPage.tsx707
-rw-r--r--packages/demobank-ui/src/pages/BankFrame.tsx42
-rw-r--r--packages/demobank-ui/src/pages/HomePage.tsx149
-rw-r--r--packages/demobank-ui/src/pages/LoginForm.tsx188
-rw-r--r--packages/demobank-ui/src/pages/PaymentOptions.tsx33
-rw-r--r--packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx317
-rw-r--r--packages/demobank-ui/src/pages/PublicHistoriesPage.tsx93
-rw-r--r--packages/demobank-ui/src/pages/QrCodeSection.tsx9
-rw-r--r--packages/demobank-ui/src/pages/RegistrationPage.tsx176
-rw-r--r--packages/demobank-ui/src/pages/Routing.tsx84
-rw-r--r--packages/demobank-ui/src/pages/WalletWithdrawForm.tsx259
-rw-r--r--packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx466
-rw-r--r--packages/demobank-ui/src/pages/WithdrawalQRCode.tsx111
-rw-r--r--packages/demobank-ui/src/scss/bank.scss7
-rw-r--r--packages/demobank-ui/src/utils.ts48
34 files changed, 3534 insertions, 1246 deletions
diff --git a/packages/demobank-ui/package.json b/packages/demobank-ui/package.json
index cdf457ed4..ff402cf3e 100644
--- a/packages/demobank-ui/package.json
+++ b/packages/demobank-ui/package.json
@@ -25,7 +25,7 @@
"preact": "10.11.3",
"preact-router": "3.2.1",
"qrcode-generator": "^1.4.4",
- "swr": "1.3.0"
+ "swr": "2.0.3"
},
"eslintConfig": {
"plugins": [
@@ -66,4 +66,4 @@
"pogen": {
"domain": "bank"
}
-}
+} \ No newline at end of file
diff --git a/packages/demobank-ui/src/components/Cashouts/index.ts b/packages/demobank-ui/src/components/Cashouts/index.ts
new file mode 100644
index 000000000..db39ba7e4
--- /dev/null
+++ b/packages/demobank-ui/src/components/Cashouts/index.ts
@@ -0,0 +1,69 @@
+/*
+ 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 { HttpError, utils } from "@gnu-taler/web-util/lib/index.browser";
+import { Loading } from "../Loading.js";
+// import { compose, StateViewMap } from "../../utils/index.js";
+// import { wxApi } from "../../wxApi.js";
+import { AbsoluteTime, AmountJson } from "@gnu-taler/taler-util";
+import { useComponentState } from "./state.js";
+import { LoadingUriView, ReadyView } from "./views.js";
+
+export interface Props {
+ account: string;
+}
+
+export type State = State.Loading | State.LoadingUriError | State.Ready;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "loading-error";
+ error: HttpError<SandboxBackend.SandboxError>;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ }
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ error: undefined;
+ cashouts: SandboxBackend.Circuit.CashoutStatusResponse[];
+ }
+}
+
+export interface Transaction {
+ negative: boolean;
+ counterpart: string;
+ when: AbsoluteTime;
+ amount: AmountJson | undefined;
+ subject: string;
+}
+
+const viewMapping: utils.StateViewMap<State> = {
+ loading: Loading,
+ "loading-error": LoadingUriView,
+ ready: ReadyView,
+};
+
+export const Cashouts = utils.compose(
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/demobank-ui/src/components/Cashouts/state.ts b/packages/demobank-ui/src/components/Cashouts/state.ts
new file mode 100644
index 000000000..7e420940f
--- /dev/null
+++ b/packages/demobank-ui/src/components/Cashouts/state.ts
@@ -0,0 +1,44 @@
+/*
+ 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 { AbsoluteTime, Amounts } from "@gnu-taler/taler-util";
+import { useCashouts } from "../../hooks/circuit.js";
+import { Props, State, Transaction } from "./index.js";
+
+export function useComponentState({
+ account,
+}: Props): State {
+ const result = useCashouts()
+ if (result.loading) {
+ return {
+ status: "loading",
+ error: undefined
+ }
+ }
+ if (!result.ok) {
+ return {
+ status: "loading-error",
+ error: result
+ }
+ }
+
+
+ return {
+ status: "ready",
+ error: undefined,
+ cashout: result.data,
+ };
+}
diff --git a/packages/demobank-ui/src/components/Cashouts/stories.tsx b/packages/demobank-ui/src/components/Cashouts/stories.tsx
new file mode 100644
index 000000000..77fdde092
--- /dev/null
+++ b/packages/demobank-ui/src/components/Cashouts/stories.tsx
@@ -0,0 +1,45 @@
+/*
+ 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 { tests } from "@gnu-taler/web-util/lib/index.browser";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "transaction list",
+};
+
+export const Ready = tests.createExample(ReadyView, {
+ transactions: [
+ {
+ amount: {
+ currency: "USD",
+ fraction: 0,
+ value: 1,
+ },
+ counterpart: "ASD",
+ negative: false,
+ subject: "Some",
+ when: {
+ t_ms: new Date().getTime(),
+ },
+ },
+ ],
+});
diff --git a/packages/demobank-ui/src/components/Cashouts/test.ts b/packages/demobank-ui/src/components/Cashouts/test.ts
new file mode 100644
index 000000000..3f2d5fb68
--- /dev/null
+++ b/packages/demobank-ui/src/components/Cashouts/test.ts
@@ -0,0 +1,179 @@
+/*
+ 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 { tests } from "@gnu-taler/web-util/lib/index.browser";
+import { SwrMockEnvironment } from "@gnu-taler/web-util/lib/tests/swr";
+import { expect } from "chai";
+import { TRANSACTION_API_EXAMPLE } from "../../endpoints.js";
+import { Props } from "./index.js";
+import { useComponentState } from "./state.js";
+
+describe("Transaction states", () => {
+ it("should query backend and render transactions", async () => {
+ const env = new SwrMockEnvironment();
+
+ const props: Props = {
+ account: "myAccount",
+ };
+
+ env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, {
+ response: {
+ transactions: [
+ {
+ creditorIban: "DE159593",
+ creditorBic: "SANDBOXX",
+ creditorName: "exchange company",
+ debtorIban: "DE118695",
+ debtorBic: "SANDBOXX",
+ debtorName: "Name unknown",
+ amount: "1",
+ currency: "KUDOS",
+ subject:
+ "Taler Withdrawal N588V8XE9TR49HKAXFQ20P0EQ0EYW2AC9NNANV8ZP5P59N6N0410",
+ date: "2022-12-12Z",
+ uid: "8PPFR9EM",
+ direction: "DBIT",
+ pmtInfId: null,
+ msgId: null,
+ },
+ {
+ creditorIban: "DE159593",
+ creditorBic: "SANDBOXX",
+ creditorName: "exchange company",
+ debtorIban: "DE118695",
+ debtorBic: "SANDBOXX",
+ debtorName: "Name unknown",
+ amount: "5.00",
+ currency: "KUDOS",
+ subject: "HNEWWT679TQC5P1BVXJS48FX9NW18FWM6PTK2N80Z8GVT0ACGNK0",
+ date: "2022-12-07Z",
+ uid: "7FZJC3RJ",
+ direction: "DBIT",
+ pmtInfId: null,
+ msgId: null,
+ },
+ {
+ creditorIban: "DE118695",
+ creditorBic: "SANDBOXX",
+ creditorName: "Name unknown",
+ debtorIban: "DE579516",
+ debtorBic: "SANDBOXX",
+ debtorName: "The Bank",
+ amount: "100",
+ currency: "KUDOS",
+ subject: "Sign-up bonus",
+ date: "2022-12-07Z",
+ uid: "I31A06J8",
+ direction: "CRDT",
+ pmtInfId: null,
+ msgId: null,
+ },
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ ({ status, error }) => {
+ expect(status).equals("ready");
+ expect(error).undefined;
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should show error message on not found", async () => {
+ const env = new SwrMockEnvironment();
+
+ const props: Props = {
+ account: "myAccount",
+ };
+
+ env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_NOT_FOUND, {});
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ ({ status, error }) => {
+ expect(status).equals("loading-error");
+ expect(error).deep.eq({
+ hasError: true,
+ operational: false,
+ message: "Transactions page 0 was not found.",
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should show error message on server error", async () => {
+ const env = new SwrMockEnvironment(false);
+
+ const props: Props = {
+ account: "myAccount",
+ };
+
+ env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, {});
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ ({ status, error }) => {
+ expect(status).equals("loading-error");
+ expect(error).deep.equal({
+ hasError: true,
+ operational: false,
+ message: "Transaction page 0 could not be retrieved.",
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/demobank-ui/src/components/Cashouts/views.tsx b/packages/demobank-ui/src/components/Cashouts/views.tsx
new file mode 100644
index 000000000..30803d4d1
--- /dev/null
+++ b/packages/demobank-ui/src/components/Cashouts/views.tsx
@@ -0,0 +1,66 @@
+/*
+ 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 { h, VNode } from "preact";
+import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
+import { State } from "./index.js";
+import { format } from "date-fns";
+import { Amounts } from "@gnu-taler/taler-util";
+
+export function LoadingUriView({ error }: State.LoadingUriError): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div>
+ <i18n.Translate>Could not load</i18n.Translate>
+ </div>
+ );
+}
+
+export function ReadyView({ cashouts }: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="results">
+ <table class="pure-table pure-table-striped">
+ <thead>
+ <tr>
+ <th>{i18n.str`Created`}</th>
+ <th>{i18n.str`Confirmed`}</th>
+ <th>{i18n.str`Counterpart`}</th>
+ <th>{i18n.str`Subject`}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {cashouts.map((item, idx) => {
+ return (
+ <tr key={idx}>
+ <td>{format(item.creation_time, "dd/MM/yyyy HH:mm:ss")}</td>
+ <td>
+ {item.confirmation_time
+ ? format(item.confirmation_time, "dd/MM/yyyy HH:mm:ss")
+ : "-"}
+ </td>
+ <td>{Amounts.stringifyValue(item.amount_credit)}</td>
+ <td>{item.counterpart}</td>
+ <td>{item.subject}</td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ );
+}
diff --git a/packages/demobank-ui/src/components/Loading.tsx b/packages/demobank-ui/src/components/Loading.tsx
index 8fd01858b..7cbdad681 100644
--- a/packages/demobank-ui/src/components/Loading.tsx
+++ b/packages/demobank-ui/src/components/Loading.tsx
@@ -17,5 +17,27 @@
import { h, VNode } from "preact";
export function Loading(): VNode {
- return <div>loading...</div>;
+ return (
+ <div
+ class="columns is-centered is-vcentered"
+ style={{
+ height: "calc(100% - 3rem)",
+ position: "absolute",
+ width: "100%",
+ }}
+ >
+ <Spinner />
+ </div>
+ );
+}
+
+export function Spinner(): VNode {
+ return (
+ <div class="lds-ring">
+ <div />
+ <div />
+ <div />
+ <div />
+ </div>
+ );
}
diff --git a/packages/demobank-ui/src/components/Transactions/index.ts b/packages/demobank-ui/src/components/Transactions/index.ts
index 0c9084946..e43b9401c 100644
--- a/packages/demobank-ui/src/components/Transactions/index.ts
+++ b/packages/demobank-ui/src/components/Transactions/index.ts
@@ -14,18 +14,16 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+import { HttpError, utils } from "@gnu-taler/web-util/lib/index.browser";
import { Loading } from "../Loading.js";
-import { HookError, utils } from "@gnu-taler/web-util/lib/index.browser";
// import { compose, StateViewMap } from "../../utils/index.js";
// import { wxApi } from "../../wxApi.js";
+import { AbsoluteTime, AmountJson } from "@gnu-taler/taler-util";
import { useComponentState } from "./state.js";
import { LoadingUriView, ReadyView } from "./views.js";
-import { AbsoluteTime, AmountJson } from "@gnu-taler/taler-util";
export interface Props {
- pageNumber: number;
- accountLabel: string;
- balanceValue?: string;
+ account: string;
}
export type State = State.Loading | State.LoadingUriError | State.Ready;
@@ -38,7 +36,7 @@ export namespace State {
export interface LoadingUriError {
status: "loading-error";
- error: HookError;
+ error: HttpError<SandboxBackend.SandboxError>;
}
export interface BaseInfo {
diff --git a/packages/demobank-ui/src/components/Transactions/state.ts b/packages/demobank-ui/src/components/Transactions/state.ts
index a5087ef32..9e1bce39b 100644
--- a/packages/demobank-ui/src/components/Transactions/state.ts
+++ b/packages/demobank-ui/src/components/Transactions/state.ts
@@ -15,66 +15,65 @@
*/
import { AbsoluteTime, Amounts } from "@gnu-taler/taler-util";
-import { parse } from "date-fns";
-import { useEffect } from "preact/hooks";
-import useSWR from "swr";
-import { Props, State } from "./index.js";
+import { useTransactions } from "../../hooks/access.js";
+import { Props, State, Transaction } from "./index.js";
export function useComponentState({
- accountLabel,
- pageNumber,
- balanceValue,
+ account,
}: Props): State {
- const { data, error, mutate } = useSWR(
- `access-api/accounts/${accountLabel}/transactions?page=${pageNumber}`,
- );
-
- useEffect(() => {
- if (balanceValue) {
- mutate();
- }
- }, [balanceValue ?? ""]);
-
- if (error) {
- switch (error.status) {
- case 404:
- return {
- status: "loading-error",
- error: {
- hasError: true,
- operational: false,
- message: `Transactions page ${pageNumber} was not found.`,
- },
- };
- case 401:
- return {
- status: "loading-error",
- error: {
- hasError: true,
- operational: false,
- message: "Wrong credentials given.",
- },
- };
- default:
- return {
- status: "loading-error",
- error: {
- hasError: true,
- operational: false,
- message: `Transaction page ${pageNumber} could not be retrieved.`,
- } as any,
- };
+ const result = useTransactions(account)
+ if (result.loading) {
+ return {
+ status: "loading",
+ error: undefined
}
}
-
- if (!data) {
+ if (!result.ok) {
return {
- status: "loading",
- error: undefined,
- };
+ status: "loading-error",
+ error: result
+ }
}
+ // if (error) {
+ // switch (error.status) {
+ // case 404:
+ // return {
+ // status: "loading-error",
+ // error: {
+ // hasError: true,
+ // operational: false,
+ // message: `Transactions page ${pageNumber} was not found.`,
+ // },
+ // };
+ // case 401:
+ // return {
+ // status: "loading-error",
+ // error: {
+ // hasError: true,
+ // operational: false,
+ // message: "Wrong credentials given.",
+ // },
+ // };
+ // default:
+ // return {
+ // status: "loading-error",
+ // error: {
+ // hasError: true,
+ // operational: false,
+ // message: `Transaction page ${pageNumber} could not be retrieved.`,
+ // } as any,
+ // };
+ // }
+ // }
+
+ // if (!data) {
+ // return {
+ // status: "loading",
+ // error: undefined,
+ // };
+ // }
- const transactions = data.transactions.map((item: unknown) => {
+ const transactions = result.data.transactions.map((item: unknown) => {
if (
!item ||
typeof item !== "object" ||
@@ -120,7 +119,7 @@ export function useComponentState({
amount,
subject,
};
- });
+ }).filter((x): x is Transaction => x !== undefined);
return {
status: "ready",
diff --git a/packages/demobank-ui/src/components/Transactions/test.ts b/packages/demobank-ui/src/components/Transactions/test.ts
index 21a0eefbb..3f2d5fb68 100644
--- a/packages/demobank-ui/src/components/Transactions/test.ts
+++ b/packages/demobank-ui/src/components/Transactions/test.ts
@@ -31,8 +31,7 @@ describe("Transaction states", () => {
const env = new SwrMockEnvironment();
const props: Props = {
- accountLabel: "myAccount",
- pageNumber: 0,
+ account: "myAccount",
};
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, {
@@ -116,8 +115,7 @@ describe("Transaction states", () => {
const env = new SwrMockEnvironment();
const props: Props = {
- accountLabel: "myAccount",
- pageNumber: 0,
+ account: "myAccount",
};
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_NOT_FOUND, {});
@@ -150,8 +148,7 @@ describe("Transaction states", () => {
const env = new SwrMockEnvironment(false);
const props: Props = {
- accountLabel: "myAccount",
- pageNumber: 0,
+ account: "myAccount",
};
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, {});
diff --git a/packages/demobank-ui/src/components/app.tsx b/packages/demobank-ui/src/components/app.tsx
index 8679b05dd..e024be41b 100644
--- a/packages/demobank-ui/src/components/app.tsx
+++ b/packages/demobank-ui/src/components/app.tsx
@@ -24,6 +24,9 @@ import { PageStateProvider } from "../context/pageState.js";
import { Routing } from "../pages/Routing.js";
import { strings } from "../i18n/strings.js";
import { TranslationProvider } from "@gnu-taler/web-util/lib/index.browser";
+import { SWRConfig } from "swr";
+
+const WITH_LOCAL_STORAGE_CACHE = false;
/**
* FIXME:
@@ -47,7 +50,15 @@ const App: FunctionalComponent = () => {
<TranslationProvider source={strings}>
<PageStateProvider>
<BackendStateProvider>
- <Routing />
+ <SWRConfig
+ value={{
+ provider: WITH_LOCAL_STORAGE_CACHE
+ ? localStorageProvider
+ : undefined,
+ }}
+ >
+ <Routing />
+ </SWRConfig>
</BackendStateProvider>
</PageStateProvider>
</TranslationProvider>
@@ -58,4 +69,14 @@ const App: FunctionalComponent = () => {
return globalLogLevel;
};
+function localStorageProvider(): Map<unknown, unknown> {
+ const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]"));
+
+ window.addEventListener("beforeunload", () => {
+ const appCache = JSON.stringify(Array.from(map.entries()));
+ localStorage.setItem("app-cache", appCache);
+ });
+ return map;
+}
+
export default App;
diff --git a/packages/demobank-ui/src/context/backend.ts b/packages/demobank-ui/src/context/backend.ts
index 58907e565..b462d20e3 100644
--- a/packages/demobank-ui/src/context/backend.ts
+++ b/packages/demobank-ui/src/context/backend.ts
@@ -31,10 +31,10 @@ export type Type = BackendStateHandler;
const initial: Type = {
state: defaultState,
- clear() {
+ logOut() {
null;
},
- save(info) {
+ logIn(info) {
null;
},
};
diff --git a/packages/demobank-ui/src/context/pageState.ts b/packages/demobank-ui/src/context/pageState.ts
index fd7a6c90c..d5428b9b7 100644
--- a/packages/demobank-ui/src/context/pageState.ts
+++ b/packages/demobank-ui/src/context/pageState.ts
@@ -14,6 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+import { TranslatedString } from "@gnu-taler/taler-util";
import { useNotNullLocalStorage } from "@gnu-taler/web-util/lib/index.browser";
import { ComponentChildren, createContext, h, VNode } from "preact";
import { StateUpdater, useContext } from "preact/hooks";
@@ -29,7 +30,6 @@ export type Type = {
};
const initial: Type = {
pageState: {
- isRawPayto: false,
withdrawalInProgress: false,
},
pageStateSetter: () => {
@@ -58,7 +58,6 @@ export const PageStateProvider = ({
*/
function usePageState(
state: PageStateType = {
- isRawPayto: false,
withdrawalInProgress: false,
},
): [PageStateType, StateUpdater<PageStateType>] {
@@ -92,24 +91,24 @@ function usePageState(
return [retObj, removeLatestInfo];
}
+export type ErrorMessage = {
+ description?: string;
+ title: TranslatedString;
+ debug?: string;
+}
/**
* Track page state.
*/
export interface PageStateType {
- isRawPayto: boolean;
- withdrawalInProgress: boolean;
- error?: {
- description?: string;
- title: string;
- debug?: string;
- };
+ error?: ErrorMessage;
+ info?: TranslatedString;
- info?: string;
+ withdrawalInProgress: boolean;
talerWithdrawUri?: string;
/**
* Not strictly a presentational value, could
* be moved in a future "withdrawal state" object.
*/
withdrawalId?: string;
- timestamp?: number;
+
}
diff --git a/packages/demobank-ui/src/declaration.d.ts b/packages/demobank-ui/src/declaration.d.ts
index 29538e44a..cf3eb5774 100644
--- a/packages/demobank-ui/src/declaration.d.ts
+++ b/packages/demobank-ui/src/declaration.d.ts
@@ -30,10 +30,6 @@ declare module "*.png" {
const content: any;
export default content;
}
-declare module "jed" {
- const x: any;
- export = x;
-}
/**********************************************
* Type definitions for states and API calls. *
@@ -73,3 +69,361 @@ interface WireTransferRequestType {
subject?: string;
amount?: string;
}
+
+
+type HashCode = string;
+type EddsaPublicKey = string;
+type EddsaSignature = string;
+type WireTransferIdentifierRawP = string;
+type RelativeTime = Duration;
+type ImageDataUrl = string;
+
+interface WithId {
+ id: string;
+}
+
+interface Timestamp {
+ // Milliseconds since epoch, or the special
+ // value "forever" to represent an event that will
+ // never happen.
+ t_s: number | "never";
+}
+interface Duration {
+ d_us: number | "forever";
+}
+
+interface WithId {
+ id: string;
+}
+
+type Amount = string;
+type UUID = string;
+type Integer = number;
+
+namespace SandboxBackend {
+
+ export interface Config {
+ // Name of this API, always "circuit".
+ name: string;
+ // API version in the form $n:$n:$n
+ version: string;
+ // Contains ratios and fees related to buying
+ // and selling the circuit currency.
+ ratios_and_fees: RatiosAndFees;
+ }
+ interface RatiosAndFees {
+ // Exchange rate to buy the circuit currency from fiat.
+ buy_at_ratio: number;
+ // Exchange rate to sell the circuit currency for fiat.
+ sell_at_ratio: number;
+ // Fee to subtract after applying the buy ratio.
+ buy_in_fee: number;
+ // Fee to subtract after applying the sell ratio.
+ sell_out_fee: number;
+ }
+
+ export interface SandboxError {
+ error: SandboxErrorDetail;
+ }
+ interface SandboxErrorDetail {
+
+ // String enum classifying the error.
+ type: ErrorType;
+
+ // Human-readable error description.
+ description: string;
+ }
+ enum ErrorType {
+ /**
+ * This error can be related to a business operation,
+ * a non-existent object requested by the client, or
+ * even when the bank itself fails.
+ */
+ SandboxError = "sandbox-error",
+
+ /**
+ * It is the error type thrown by helper functions
+ * from the Util library. Those are used by both
+ * Sandbox and Nexus, therefore the actual meaning
+ * must be carried by the error 'message' field.
+ */
+ UtilError = "util-error"
+ }
+
+ namespace Access {
+
+ interface PublicAccountsResponse {
+ publicAccounts: PublicAccount[]
+ }
+ interface PublicAccount {
+ iban: string;
+ balance: string;
+ // The account name _and_ the username of the
+ // Sandbox customer that owns such a bank account.
+ accountLabel: string;
+ }
+
+ interface BankAccountBalanceResponse {
+ // Available balance on the account.
+ balance: {
+ amount: Amount;
+ credit_debit_indicator: "credit" | "debit";
+ };
+ // payto://-URI of the account. (New)
+ paytoUri: string;
+ }
+ interface BankAccountCreateWithdrawalRequest {
+ // Amount to withdraw.
+ amount: Amount;
+ }
+ interface BankAccountCreateWithdrawalResponse {
+ // ID of the withdrawal, can be used to view/modify the withdrawal operation.
+ withdrawal_id: string;
+
+ // URI that can be passed to the wallet to initiate the withdrawal.
+ taler_withdraw_uri: string;
+ }
+ interface BankAccountGetWithdrawalResponse {
+ // Amount that will be withdrawn with this withdrawal operation.
+ amount: Amount;
+
+ // Was the withdrawal aborted?
+ aborted: boolean;
+
+ // Has the withdrawal been confirmed by the bank?
+ // The wire transfer for a withdrawal is only executed once
+ // both confirmation_done is true and selection_done is true.
+ confirmation_done: boolean;
+
+ // Did the wallet select reserve details?
+ selection_done: boolean;
+
+ // Reserve public key selected by the exchange,
+ // only non-null if selection_done is true.
+ selected_reserve_pub: string | null;
+
+ // Exchange account selected by the wallet, or by the bank
+ // (with the default exchange) in case the wallet did not provide one
+ // through the Integration API.
+ selected_exchange_account: string | null;
+ }
+
+ interface BankAccountTransactionsResponse {
+ transactions: BankAccountTransactionInfo[];
+ }
+
+ interface BankAccountTransactionInfo {
+
+ creditorIban: string;
+ creditorBic: string; // Optional
+ creditorName: string;
+
+ debtorIban: string;
+ debtorBic: string;
+ debtorName: string;
+
+ amount: number;
+ currency: string;
+ subject: string;
+
+ // Transaction unique ID. Matches
+ // $transaction_id from the URI.
+ uid: string;
+ direction: "DBIT" | "CRDT";
+ date: string; // milliseconds since the Unix epoch
+ }
+ interface CreateBankAccountTransactionCreate {
+
+ // Address in the Payto format of the wire transfer receiver.
+ // It needs at least the 'message' query string parameter.
+ paytoUri: string;
+
+ // Transaction amount (in the $currency:x.y format), optional.
+ // However, when not given, its value must occupy the 'amount'
+ // query string parameter of the 'payto' field. In case it
+ // is given in both places, the paytoUri's takes the precedence.
+ amount?: string;
+ }
+
+ interface BankRegistrationRequest {
+ username: string;
+
+ password: string;
+ }
+
+ }
+
+ namespace Circuit {
+ interface CircuitAccountRequest {
+ // Username
+ username: string;
+
+ // Password.
+ password: string;
+
+ // Addresses where to send the TAN. If
+ // this field is missing, then the cashout
+ // won't succeed.
+ contact_data: CircuitContactData;
+
+ // Legal subject owning the account.
+ name: string;
+
+ // 'payto' address pointing the bank account
+ // where to send payments, in case the user
+ // wants to convert the local currency back
+ // to fiat.
+ cashout_address: string;
+
+ // IBAN of this bank account, which is therefore
+ // internal to the circuit. Randomly generated,
+ // when it is not given.
+ internal_iban?: string;
+ }
+ interface CircuitContactData {
+
+ // E-Mail address
+ email?: string;
+
+ // Phone number.
+ phone?: string;
+ }
+ interface CircuitAccountReconfiguration {
+
+ // Addresses where to send the TAN.
+ contact_data: CircuitContactData;
+
+ // 'payto' address pointing the bank account
+ // where to send payments, in case the user
+ // wants to convert the local currency back
+ // to fiat.
+ cashout_address: string;
+ }
+ interface AccountPasswordChange {
+
+ // New password.
+ new_password: string;
+ }
+
+ interface CircuitAccounts {
+ customers: CircuitAccountMinimalData[];
+ }
+ interface CircuitAccountMinimalData {
+ // Username
+ username: string;
+
+ // Legal subject owning the account.
+ name: string;
+
+ }
+
+ interface CircuitAccountData {
+ // Username
+ username: string;
+
+ // IBAN hosted at Libeufin Sandbox
+ iban: string;
+
+ contact_data: CircuitContactData;
+
+ // Legal subject owning the account.
+ name: string;
+
+ // 'payto' address pointing the bank account
+ // where to send cashouts.
+ cashout_address: string;
+ }
+ enum TanChannel {
+ SMS = "sms",
+ EMAIL = "email",
+ FILE = "file"
+ }
+ interface CashoutRequest {
+
+ // Optional subject to associate to the
+ // cashout operation. This data will appear
+ // as the incoming wire transfer subject in
+ // the user's external bank account.
+ subject?: string;
+
+ // That is the plain amount that the user specified
+ // to cashout. Its $currency is the circuit currency.
+ amount_debit: Amount;
+
+ // That is the amount that will effectively be
+ // transferred by the bank to the user's bank
+ // account, that is external to the circuit.
+ // It is expressed in the fiat currency and
+ // is calculated after the cashout fee and the
+ // exchange rate. See the /cashout-rates call.
+ amount_credit: Amount;
+
+ // Which channel the TAN should be sent to. If
+ // this field is missing, it defaults to SMS.
+ // The default choice prefers to change the communication
+ // channel respect to the one used to issue this request.
+ tan_channel?: TanChannel;
+ }
+ interface CashoutPending {
+ // UUID identifying the operation being created
+ // and now waiting for the TAN confirmation.
+ uuid: string;
+ }
+ interface CashoutConfirm {
+
+ // the TAN that confirms $cashoutId.
+ tan: string;
+ }
+ interface Config {
+ // Name of this API, always "circuit".
+ name: string;
+ // API version in the form $n:$n:$n
+ version: string;
+ // Contains ratios and fees related to buying
+ // and selling the circuit currency.
+ ratios_and_fees: RatiosAndFees;
+ }
+ interface RatiosAndFees {
+ // Exchange rate to buy the circuit currency from fiat.
+ buy_at_ratio: float;
+ // Exchange rate to sell the circuit currency for fiat.
+ sell_at_ratio: float;
+ // Fee to subtract after applying the buy ratio.
+ buy_in_fee: float;
+ // Fee to subtract after applying the sell ratio.
+ sell_out_fee: float;
+ }
+ interface Cashouts {
+ // Every string represents a cash-out operation UUID.
+ cashouts: string[];
+ }
+ interface CashoutStatusResponse {
+
+ status: CashoutStatus;
+ // Amount debited to the circuit bank account.
+ amount_debit: Amount;
+ // Amount credited to the external bank account.
+ amount_credit: Amount;
+ // Transaction subject.
+ subject: string;
+ // Circuit bank account that created the cash-out.
+ account: string;
+ // Time when the cash-out was created.
+ creation_time: number; // milliseconds since the Unix epoch
+ // Time when the cash-out was confirmed via its TAN.
+ // Missing or null, when the operation wasn't confirmed yet.
+ confirmation_time?: number | null; // milliseconds since the Unix epoch
+ }
+ enum CashoutStatus {
+
+ // The payment was initiated after a valid
+ // TAN was received by the bank.
+ CONFIRMED = "confirmed",
+
+ // The cashout was created and now waits
+ // for the TAN by the author.
+ PENDING = "pending",
+ }
+ }
+
+}
diff --git a/packages/demobank-ui/src/hooks/access.ts b/packages/demobank-ui/src/hooks/access.ts
new file mode 100644
index 000000000..4d4574dac
--- /dev/null
+++ b/packages/demobank-ui/src/hooks/access.ts
@@ -0,0 +1,330 @@
+/*
+ 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 useSWR from "swr";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js";
+import { useEffect, useState } from "preact/hooks";
+import {
+ HttpError,
+ HttpResponse,
+ HttpResponseOk,
+ HttpResponsePaginated,
+} from "@gnu-taler/web-util/lib/index.browser";
+import { useAuthenticatedBackend, useMatchMutate, usePublicBackend } from "./backend.js";
+import { useBackendContext } from "../context/backend.js";
+
+export function useAccessAPI(): AccessAPI {
+ const mutateAll = useMatchMutate();
+ const { request } = useAuthenticatedBackend();
+ const { state } = useBackendContext()
+ if (state.status === "loggedOut") {
+ throw Error("access-api can't be used when the user is not logged In")
+ }
+ const account = state.username
+
+ const createWithdrawal = async (
+ data: SandboxBackend.Access.BankAccountCreateWithdrawalRequest,
+ ): Promise<HttpResponseOk<SandboxBackend.Access.BankAccountCreateWithdrawalResponse>> => {
+ const res = await request<SandboxBackend.Access.BankAccountCreateWithdrawalResponse>(`access-api/accounts/${account}/withdrawals`, {
+ method: "POST",
+ data,
+ contentType: "json"
+ });
+ return res;
+ };
+ const abortWithdrawal = async (
+ id: string,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`access-api/accounts/${account}/withdrawals/${id}`, {
+ method: "POST",
+ contentType: "json"
+ });
+ await mutateAll(/.*accounts\/.*\/withdrawals\/.*/);
+ return res;
+ };
+ const confirmWithdrawal = async (
+ id: string,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`access-api/accounts/${account}/withdrawals/${id}`, {
+ method: "POST",
+ contentType: "json"
+ });
+ await mutateAll(/.*accounts\/.*\/withdrawals\/.*/);
+ return res;
+ };
+ const createTransaction = async (
+ data: SandboxBackend.Access.CreateBankAccountTransactionCreate
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`access-api/accounts/${account}/transactions`, {
+ method: "POST",
+ data,
+ contentType: "json"
+ });
+ await mutateAll(/.*accounts\/.*\/transactions.*/);
+ return res;
+ };
+ const deleteAccount = async (
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`access-api/accounts/${account}`, {
+ method: "DELETE",
+ contentType: "json"
+ });
+ await mutateAll(/.*accounts\/.*/);
+ return res;
+ };
+
+ return { abortWithdrawal, confirmWithdrawal, createWithdrawal, createTransaction, deleteAccount };
+}
+
+export function useTestingAPI(): TestingAPI {
+ const mutateAll = useMatchMutate();
+ const { request: noAuthRequest } = usePublicBackend();
+ const register = async (
+ data: SandboxBackend.Access.BankRegistrationRequest
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await noAuthRequest<void>(`access-api/testing/register`, {
+ method: "POST",
+ data,
+ contentType: "json"
+ });
+ await mutateAll(/.*accounts\/.*/);
+ return res;
+ };
+
+ return { register };
+}
+
+
+export interface TestingAPI {
+ register: (
+ data: SandboxBackend.Access.BankRegistrationRequest
+ ) => Promise<HttpResponseOk<void>>;
+}
+
+export interface AccessAPI {
+ createWithdrawal: (
+ data: SandboxBackend.Access.BankAccountCreateWithdrawalRequest,
+ ) => Promise<HttpResponseOk<SandboxBackend.Access.BankAccountCreateWithdrawalResponse>>;
+ abortWithdrawal: (
+ wid: string,
+ ) => Promise<HttpResponseOk<void>>;
+ confirmWithdrawal: (
+ wid: string
+ ) => Promise<HttpResponseOk<void>>;
+ createTransaction: (
+ data: SandboxBackend.Access.CreateBankAccountTransactionCreate
+ ) => Promise<HttpResponseOk<void>>;
+ deleteAccount: () => Promise<HttpResponseOk<void>>;
+}
+
+export interface InstanceTemplateFilter {
+ //FIXME: add filter to the template list
+ position?: string;
+}
+
+
+export function useAccountDetails(account: string): HttpResponse<SandboxBackend.Access.BankAccountBalanceResponse, SandboxBackend.SandboxError> {
+ const { fetcher } = useAuthenticatedBackend();
+
+ const { data, error } = useSWR<
+ HttpResponseOk<SandboxBackend.Access.BankAccountBalanceResponse>,
+ HttpError<SandboxBackend.SandboxError>
+ >([`access-api/accounts/${account}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ });
+
+ if (data) return data;
+ if (error) return error;
+ return { loading: true };
+}
+
+// FIXME: should poll
+export function useWithdrawalDetails(account: string, wid: string): HttpResponse<SandboxBackend.Access.BankAccountGetWithdrawalResponse, SandboxBackend.SandboxError> {
+ const { fetcher } = useAuthenticatedBackend();
+
+ const { data, error } = useSWR<
+ HttpResponseOk<SandboxBackend.Access.BankAccountGetWithdrawalResponse>,
+ HttpError<SandboxBackend.SandboxError>
+ >([`access-api/accounts/${account}/withdrawals/${wid}`], fetcher, {
+ refreshInterval: 1000,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+
+ });
+
+ // if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error;
+ return { loading: true };
+}
+
+export function useTransactionDetails(account: string, tid: string): HttpResponse<SandboxBackend.Access.BankAccountTransactionInfo, SandboxBackend.SandboxError> {
+ const { fetcher } = useAuthenticatedBackend();
+
+ const { data, error } = useSWR<
+ HttpResponseOk<SandboxBackend.Access.BankAccountTransactionInfo>,
+ HttpError<SandboxBackend.SandboxError>
+ >([`access-api/accounts/${account}/transactions/${tid}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ });
+
+ // if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error;
+ return { loading: true };
+}
+
+interface PaginationFilter {
+ page: number,
+}
+
+export function usePublicAccounts(
+ args?: PaginationFilter,
+): HttpResponsePaginated<SandboxBackend.Access.PublicAccountsResponse, SandboxBackend.SandboxError> {
+ const { paginatedFetcher } = usePublicBackend();
+
+ const [page, setPage] = useState(1);
+
+ const {
+ data: afterData,
+ error: afterError,
+ isValidating: loadingAfter,
+ } = useSWR<
+ HttpResponseOk<SandboxBackend.Access.PublicAccountsResponse>,
+ HttpError<SandboxBackend.SandboxError>
+ >([`public-accounts`, args?.page, PAGE_SIZE], paginatedFetcher);
+
+ const [lastAfter, setLastAfter] = useState<
+ HttpResponse<SandboxBackend.Access.PublicAccountsResponse, SandboxBackend.SandboxError>
+ >({ loading: true });
+
+ useEffect(() => {
+ if (afterData) setLastAfter(afterData);
+ }, [afterData]);
+
+ if (afterError) return afterError;
+
+ // if the query returns less that we ask, then we have reach the end or beginning
+ const isReachingEnd =
+ afterData && afterData.data.publicAccounts.length < PAGE_SIZE;
+ const isReachingStart = false;
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data.publicAccounts.length < MAX_RESULT_SIZE) {
+ setPage(page + 1);
+ }
+ },
+ loadMorePrev: () => {
+ null
+ },
+ };
+
+ const publicAccounts = !afterData ? [] : (afterData || lastAfter).data.publicAccounts;
+ if (loadingAfter)
+ return { loading: true, data: { publicAccounts } };
+ if (afterData) {
+ return { ok: true, data: { publicAccounts }, ...pagination };
+ }
+ return { loading: true };
+}
+
+
+/**
+ * FIXME: mutate result when balance change (transaction )
+ * @param account
+ * @param args
+ * @returns
+ */
+export function useTransactions(
+ account: string,
+ args?: PaginationFilter,
+): HttpResponsePaginated<SandboxBackend.Access.BankAccountTransactionsResponse, SandboxBackend.SandboxError> {
+ const { paginatedFetcher } = useAuthenticatedBackend();
+
+ const [page, setPage] = useState(1);
+
+ const {
+ data: afterData,
+ error: afterError,
+ isValidating: loadingAfter,
+ } = useSWR<
+ HttpResponseOk<SandboxBackend.Access.BankAccountTransactionsResponse>,
+ HttpError<SandboxBackend.SandboxError>
+ >([`access-api/accounts/${account}/transactions`, args?.page, PAGE_SIZE], paginatedFetcher);
+
+ const [lastAfter, setLastAfter] = useState<
+ HttpResponse<SandboxBackend.Access.BankAccountTransactionsResponse, SandboxBackend.SandboxError>
+ >({ loading: true });
+
+ useEffect(() => {
+ if (afterData) setLastAfter(afterData);
+ }, [afterData]);
+
+ if (afterError) return afterError;
+
+ // if the query returns less that we ask, then we have reach the end or beginning
+ const isReachingEnd =
+ afterData && afterData.data.transactions.length < PAGE_SIZE;
+ const isReachingStart = false;
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data.transactions.length < MAX_RESULT_SIZE) {
+ setPage(page + 1);
+ }
+ },
+ loadMorePrev: () => {
+ null
+ },
+ };
+
+ const transactions = !afterData ? [] : (afterData || lastAfter).data.transactions;
+ if (loadingAfter)
+ return { loading: true, data: { transactions } };
+ if (afterData) {
+ return { ok: true, data: { transactions }, ...pagination };
+ }
+ return { loading: true };
+}
diff --git a/packages/demobank-ui/src/hooks/async.ts b/packages/demobank-ui/src/hooks/async.ts
index 6492b7729..b968cfb84 100644
--- a/packages/demobank-ui/src/hooks/async.ts
+++ b/packages/demobank-ui/src/hooks/async.ts
@@ -62,7 +62,6 @@ export function useAsync<T>(
};
function cancel() {
- // cancelPendingRequest()
setLoading(false);
setSlow(false);
}
diff --git a/packages/demobank-ui/src/hooks/backend.ts b/packages/demobank-ui/src/hooks/backend.ts
index 13a158f4f..f4f5ecfd0 100644
--- a/packages/demobank-ui/src/hooks/backend.ts
+++ b/packages/demobank-ui/src/hooks/backend.ts
@@ -14,7 +14,17 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+import { canonicalizeBaseUrl } from "@gnu-taler/taler-util";
import { useLocalStorage } from "@gnu-taler/web-util/lib/index.browser";
+import {
+ HttpResponse,
+ HttpResponseOk,
+ RequestOptions,
+} from "@gnu-taler/web-util/lib/index.browser";
+import { useApiContext } from "@gnu-taler/web-util/lib/index.browser";
+import { useCallback, useEffect, useState } from "preact/hooks";
+import { useSWRConfig } from "swr";
+import { useBackendContext } from "../context/backend.js";
/**
* Has the information to reach and
@@ -22,25 +32,38 @@ import { useLocalStorage } from "@gnu-taler/web-util/lib/index.browser";
*/
export type BackendState = LoggedIn | LoggedOut;
-export interface BackendInfo {
- url: string;
+export interface BackendCredentials {
username: string;
password: string;
}
-interface LoggedIn extends BackendInfo {
+interface LoggedIn extends BackendCredentials {
+ url: string;
status: "loggedIn";
+ isUserAdministrator: boolean;
}
interface LoggedOut {
+ url: string;
status: "loggedOut";
}
-export const defaultState: BackendState = { status: "loggedOut" };
+const maybeRootPath = "https://bank.demo.taler.net/demobanks/default/";
+
+export function getInitialBackendBaseURL(): string {
+ const overrideUrl = localStorage.getItem("bank-base-url");
+
+ return canonicalizeBaseUrl(overrideUrl ? overrideUrl : maybeRootPath);
+}
+
+export const defaultState: BackendState = {
+ status: "loggedOut",
+ url: getInitialBackendBaseURL()
+};
export interface BackendStateHandler {
state: BackendState;
- clear(): void;
- save(info: BackendInfo): void;
+ logOut(): void;
+ logIn(info: BackendCredentials): void;
}
/**
* Return getters and setters for
@@ -52,7 +75,7 @@ export function useBackendState(): BackendStateHandler {
"backend-state",
JSON.stringify(defaultState),
);
- // const parsed = value !== undefined ? JSON.parse(value) : value;
+
let parsed;
try {
parsed = JSON.parse(value!);
@@ -63,12 +86,162 @@ export function useBackendState(): BackendStateHandler {
return {
state,
- clear() {
- update(JSON.stringify(defaultState));
+ logOut() {
+ update(JSON.stringify({ ...defaultState, url: state.url }));
},
- save(info) {
- const nextState: BackendState = { status: "loggedIn", ...info };
+ logIn(info) {
+ //admin is defined by the username
+ const nextState: BackendState = { status: "loggedIn", url: state.url, ...info, isUserAdministrator: info.username === "admin" };
update(JSON.stringify(nextState));
},
};
}
+
+interface useBackendType {
+ request: <T>(
+ path: string,
+ options?: RequestOptions,
+ ) => Promise<HttpResponseOk<T>>;
+ fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>;
+ multiFetcher: <T>(endpoint: string[]) => Promise<HttpResponseOk<T>[]>;
+ paginatedFetcher: <T>(args: [string, number, number]) => Promise<HttpResponseOk<T>>;
+ sandboxAccountsFetcher: <T>(args: [string, number, number, string]) => Promise<HttpResponseOk<T>>;
+}
+
+
+export function usePublicBackend(): useBackendType {
+ const { state } = useBackendContext();
+ const { request: requestHandler } = useApiContext();
+
+ const baseUrl = state.url
+
+ const request = useCallback(
+ function requestImpl<T>(
+ path: string,
+ options: RequestOptions = {},
+ ): Promise<HttpResponseOk<T>> {
+
+ return requestHandler<T>(baseUrl, path, options);
+ },
+ [baseUrl],
+ );
+
+ const fetcher = useCallback(
+ function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(baseUrl, endpoint);
+ },
+ [baseUrl],
+ );
+ const paginatedFetcher = useCallback(
+ function fetcherImpl<T>([endpoint, page, size]: [string, number, number]): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(baseUrl, endpoint, { params: { page: page || 1, size } });
+ },
+ [baseUrl],
+ );
+ const multiFetcher = useCallback(
+ function multiFetcherImpl<T>(
+ endpoints: string[],
+ ): Promise<HttpResponseOk<T>[]> {
+ return Promise.all(
+ endpoints.map((endpoint) => requestHandler<T>(baseUrl, endpoint)),
+ );
+ },
+ [baseUrl],
+ );
+ const sandboxAccountsFetcher = useCallback(
+ function fetcherImpl<T>([endpoint, page, size, account]: [string, number, number, string]): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(baseUrl, endpoint, { params: { page: page || 1, size } });
+ },
+ [baseUrl],
+ );
+ return { request, fetcher, paginatedFetcher, multiFetcher, sandboxAccountsFetcher };
+}
+
+export function useAuthenticatedBackend(): useBackendType {
+ const { state } = useBackendContext();
+ const { request: requestHandler } = useApiContext();
+
+ const creds = state.status === "loggedIn" ? state : undefined
+ const baseUrl = state.url
+
+ const request = useCallback(
+ function requestImpl<T>(
+ path: string,
+ options: RequestOptions = {},
+ ): Promise<HttpResponseOk<T>> {
+
+ return requestHandler<T>(baseUrl, path, { basicAuth: creds, ...options });
+ },
+ [baseUrl, creds],
+ );
+
+ const fetcher = useCallback(
+ function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(baseUrl, endpoint, { basicAuth: creds });
+ },
+ [baseUrl, creds],
+ );
+ const paginatedFetcher = useCallback(
+ function fetcherImpl<T>([endpoint, page = 0, size]: [string, number, number]): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(baseUrl, endpoint, { basicAuth: creds, params: { page, size } });
+ },
+ [baseUrl, creds],
+ );
+ const multiFetcher = useCallback(
+ function multiFetcherImpl<T>(
+ endpoints: string[],
+ ): Promise<HttpResponseOk<T>[]> {
+ return Promise.all(
+ endpoints.map((endpoint) => requestHandler<T>(baseUrl, endpoint, { basicAuth: creds })),
+ );
+ },
+ [baseUrl, creds],
+ );
+ const sandboxAccountsFetcher = useCallback(
+ function fetcherImpl<T>([endpoint, page, size, account]: [string, number, number, string]): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(baseUrl, endpoint, { basicAuth: creds, params: { page: page || 1, size } });
+ },
+ [baseUrl],
+ );
+ return { request, fetcher, paginatedFetcher, multiFetcher, sandboxAccountsFetcher };
+}
+
+export function useBackendConfig(): HttpResponse<SandboxBackend.Config, SandboxBackend.SandboxError> {
+ const { request } = usePublicBackend();
+
+ type Type = SandboxBackend.Config;
+
+ const [result, setResult] = useState<HttpResponse<Type, SandboxBackend.SandboxError>>({ loading: true });
+
+ useEffect(() => {
+ request<Type>(`/config`)
+ .then((data) => setResult(data))
+ .catch((error) => setResult(error));
+ }, [request]);
+
+ return result;
+}
+
+export function useMatchMutate(): (
+ re: RegExp,
+ value?: unknown,
+) => Promise<any> {
+ const { cache, mutate } = useSWRConfig();
+
+ if (!(cache instanceof Map)) {
+ throw new Error(
+ "matchMutate requires the cache provider to be a Map instance",
+ );
+ }
+
+ return function matchRegexMutate(re: RegExp, value?: unknown) {
+ const allKeys = Array.from(cache.keys());
+ const keys = allKeys.filter((key) => re.test(key));
+ const mutations = keys.map((key) => {
+ mutate(key, value, true);
+ });
+ return Promise.all(mutations);
+ };
+}
+
+
diff --git a/packages/demobank-ui/src/hooks/circuit.ts b/packages/demobank-ui/src/hooks/circuit.ts
new file mode 100644
index 000000000..6e9ada601
--- /dev/null
+++ b/packages/demobank-ui/src/hooks/circuit.ts
@@ -0,0 +1,317 @@
+/*
+ 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 {
+ HttpError,
+ HttpResponse,
+ HttpResponseOk,
+ HttpResponsePaginated,
+ RequestError
+} from "@gnu-taler/web-util/lib/index.browser";
+import { useEffect, useMemo, useState } from "preact/hooks";
+import useSWR from "swr";
+import { useBackendContext } from "../context/backend.js";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js";
+import { useAuthenticatedBackend } from "./backend.js";
+
+export function useAdminAccountAPI(): AdminAccountAPI {
+ const { request } = useAuthenticatedBackend();
+ const { state } = useBackendContext()
+ if (state.status === "loggedOut") {
+ throw Error("access-api can't be used when the user is not logged In")
+ }
+
+ const createAccount = async (
+ data: SandboxBackend.Circuit.CircuitAccountRequest,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`circuit-api/accounts`, {
+ method: "POST",
+ data,
+ contentType: "json"
+ });
+ return res;
+ };
+
+ const updateAccount = async (
+ account: string,
+ data: SandboxBackend.Circuit.CircuitAccountReconfiguration,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`circuit-api/accounts/${account}`, {
+ method: "PATCH",
+ data,
+ contentType: "json"
+ });
+ return res;
+ };
+ const deleteAccount = async (
+ account: string,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`circuit-api/accounts/${account}`, {
+ method: "DELETE",
+ contentType: "json"
+ });
+ return res;
+ };
+ const changePassword = async (
+ account: string,
+ data: SandboxBackend.Circuit.AccountPasswordChange,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`circuit-api/accounts/${account}/auth`, {
+ method: "PATCH",
+ data,
+ contentType: "json"
+ });
+ return res;
+ };
+
+ return { createAccount, deleteAccount, updateAccount, changePassword };
+}
+
+export function useCircuitAccountAPI(): CircuitAccountAPI {
+ const { request } = useAuthenticatedBackend();
+ const { state } = useBackendContext()
+ if (state.status === "loggedOut") {
+ throw Error("access-api can't be used when the user is not logged In")
+ }
+ const account = state.username;
+
+ const updateAccount = async (
+ data: SandboxBackend.Circuit.CircuitAccountReconfiguration,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`circuit-api/accounts/${account}`, {
+ method: "PATCH",
+ data,
+ contentType: "json"
+ });
+ return res;
+ };
+ const changePassword = async (
+ data: SandboxBackend.Circuit.AccountPasswordChange,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`circuit-api/accounts/${account}/auth`, {
+ method: "PATCH",
+ data,
+ contentType: "json"
+ });
+ return res;
+ };
+
+ return { updateAccount, changePassword };
+}
+
+export interface AdminAccountAPI {
+ createAccount: (
+ data: SandboxBackend.Circuit.CircuitAccountRequest,
+ ) => Promise<HttpResponseOk<void>>;
+ deleteAccount: (account: string) => Promise<HttpResponseOk<void>>;
+
+ updateAccount: (
+ account: string,
+ data: SandboxBackend.Circuit.CircuitAccountReconfiguration
+ ) => Promise<HttpResponseOk<void>>;
+ changePassword: (
+ account: string,
+ data: SandboxBackend.Circuit.AccountPasswordChange
+ ) => Promise<HttpResponseOk<void>>;
+}
+
+export interface CircuitAccountAPI {
+ updateAccount: (
+ data: SandboxBackend.Circuit.CircuitAccountReconfiguration
+ ) => Promise<HttpResponseOk<void>>;
+ changePassword: (
+ data: SandboxBackend.Circuit.AccountPasswordChange
+ ) => Promise<HttpResponseOk<void>>;
+}
+
+
+export interface InstanceTemplateFilter {
+ //FIXME: add filter to the template list
+ position?: string;
+}
+
+
+export function useMyAccountDetails(): HttpResponse<SandboxBackend.Circuit.CircuitAccountData, SandboxBackend.SandboxError> {
+ const { fetcher } = useAuthenticatedBackend();
+ const { state } = useBackendContext()
+ if (state.status === "loggedOut") {
+ throw Error("can't access my-account-details when logged out")
+ }
+ const { data, error } = useSWR<
+ HttpResponseOk<SandboxBackend.Circuit.CircuitAccountData>,
+ HttpError<SandboxBackend.SandboxError>
+ >([`accounts/${state.username}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ });
+
+ if (data) return data;
+ if (error) return error;
+ return { loading: true };
+}
+
+export function useAccountDetails(account: string): HttpResponse<SandboxBackend.Circuit.CircuitAccountData, SandboxBackend.SandboxError> {
+ const { fetcher } = useAuthenticatedBackend();
+
+ const { data, error } = useSWR<
+ HttpResponseOk<SandboxBackend.Circuit.CircuitAccountData>,
+ RequestError<SandboxBackend.SandboxError>
+ >([`circuit-api/accounts/${account}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ });
+
+ // if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.info;
+ return { loading: true };
+}
+
+interface PaginationFilter {
+ account?: string,
+ page?: number,
+}
+
+export function useAccounts(
+ args?: PaginationFilter,
+): HttpResponsePaginated<SandboxBackend.Circuit.CircuitAccounts, SandboxBackend.SandboxError> {
+ const { sandboxAccountsFetcher } = useAuthenticatedBackend();
+ const [page, setPage] = useState(0);
+
+ const {
+ data: afterData,
+ error: afterError,
+ // isValidating: loadingAfter,
+ } = useSWR<
+ HttpResponseOk<SandboxBackend.Circuit.CircuitAccounts>,
+ RequestError<SandboxBackend.SandboxError>
+ >([`circuit-api/accounts`, args?.page, PAGE_SIZE, args?.account], sandboxAccountsFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ });
+
+ // const [lastAfter, setLastAfter] = useState<
+ // HttpResponse<SandboxBackend.Circuit.CircuitAccounts, SandboxBackend.SandboxError>
+ // >({ loading: true });
+
+ // useEffect(() => {
+ // if (afterData) setLastAfter(afterData);
+ // }, [afterData]);
+
+ // if the query returns less that we ask, then we have reach the end or beginning
+ const isReachingEnd =
+ afterData && afterData.data?.customers?.length < PAGE_SIZE;
+ const isReachingStart = false;
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data?.customers?.length < MAX_RESULT_SIZE) {
+ setPage(page + 1);
+ }
+ },
+ loadMorePrev: () => {
+ null
+ },
+ };
+
+ const result = useMemo(() => {
+ const customers = !afterData ? [] : (afterData)?.data?.customers ?? [];
+ return { ok: true as const, data: { customers }, ...pagination }
+ }, [afterData?.data])
+
+ if (afterError) return afterError.info;
+ if (afterData) {
+ return result
+ }
+
+ // if (loadingAfter)
+ // return { loading: true, data: { customers } };
+ // if (afterData) {
+ // return { ok: true, data: { customers }, ...pagination };
+ // }
+ return { loading: true };
+}
+
+export function useCashouts(): HttpResponse<
+ (SandboxBackend.Circuit.CashoutStatusResponse & WithId)[],
+ SandboxBackend.SandboxError
+> {
+ const { fetcher, multiFetcher } = useAuthenticatedBackend();
+
+ const { data: list, error: listError } = useSWR<
+ HttpResponseOk<SandboxBackend.Circuit.Cashouts>,
+ RequestError<SandboxBackend.SandboxError>
+ >([`circuit-api/cashouts`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ const paths = (list?.data.cashouts || []).map(
+ (cashoutId) => `circuit-api/cashouts/${cashoutId}`,
+ );
+ const { data: cashouts, error: productError } = useSWR<
+ HttpResponseOk<SandboxBackend.Circuit.CashoutStatusResponse>[],
+ RequestError<SandboxBackend.SandboxError>
+ >([paths], multiFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (listError) return listError.info;
+ if (productError) return productError.info;
+
+ if (cashouts) {
+ const dataWithId = cashouts.map((d) => {
+ //take the id from the queried url
+ return {
+ ...d.data,
+ id: d.info?.url.replace(/.*\/cashouts\//, "") || "",
+ };
+ });
+ return { ok: true, data: dataWithId };
+ }
+ return { loading: true };
+}
diff --git a/packages/demobank-ui/src/pages/AccountPage.tsx b/packages/demobank-ui/src/pages/AccountPage.tsx
index 8d29bd933..769e85804 100644
--- a/packages/demobank-ui/src/pages/AccountPage.tsx
+++ b/packages/demobank-ui/src/pages/AccountPage.tsx
@@ -14,206 +14,52 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { Amounts, HttpStatusCode, Logger } from "@gnu-taler/taler-util";
-import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
-import { ComponentChildren, Fragment, h, VNode } from "preact";
-import { useEffect } from "preact/hooks";
-import useSWR, { SWRConfig, useSWRConfig } from "swr";
-import { useBackendContext } from "../context/backend.js";
-import { PageStateType, usePageContext } from "../context/pageState.js";
-import { BackendInfo } from "../hooks/backend.js";
-import { bankUiSettings } from "../settings.js";
-import { getIbanFromPayto, prepareHeaders } from "../utils.js";
-import { BankFrame } from "./BankFrame.js";
-import { LoginForm } from "./LoginForm.js";
-import { PaymentOptions } from "./PaymentOptions.js";
+import { Amounts, parsePaytoUri } from "@gnu-taler/taler-util";
+import {
+ HttpResponsePaginated,
+ useTranslationContext,
+} from "@gnu-taler/web-util/lib/index.browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Cashouts } from "../components/Cashouts/index.js";
import { Transactions } from "../components/Transactions/index.js";
-import { WithdrawalQRCode } from "./WithdrawalQRCode.js";
-
-export function AccountPage(): VNode {
- const backend = useBackendContext();
- const { i18n } = useTranslationContext();
-
- if (backend.state.status === "loggedOut") {
- return (
- <BankFrame>
- <h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1>
- <LoginForm />
- </BankFrame>
- );
- }
-
- return (
- <SWRWithCredentials info={backend.state}>
- <Account accountLabel={backend.state.username} />
- </SWRWithCredentials>
- );
-}
-
-/**
- * Factor out login credentials.
- */
-function SWRWithCredentials({
- children,
- info,
-}: {
- children: ComponentChildren;
- info: BackendInfo;
-}): VNode {
- const { username, password, url: backendUrl } = info;
- const headers = prepareHeaders(username, password);
- return (
- <SWRConfig
- value={{
- fetcher: (url: string) => {
- return fetch(new URL(url, backendUrl).href, { headers }).then((r) => {
- if (!r.ok) throw { status: r.status, json: r.json() };
+import { useAccountDetails } from "../hooks/access.js";
+import { PaymentOptions } from "./PaymentOptions.js";
- return r.json();
- });
- },
- }}
- >
- {children as any}
- </SWRConfig>
- );
+interface Props {
+ account: string;
+ onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
}
-
-const logger = new Logger("AccountPage");
-
/**
- * Show only the account's balance. NOTE: the backend state
- * is mostly needed to provide the user's credentials to POST
- * to the bank.
+ * Query account information and show QR code if there is pending withdrawal
*/
-function Account({ accountLabel }: { accountLabel: string }): VNode {
- const { cache } = useSWRConfig();
-
- // Getting the bank account balance:
- const endpoint = `access-api/accounts/${accountLabel}`;
- const { data, error, mutate } = useSWR(endpoint, {
- // refreshInterval: 0,
- // revalidateIfStale: false,
- // revalidateOnMount: false,
- // revalidateOnFocus: false,
- // revalidateOnReconnect: false,
- });
- const backend = useBackendContext();
- const { pageState, pageStateSetter: setPageState } = usePageContext();
- const { withdrawalId, talerWithdrawUri, timestamp } = pageState;
+export function AccountPage({ account, onLoadNotOk }: Props): VNode {
+ const result = useAccountDetails(account);
const { i18n } = useTranslationContext();
- useEffect(() => {
- mutate();
- }, [timestamp]);
- /**
- * This part shows a list of transactions: with 5 elements by
- * default and offers a "load more" button.
- */
- // const [txPageNumber, setTxPageNumber] = useTransactionPageNumber();
- // const txsPages = [];
- // for (let i = 0; i <= txPageNumber; i++) {
- // txsPages.push(<Transactions accountLabel={accountLabel} pageNumber={i} />);
- // }
-
- if (typeof error !== "undefined") {
- logger.error("account error", error, endpoint);
- /**
- * FIXME: to minimize the code, try only one invocation
- * of pageStateSetter, after having decided the error
- * message in the case-branch.
- */
- switch (error.status) {
- case 404: {
- backend.clear();
- setPageState((prevState: PageStateType) => ({
- ...prevState,
-
- error: {
- title: i18n.str`Username or account label '${accountLabel}' not found. Won't login.`,
- },
- }));
-
- /**
- * 404 should never stick to the cache, because they
- * taint successful future registrations. How? After
- * registering, the user gets navigated to this page,
- * therefore a previous 404 on this SWR key (the requested
- * resource) would still appear as valid and cause this
- * page not to be shown! A typical case is an attempted
- * login of a unregistered user X, and then a registration
- * attempt of the same user X: in this case, the failed
- * login would cache a 404 error to X's profile, resulting
- * in the legitimate request after the registration to still
- * be flagged as 404. Clearing the cache should prevent
- * this. */
- (cache as any).clear();
- return <p>Profile not found...</p>;
- }
- case HttpStatusCode.Unauthorized:
- case HttpStatusCode.Forbidden: {
- backend.clear();
- setPageState((prevState: PageStateType) => ({
- ...prevState,
- error: {
- title: i18n.str`Wrong credentials given.`,
- },
- }));
- return <p>Wrong credentials...</p>;
- }
- default: {
- backend.clear();
- setPageState((prevState: PageStateType) => ({
- ...prevState,
- error: {
- title: i18n.str`Account information could not be retrieved.`,
- debug: JSON.stringify(error),
- },
- }));
- return <p>Unknown problem...</p>;
- }
- }
+ if (!result.ok) {
+ return onLoadNotOk(result);
}
- const balance = !data ? undefined : Amounts.parse(data.balance.amount);
- const errorParsingBalance = data && !balance;
- const accountNumber = !data ? undefined : getIbanFromPayto(data.paytoUri);
- const balanceIsDebit = data && data.balance.credit_debit_indicator == "debit";
- /**
- * This block shows the withdrawal QR code.
- *
- * A withdrawal operation replaces everything in the page and
- * (ToDo:) starts polling the backend until either the wallet
- * selected a exchange and reserve public key, or a error / abort
- * happened.
- *
- * After reaching one of the above states, the user should be
- * brought to this ("Account") page where they get informed about
- * the outcome.
- */
- if (talerWithdrawUri && withdrawalId) {
- logger.trace("Bank created a new Taler withdrawal");
+ const { data } = result;
+ const balance = Amounts.parse(data.balance.amount);
+ const errorParsingBalance = !balance;
+ const payto = parsePaytoUri(data.paytoUri);
+ if (!payto || !payto.isKnown || payto.targetType !== "iban") {
return (
- <BankFrame>
- <WithdrawalQRCode
- withdrawalId={withdrawalId}
- talerWithdrawUri={talerWithdrawUri}
- />
- </BankFrame>
+ <div>Payto from server is not valid &quot;{data.paytoUri}&quot;</div>
);
}
- const balanceValue = !balance ? undefined : Amounts.stringifyValue(balance);
+ const accountNumber = payto.iban;
+ const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
return (
- <BankFrame>
+ <Fragment>
<div>
<h1 class="nav welcome-text">
<i18n.Translate>
Welcome,
- {accountNumber
- ? `${accountLabel} (${accountNumber})`
- : accountLabel}
- !
+ {accountNumber ? `${account} (${accountNumber})` : account}!
</i18n.Translate>
</h1>
</div>
@@ -239,7 +85,10 @@ function Account({ accountLabel }: { accountLabel: string }): VNode {
) : (
<div class="large-amount amount">
{balanceIsDebit ? <b>-</b> : null}
- <span class="value">{`${balanceValue}`}</span>&nbsp;
+ <span class="value">{`${Amounts.stringifyValue(
+ balance,
+ )}`}</span>
+ &nbsp;
<span class="currency">{`${balance.currency}`}</span>
</div>
)}
@@ -248,34 +97,56 @@ function Account({ accountLabel }: { accountLabel: string }): VNode {
<section id="payments">
<div class="payments">
<h2>{i18n.str`Payments`}</h2>
- <PaymentOptions currency={balance?.currency} />
+ <PaymentOptions currency={balance.currency} />
</div>
</section>
</Fragment>
)}
- <section id="main">
- <article>
- <h2>{i18n.str`Latest transactions:`}</h2>
- <Transactions
- balanceValue={balanceValue}
- pageNumber={0}
- accountLabel={accountLabel}
- />
- </article>
+
+ <section style={{ marginTop: "2em" }}>
+ <Moves account={account} />
</section>
- </BankFrame>
+ </Fragment>
);
}
-// function useTransactionPageNumber(): [number, StateUpdater<number>] {
-// const ret = useNotNullLocalStorage("transaction-page", "0");
-// const retObj = JSON.parse(ret[0]);
-// const retSetter: StateUpdater<number> = function (val) {
-// const newVal =
-// val instanceof Function
-// ? JSON.stringify(val(retObj))
-// : JSON.stringify(val);
-// ret[1](newVal);
-// };
-// return [retObj, retSetter];
-// }
+function Moves({ account }: { account: string }): VNode {
+ const [tab, setTab] = useState<"transactions" | "cashouts">("transactions");
+ const { i18n } = useTranslationContext();
+ return (
+ <article>
+ <div class="payments">
+ <div class="tab">
+ <button
+ class={tab === "transactions" ? "tablinks active" : "tablinks"}
+ onClick={(): void => {
+ setTab("transactions");
+ }}
+ >
+ {i18n.str`Transactions`}
+ </button>
+ <button
+ class={tab === "cashouts" ? "tablinks active" : "tablinks"}
+ onClick={(): void => {
+ setTab("cashouts");
+ }}
+ >
+ {i18n.str`Cashouts`}
+ </button>
+ </div>
+ {tab === "transactions" && (
+ <div class="active">
+ <h3>{i18n.str`Latest transactions`}</h3>
+ <Transactions account={account} />
+ </div>
+ )}
+ {tab === "cashouts" && (
+ <div class="active">
+ <h3>{i18n.str`Latest cashouts`}</h3>
+ <Cashouts account={account} />
+ </div>
+ )}
+ </div>
+ </article>
+ );
+}
diff --git a/packages/demobank-ui/src/pages/AdminPage.tsx b/packages/demobank-ui/src/pages/AdminPage.tsx
new file mode 100644
index 000000000..9efd37f12
--- /dev/null
+++ b/packages/demobank-ui/src/pages/AdminPage.tsx
@@ -0,0 +1,707 @@
+/*
+ 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 { parsePaytoUri, TranslatedString } from "@gnu-taler/taler-util";
+import {
+ HttpResponsePaginated,
+ RequestError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/lib/index.browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorMessage, usePageContext } from "../context/pageState.js";
+import {
+ useAccountDetails,
+ useAccounts,
+ useAdminAccountAPI,
+} from "../hooks/circuit.js";
+import {
+ PartialButDefined,
+ undefinedIfEmpty,
+ WithIntermediate,
+} from "../utils.js";
+import { ErrorBanner } from "./BankFrame.js";
+import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
+
+const charset =
+ "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+const upperIdx = charset.indexOf("A");
+
+function randomPassword(): string {
+ const random = Array.from({ length: 16 }).map(() => {
+ return charset.charCodeAt(Math.random() * charset.length);
+ });
+ // first char can't be upper
+ const charIdx = charset.indexOf(String.fromCharCode(random[0]));
+ random[0] =
+ charIdx > upperIdx ? charset.charCodeAt(charIdx - upperIdx) : random[0];
+ return String.fromCharCode(...random);
+}
+
+interface Props {
+ onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
+}
+/**
+ * Query account information and show QR code if there is pending withdrawal
+ */
+export function AdminPage({ onLoadNotOk }: Props): VNode {
+ const [account, setAccount] = useState<string | undefined>();
+ const [showDetails, setShowDetails] = useState<string | undefined>();
+ const [updatePassword, setUpdatePassword] = useState<string | undefined>();
+ const [createAccount, setCreateAccount] = useState(false);
+ const { pageStateSetter } = usePageContext();
+
+ function showInfoMessage(info: TranslatedString): void {
+ pageStateSetter((prev) => ({
+ ...prev,
+ info,
+ }));
+ }
+
+ const result = useAccounts({ account });
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <div />;
+ if (!result.ok) {
+ return onLoadNotOk(result);
+ }
+
+ const { customers } = result.data;
+
+ if (showDetails) {
+ return (
+ <ShowAccountDetails
+ account={showDetails}
+ onLoadNotOk={onLoadNotOk}
+ onUpdateSuccess={() => {
+ showInfoMessage(i18n.str`Account updated`);
+ setShowDetails(undefined);
+ }}
+ onClear={() => {
+ setShowDetails(undefined);
+ }}
+ />
+ );
+ }
+ if (updatePassword) {
+ return (
+ <UpdateAccountPassword
+ account={updatePassword}
+ onLoadNotOk={onLoadNotOk}
+ onUpdateSuccess={() => {
+ showInfoMessage(i18n.str`Password changed`);
+ setUpdatePassword(undefined);
+ }}
+ onClear={() => {
+ setUpdatePassword(undefined);
+ }}
+ />
+ );
+ }
+ if (createAccount) {
+ return (
+ <CreateNewAccount
+ onClose={() => setCreateAccount(false)}
+ onCreateSuccess={(password) => {
+ showInfoMessage(
+ i18n.str`Account created with password "${password}"`,
+ );
+ setCreateAccount(false);
+ }}
+ />
+ );
+ }
+ return (
+ <Fragment>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>Admin panel</i18n.Translate>
+ </h1>
+ </div>
+
+ <p>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div></div>
+ <div>
+ <input
+ class="pure-button pure-button-primary content"
+ type="submit"
+ value={i18n.str`Create account`}
+ onClick={async (e) => {
+ e.preventDefault();
+
+ setCreateAccount(true);
+ }}
+ />
+ </div>
+ </div>
+ </p>
+
+ <section id="main">
+ <article>
+ <h2>{i18n.str`Accounts:`}</h2>
+ <div class="results">
+ <table class="pure-table pure-table-striped">
+ <thead>
+ <tr>
+ <th>{i18n.str`Username`}</th>
+ <th>{i18n.str`Name`}</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ {customers.map((item, idx) => {
+ return (
+ <tr key={idx}>
+ <td>
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ setShowDetails(item.username);
+ }}
+ >
+ {item.username}
+ </a>
+ </td>
+ <td>{item.name}</td>
+ <td>
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ setUpdatePassword(item.username);
+ }}
+ >
+ change password
+ </a>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ </article>
+ </section>
+ </Fragment>
+ );
+}
+
+const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/;
+const EMAIL_REGEX =
+ /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/;
+
+function initializeFromTemplate(
+ account: SandboxBackend.Circuit.CircuitAccountData | undefined,
+): WithIntermediate<SandboxBackend.Circuit.CircuitAccountData> {
+ const emptyAccount = {
+ cashout_address: undefined,
+ iban: undefined,
+ name: undefined,
+ username: undefined,
+ contact_data: undefined,
+ };
+ const emptyContact = {
+ email: undefined,
+ phone: undefined,
+ };
+
+ const initial: PartialButDefined<SandboxBackend.Circuit.CircuitAccountData> =
+ structuredClone(account) ?? emptyAccount;
+ if (typeof initial.contact_data === "undefined") {
+ initial.contact_data = emptyContact;
+ }
+ initial.contact_data.email;
+ return initial as any;
+}
+
+function UpdateAccountPassword({
+ account,
+ onClear,
+ onUpdateSuccess,
+ onLoadNotOk,
+}: {
+ onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
+ onClear: () => void;
+ onUpdateSuccess: () => void;
+ account: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useAccountDetails(account);
+ const { changePassword } = useAdminAccountAPI();
+ const [password, setPassword] = useState<string | undefined>();
+ const [repeat, setRepeat] = useState<string | undefined>();
+ const [error, saveError] = useState<ErrorMessage | undefined>();
+
+ if (result.clientError) {
+ if (result.isNotfound) return <div>account not found</div>;
+ }
+ if (!result.ok) {
+ return onLoadNotOk(result);
+ }
+
+ const errors = undefinedIfEmpty({
+ password: !password ? i18n.str`required` : undefined,
+ repeat: !repeat
+ ? i18n.str`required`
+ : password !== repeat
+ ? i18n.str`password doesn't match`
+ : undefined,
+ });
+
+ return (
+ <div>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>Admin panel</i18n.Translate>
+ </h1>
+ </div>
+ {error && (
+ <ErrorBanner error={error} onClear={() => saveError(undefined)} />
+ )}
+
+ <form class="pure-form">
+ <fieldset>
+ <label for="username">{i18n.str`Username`}</label>
+ <input name="username" type="text" readOnly value={account} />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Password`}</label>
+ <input
+ type="password"
+ value={password ?? ""}
+ onChange={(e) => {
+ setPassword(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.password}
+ isDirty={password !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Repeast password`}</label>
+ <input
+ type="password"
+ value={repeat ?? ""}
+ onChange={(e) => {
+ setRepeat(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.repeat}
+ isDirty={repeat !== undefined}
+ />
+ </fieldset>
+ </form>
+ <p>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div>
+ <input
+ class="pure-button"
+ type="submit"
+ value={i18n.str`Close`}
+ onClick={async (e) => {
+ e.preventDefault();
+ onClear();
+ }}
+ />
+ </div>
+ <div>
+ <input
+ id="select-exchange"
+ class="pure-button pure-button-primary content"
+ disabled={!!errors}
+ type="submit"
+ value={i18n.str`Confirm`}
+ onClick={async (e) => {
+ e.preventDefault();
+ if (!!errors || !password) return;
+ try {
+ const r = await changePassword(account, {
+ new_password: password,
+ });
+ onUpdateSuccess();
+ } catch (error) {
+ handleError(error, saveError, i18n);
+ }
+ }}
+ />
+ </div>
+ </div>
+ </p>
+ </div>
+ );
+}
+
+function CreateNewAccount({
+ onClose,
+ onCreateSuccess,
+}: {
+ onClose: () => void;
+ onCreateSuccess: (password: string) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { createAccount } = useAdminAccountAPI();
+ const [submitAccount, setSubmitAccount] = useState<
+ SandboxBackend.Circuit.CircuitAccountData | undefined
+ >();
+ const [error, saveError] = useState<ErrorMessage | undefined>();
+ return (
+ <div>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>Admin panel</i18n.Translate>
+ </h1>
+ </div>
+ {error && (
+ <ErrorBanner error={error} onClear={() => saveError(undefined)} />
+ )}
+
+ <AccountForm
+ template={undefined}
+ purpose="create"
+ onChange={(a) => setSubmitAccount(a)}
+ />
+
+ <p>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div>
+ <input
+ class="pure-button"
+ type="submit"
+ value={i18n.str`Close`}
+ onClick={async (e) => {
+ e.preventDefault();
+ onClose();
+ }}
+ />
+ </div>
+ <div>
+ <input
+ id="select-exchange"
+ class="pure-button pure-button-primary content"
+ disabled={!submitAccount}
+ type="submit"
+ value={i18n.str`Confirm`}
+ onClick={async (e) => {
+ e.preventDefault();
+
+ if (!submitAccount) return;
+ try {
+ const account: SandboxBackend.Circuit.CircuitAccountRequest =
+ {
+ cashout_address: submitAccount.cashout_address,
+ contact_data: submitAccount.contact_data,
+ internal_iban: submitAccount.iban,
+ name: submitAccount.name,
+ username: submitAccount.username,
+ password: randomPassword(),
+ };
+
+ await createAccount(account);
+ onCreateSuccess(account.password);
+ } catch (error) {
+ handleError(error, saveError, i18n);
+ }
+ }}
+ />
+ </div>
+ </div>
+ </p>
+ </div>
+ );
+}
+
+function ShowAccountDetails({
+ account,
+ onClear,
+ onUpdateSuccess,
+ onLoadNotOk,
+}: {
+ onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
+ onClear: () => void;
+ onUpdateSuccess: () => void;
+ account: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useAccountDetails(account);
+ const { updateAccount } = useAdminAccountAPI();
+ const [update, setUpdate] = useState(false);
+ const [submitAccount, setSubmitAccount] = useState<
+ SandboxBackend.Circuit.CircuitAccountData | undefined
+ >();
+ const [error, saveError] = useState<ErrorMessage | undefined>();
+
+ if (result.clientError) {
+ if (result.isNotfound) return <div>account not found</div>;
+ }
+ if (!result.ok) {
+ return onLoadNotOk(result);
+ }
+
+ return (
+ <div>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>Admin panel</i18n.Translate>
+ </h1>
+ </div>
+ {error && (
+ <ErrorBanner error={error} onClear={() => saveError(undefined)} />
+ )}
+ <AccountForm
+ template={result.data}
+ purpose={update ? "update" : "show"}
+ onChange={(a) => setSubmitAccount(a)}
+ />
+
+ <p>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div>
+ <input
+ class="pure-button"
+ type="submit"
+ value={i18n.str`Close`}
+ onClick={async (e) => {
+ e.preventDefault();
+ onClear();
+ }}
+ />
+ </div>
+ <div>
+ <input
+ id="select-exchange"
+ class="pure-button pure-button-primary content"
+ disabled={update && !submitAccount}
+ type="submit"
+ value={update ? i18n.str`Confirm` : i18n.str`Update`}
+ onClick={async (e) => {
+ e.preventDefault();
+
+ if (!update) {
+ setUpdate(true);
+ } else {
+ if (!submitAccount) return;
+ try {
+ await updateAccount(account, {
+ cashout_address: submitAccount.cashout_address,
+ contact_data: submitAccount.contact_data,
+ });
+ onUpdateSuccess();
+ } catch (error) {
+ handleError(error, saveError, i18n);
+ }
+ }
+ }}
+ />
+ </div>
+ </div>
+ </p>
+ </div>
+ );
+}
+
+/**
+ * Create valid account object to update or create
+ * Take template as initial values for the form
+ * Purpose indicate if all field al read only (show), part of them (update)
+ * or none (create)
+ * @param param0
+ * @returns
+ */
+function AccountForm({
+ template,
+ purpose,
+ onChange,
+}: {
+ template: SandboxBackend.Circuit.CircuitAccountData | undefined;
+ onChange: (a: SandboxBackend.Circuit.CircuitAccountData | undefined) => void;
+ purpose: "create" | "update" | "show";
+}): VNode {
+ const initial = initializeFromTemplate(template);
+ const [form, setForm] = useState(initial);
+ const [errors, setErrors] = useState<typeof initial | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+
+ function updateForm(newForm: typeof initial): void {
+ const parsed = !newForm.cashout_address
+ ? undefined
+ : parsePaytoUri(newForm.cashout_address);
+
+ const validationResult = undefinedIfEmpty<typeof initial>({
+ cashout_address: !newForm.cashout_address
+ ? i18n.str`required`
+ : !parsed
+ ? i18n.str`does not follow the pattern`
+ : !parsed.isKnown || parsed.targetType !== "iban"
+ ? i18n.str`only "IBAN" target are supported`
+ : !IBAN_REGEX.test(parsed.iban)
+ ? i18n.str`IBAN should have just uppercased letters and numbers`
+ : undefined,
+ contact_data: {
+ email: !newForm.contact_data.email
+ ? undefined
+ : !EMAIL_REGEX.test(newForm.contact_data.email)
+ ? i18n.str`it should be an email`
+ : undefined,
+ phone: !newForm.contact_data.phone
+ ? undefined
+ : !newForm.contact_data.phone.startsWith("+")
+ ? i18n.str`should start with +`
+ : !REGEX_JUST_NUMBERS_REGEX.test(newForm.contact_data.phone)
+ ? i18n.str`phone number can't have other than numbers`
+ : undefined,
+ },
+ iban: !newForm.iban
+ ? i18n.str`required`
+ : !IBAN_REGEX.test(newForm.iban)
+ ? i18n.str`IBAN should have just uppercased letters and numbers`
+ : undefined,
+ name: !newForm.name ? i18n.str`required` : undefined,
+ username: !newForm.username ? i18n.str`required` : undefined,
+ });
+
+ setErrors(validationResult);
+ setForm(newForm);
+ onChange(validationResult === undefined ? undefined : (newForm as any));
+ }
+
+ return (
+ <form class="pure-form">
+ <fieldset>
+ <label for="username">{i18n.str`Username`}</label>
+ <input
+ name="username"
+ type="text"
+ disabled={purpose !== "create"}
+ value={form.username}
+ onChange={(e) => {
+ form.username = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.username}
+ isDirty={form.username !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Name`}</label>
+ <input
+ disabled={purpose !== "create"}
+ value={form.name ?? ""}
+ onChange={(e) => {
+ form.name = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.name}
+ isDirty={form.name !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`IBAN`}</label>
+ <input
+ disabled={purpose !== "create"}
+ value={form.iban ?? ""}
+ onChange={(e) => {
+ form.iban = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.iban}
+ isDirty={form.iban !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Email`}</label>
+ <input
+ disabled={purpose === "show"}
+ value={form.contact_data.email ?? ""}
+ onChange={(e) => {
+ form.contact_data.email = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.contact_data.email}
+ isDirty={form.contact_data.email !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Phone`}</label>
+ <input
+ disabled={purpose === "show"}
+ value={form.contact_data.phone ?? ""}
+ onChange={(e) => {
+ form.contact_data.phone = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.contact_data.phone}
+ isDirty={form.contact_data?.phone !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Cashout address`}</label>
+ <input
+ disabled={purpose === "show"}
+ value={form.cashout_address ?? ""}
+ onChange={(e) => {
+ form.cashout_address = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.cashout_address}
+ isDirty={form.cashout_address !== undefined}
+ />
+ </fieldset>
+ </form>
+ );
+}
+
+function handleError(
+ error: unknown,
+ saveError: (e: ErrorMessage) => void,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): void {
+ if (error instanceof RequestError) {
+ const payload = error.info.error as SandboxBackend.SandboxError;
+ saveError({
+ title: error.info.serverError
+ ? i18n.str`Server had an error`
+ : i18n.str`Server didn't accept the request`,
+ description: payload.error.description,
+ });
+ } else if (error instanceof Error) {
+ saveError({
+ title: i18n.str`Could not update account`,
+ description: error.message,
+ });
+ } else {
+ saveError({
+ title: i18n.str`Error, please report`,
+ debug: JSON.stringify(error),
+ });
+ }
+}
diff --git a/packages/demobank-ui/src/pages/BankFrame.tsx b/packages/demobank-ui/src/pages/BankFrame.tsx
index e36629e2a..ed36daa21 100644
--- a/packages/demobank-ui/src/pages/BankFrame.tsx
+++ b/packages/demobank-ui/src/pages/BankFrame.tsx
@@ -19,7 +19,11 @@ import { ComponentChildren, Fragment, h, VNode } from "preact";
import talerLogo from "../assets/logo-white.svg";
import { LangSelectorLikePy as LangSelector } from "../components/LangSelector.js";
import { useBackendContext } from "../context/backend.js";
-import { PageStateType, usePageContext } from "../context/pageState.js";
+import {
+ ErrorMessage,
+ PageStateType,
+ usePageContext,
+} from "../context/pageState.js";
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { bankUiSettings } from "../settings.js";
@@ -42,7 +46,7 @@ export function BankFrame({
onClick={() => {
pageStateSetter((prevState: PageStateType) => {
const { talerWithdrawUri, withdrawalId, ...rest } = prevState;
- backend.clear();
+ backend.logOut();
return {
...rest,
withdrawalInProgress: false,
@@ -107,7 +111,14 @@ export function BankFrame({
</nav>
</div>
<section id="main" class="content">
- <ErrorBanner />
+ {pageState.error && (
+ <ErrorBanner
+ error={pageState.error}
+ onClear={() => {
+ pageStateSetter((prev) => ({ ...prev, error: undefined }));
+ }}
+ />
+ )}
<StatusBanner />
{backend.state.status === "loggedIn" ? logOut : null}
{children}
@@ -136,33 +147,34 @@ function maybeDemoContent(content: VNode): VNode {
return <Fragment />;
}
-function ErrorBanner(): VNode | null {
- const { pageState, pageStateSetter } = usePageContext();
-
- if (!pageState.error) return null;
-
- const rval = (
+export function ErrorBanner({
+ error,
+ onClear,
+}: {
+ error: ErrorMessage;
+ onClear: () => void;
+}): VNode | null {
+ return (
<div class="informational informational-fail" style={{ marginTop: 8 }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<p>
- <b>{pageState.error.title}</b>
+ <b>{error.title}</b>
</p>
<div>
<input
type="button"
class="pure-button"
value="Clear"
- onClick={async () => {
- pageStateSetter((prev) => ({ ...prev, error: undefined }));
+ onClick={(e) => {
+ e.preventDefault();
+ onClear();
}}
/>
</div>
</div>
- <p>{pageState.error.description}</p>
+ <p>{error.description}</p>
</div>
);
- delete pageState.error;
- return rval;
}
function StatusBanner(): VNode | null {
diff --git a/packages/demobank-ui/src/pages/HomePage.tsx b/packages/demobank-ui/src/pages/HomePage.tsx
new file mode 100644
index 000000000..e60732d42
--- /dev/null
+++ b/packages/demobank-ui/src/pages/HomePage.tsx
@@ -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 { Logger } from "@gnu-taler/taler-util";
+import {
+ HttpResponsePaginated,
+ useTranslationContext,
+} from "@gnu-taler/web-util/lib/index.browser";
+import { Fragment, h, VNode } from "preact";
+import { Loading } from "../components/Loading.js";
+import { useBackendContext } from "../context/backend.js";
+import { PageStateType, usePageContext } from "../context/pageState.js";
+import { AccountPage } from "./AccountPage.js";
+import { AdminPage } from "./AdminPage.js";
+import { LoginForm } from "./LoginForm.js";
+import { WithdrawalQRCode } from "./WithdrawalQRCode.js";
+
+const logger = new Logger("AccountPage");
+
+/**
+ * show content based on state:
+ * - LoginForm if the user is not logged in
+ * - qr code if withdrawal in progress
+ * - else account information
+ * Use the handler to catch error cases
+ *
+ * @param param0
+ * @returns
+ */
+export function HomePage({ onRegister }: { onRegister: () => void }): VNode {
+ const backend = useBackendContext();
+ const { pageState, pageStateSetter } = usePageContext();
+ const { i18n } = useTranslationContext();
+
+ function saveError(error: PageStateType["error"]): void {
+ pageStateSetter((prev) => ({ ...prev, error }));
+ }
+
+ function saveErrorAndLogout(error: PageStateType["error"]): void {
+ saveError(error);
+ backend.logOut();
+ }
+
+ function clearCurrentWithdrawal(): void {
+ pageStateSetter((prevState: PageStateType) => {
+ return {
+ ...prevState,
+ withdrawalId: undefined,
+ talerWithdrawUri: undefined,
+ withdrawalInProgress: false,
+ };
+ });
+ }
+
+ if (backend.state.status === "loggedOut") {
+ return <LoginForm onRegister={onRegister} />;
+ }
+
+ const { withdrawalId, talerWithdrawUri } = pageState;
+
+ if (talerWithdrawUri && withdrawalId) {
+ return (
+ <WithdrawalQRCode
+ account={backend.state.username}
+ withdrawalId={withdrawalId}
+ talerWithdrawUri={talerWithdrawUri}
+ onAbort={clearCurrentWithdrawal}
+ onLoadNotOk={handleNotOkResult(
+ backend.state.username,
+ saveError,
+ i18n,
+ onRegister,
+ )}
+ />
+ );
+ }
+
+ if (backend.state.isUserAdministrator) {
+ return (
+ <AdminPage
+ onLoadNotOk={handleNotOkResult(
+ backend.state.username,
+ saveErrorAndLogout,
+ i18n,
+ onRegister,
+ )}
+ />
+ );
+ }
+
+ return (
+ <AccountPage
+ account={backend.state.username}
+ onLoadNotOk={handleNotOkResult(
+ backend.state.username,
+ saveErrorAndLogout,
+ i18n,
+ onRegister,
+ )}
+ />
+ );
+}
+
+function handleNotOkResult(
+ account: string,
+ onErrorHandler: (state: PageStateType["error"]) => void,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+ onRegister: () => void,
+): <T, E>(result: HttpResponsePaginated<T, E>) => VNode {
+ return function handleNotOkResult2<T, E>(
+ result: HttpResponsePaginated<T, E>,
+ ): VNode {
+ if (result.clientError && result.isUnauthorized) {
+ onErrorHandler({
+ title: i18n.str`Wrong credentials for "${account}"`,
+ });
+ return <LoginForm onRegister={onRegister} />;
+ }
+ if (result.clientError && result.isNotfound) {
+ onErrorHandler({
+ title: i18n.str`Username or account label "${account}" not found`,
+ });
+ return <LoginForm onRegister={onRegister} />;
+ }
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ onErrorHandler({
+ title: i18n.str`The backend reported a problem: HTTP status #${result.status}`,
+ description: `Diagnostic from ${result.info?.url.href} is "${result.message}"`,
+ debug: JSON.stringify(result.error),
+ });
+ return <LoginForm onRegister={onRegister} />;
+ }
+ return <div />;
+ };
+}
diff --git a/packages/demobank-ui/src/pages/LoginForm.tsx b/packages/demobank-ui/src/pages/LoginForm.tsx
index a5d8695dc..3d4279f99 100644
--- a/packages/demobank-ui/src/pages/LoginForm.tsx
+++ b/packages/demobank-ui/src/pages/LoginForm.tsx
@@ -14,21 +14,19 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { h, VNode } from "preact";
-import { route } from "preact-router";
+import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
+import { Fragment, h, VNode } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { useBackendContext } from "../context/backend.js";
-import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
-import { BackendStateHandler } from "../hooks/backend.js";
import { bankUiSettings } from "../settings.js";
-import { getBankBackendBaseUrl, undefinedIfEmpty } from "../utils.js";
+import { undefinedIfEmpty } from "../utils.js";
+import { PASSWORD_REGEX, USERNAME_REGEX } from "./RegistrationPage.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
-import { USERNAME_REGEX, PASSWORD_REGEX } from "./RegistrationPage.js";
/**
* Collect and submit login data.
*/
-export function LoginForm(): VNode {
+export function LoginForm({ onRegister }: { onRegister: () => void }): VNode {
const backend = useBackendContext();
const [username, setUsername] = useState<string | undefined>();
const [password, setPassword] = useState<string | undefined>();
@@ -52,107 +50,93 @@ export function LoginForm(): VNode {
});
return (
- <div class="login-div">
- <form
- class="login-form"
- noValidate
- onSubmit={(e) => {
- e.preventDefault();
- }}
- autoCapitalize="none"
- autoCorrect="off"
- >
- <div class="pure-form">
- <h2>{i18n.str`Please login!`}</h2>
- <p class="unameFieldLabel loginFieldLabel formFieldLabel">
- <label for="username">{i18n.str`Username:`}</label>
- </p>
- <input
- ref={ref}
- autoFocus
- type="text"
- name="username"
- id="username"
- value={username ?? ""}
- placeholder="Username"
- required
- onInput={(e): void => {
- setUsername(e.currentTarget.value);
- }}
- />
- <ShowInputErrorLabel
- message={errors?.username}
- isDirty={username !== undefined}
- />
- <p class="passFieldLabel loginFieldLabel formFieldLabel">
- <label for="password">{i18n.str`Password:`}</label>
- </p>
- <input
- type="password"
- name="password"
- id="password"
- value={password ?? ""}
- placeholder="Password"
- required
- onInput={(e): void => {
- setPassword(e.currentTarget.value);
- }}
- />
- <ShowInputErrorLabel
- message={errors?.password}
- isDirty={password !== undefined}
- />
- <br />
- <button
- type="submit"
- class="pure-button pure-button-primary"
- disabled={!!errors}
- onClick={(e) => {
- e.preventDefault();
- if (!username || !password) return;
- loginCall({ username, password }, backend);
- setUsername(undefined);
- setPassword(undefined);
- }}
- >
- {i18n.str`Login`}
- </button>
+ <Fragment>
+ <h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1>
- {bankUiSettings.allowRegistrations ? (
+ <div class="login-div">
+ <form
+ class="login-form"
+ noValidate
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ autoCapitalize="none"
+ autoCorrect="off"
+ >
+ <div class="pure-form">
+ <h2>{i18n.str`Please login!`}</h2>
+ <p class="unameFieldLabel loginFieldLabel formFieldLabel">
+ <label for="username">{i18n.str`Username:`}</label>
+ </p>
+ <input
+ ref={ref}
+ autoFocus
+ type="text"
+ name="username"
+ id="username"
+ value={username ?? ""}
+ placeholder="Username"
+ autocomplete="username"
+ required
+ onInput={(e): void => {
+ setUsername(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.username}
+ isDirty={username !== undefined}
+ />
+ <p class="passFieldLabel loginFieldLabel formFieldLabel">
+ <label for="password">{i18n.str`Password:`}</label>
+ </p>
+ <input
+ type="password"
+ name="password"
+ id="password"
+ autocomplete="current-password"
+ value={password ?? ""}
+ placeholder="Password"
+ required
+ onInput={(e): void => {
+ setPassword(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.password}
+ isDirty={password !== undefined}
+ />
+ <br />
<button
- class="pure-button pure-button-secondary btn-cancel"
+ type="submit"
+ class="pure-button pure-button-primary"
+ disabled={!!errors}
onClick={(e) => {
e.preventDefault();
- route("/register");
+ if (!username || !password) return;
+ backend.logIn({ username, password });
+ setUsername(undefined);
+ setPassword(undefined);
}}
>
- {i18n.str`Register`}
+ {i18n.str`Login`}
</button>
- ) : (
- <div />
- )}
- </div>
- </form>
- </div>
- );
-}
-
-async function loginCall(
- req: { username: string; password: string },
- /**
- * FIXME: figure out if the two following
- * functions can be retrieved from the state.
- */
- backend: BackendStateHandler,
-): Promise<void> {
- /**
- * Optimistically setting the state as 'logged in', and
- * let the Account component request the balance to check
- * whether the credentials are valid. */
- backend.save({
- url: getBankBackendBaseUrl(),
- username: req.username,
- password: req.password,
- });
+ {bankUiSettings.allowRegistrations ? (
+ <button
+ class="pure-button pure-button-secondary btn-cancel"
+ onClick={(e) => {
+ e.preventDefault();
+ onRegister();
+ }}
+ >
+ {i18n.str`Register`}
+ </button>
+ ) : (
+ <div />
+ )}
+ </div>
+ </form>
+ </div>
+ </Fragment>
+ );
}
diff --git a/packages/demobank-ui/src/pages/PaymentOptions.tsx b/packages/demobank-ui/src/pages/PaymentOptions.tsx
index ae876d556..dd04ed6e2 100644
--- a/packages/demobank-ui/src/pages/PaymentOptions.tsx
+++ b/packages/demobank-ui/src/pages/PaymentOptions.tsx
@@ -19,17 +19,22 @@ import { useState } from "preact/hooks";
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
import { WalletWithdrawForm } from "./WalletWithdrawForm.js";
+import { PageStateType, usePageContext } from "../context/pageState.js";
/**
* Let the user choose a payment option,
* then specify the details trigger the action.
*/
-export function PaymentOptions({ currency }: { currency?: string }): VNode {
+export function PaymentOptions({ currency }: { currency: string }): VNode {
const { i18n } = useTranslationContext();
+ const { pageStateSetter } = usePageContext();
const [tab, setTab] = useState<"charge-wallet" | "wire-transfer">(
"charge-wallet",
);
+ function saveError(error: PageStateType["error"]): void {
+ pageStateSetter((prev) => ({ ...prev, error }));
+ }
return (
<article>
@@ -55,13 +60,35 @@ export function PaymentOptions({ currency }: { currency?: string }): VNode {
{tab === "charge-wallet" && (
<div id="charge-wallet" class="tabcontent active">
<h3>{i18n.str`Obtain digital cash`}</h3>
- <WalletWithdrawForm focus currency={currency} />
+ <WalletWithdrawForm
+ focus
+ currency={currency}
+ onSuccess={(data) => {
+ pageStateSetter((prevState: PageStateType) => ({
+ ...prevState,
+ withdrawalInProgress: true,
+ talerWithdrawUri: data.taler_withdraw_uri,
+ withdrawalId: data.withdrawal_id,
+ }));
+ }}
+ onError={saveError}
+ />
</div>
)}
{tab === "wire-transfer" && (
<div id="wire-transfer" class="tabcontent active">
<h3>{i18n.str`Transfer to bank account`}</h3>
- <PaytoWireTransferForm focus currency={currency} />
+ <PaytoWireTransferForm
+ focus
+ currency={currency}
+ onSuccess={() => {
+ pageStateSetter((prevState: PageStateType) => ({
+ ...prevState,
+ info: i18n.str`Wire transfer created!`,
+ }));
+ }}
+ onError={saveError}
+ />
</div>
)}
</div>
diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx
index 46b006880..d859b1cc7 100644
--- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx
+++ b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx
@@ -14,64 +14,81 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { Amounts, Logger, parsePaytoUri } from "@gnu-taler/taler-util";
-import { useLocalStorage } from "@gnu-taler/web-util/lib/index.browser";
-import { h, VNode } from "preact";
-import { StateUpdater, useEffect, useRef, useState } from "preact/hooks";
-import { useBackendContext } from "../context/backend.js";
-import { PageStateType, usePageContext } from "../context/pageState.js";
+import {
+ Amounts,
+ buildPayto,
+ Logger,
+ parsePaytoUri,
+ stringifyPaytoUri,
+} from "@gnu-taler/taler-util";
import {
InternationalizationAPI,
+ RequestError,
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
+import { h, VNode } from "preact";
+import { StateUpdater, useEffect, useRef, useState } from "preact/hooks";
+import { useBackendContext } from "../context/backend.js";
+import { PageStateType, usePageContext } from "../context/pageState.js";
+import { useAccessAPI } from "../hooks/access.js";
import { BackendState } from "../hooks/backend.js";
-import { prepareHeaders, undefinedIfEmpty } from "../utils.js";
+import { undefinedIfEmpty } from "../utils.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
const logger = new Logger("PaytoWireTransferForm");
export function PaytoWireTransferForm({
focus,
+ onError,
+ onSuccess,
currency,
}: {
focus?: boolean;
- currency?: string;
+ onError: (e: PageStateType["error"]) => void;
+ onSuccess: () => void;
+ currency: string;
}): VNode {
const backend = useBackendContext();
- const { pageState, pageStateSetter } = usePageContext(); // NOTE: used for go-back button?
+ // const { pageState, pageStateSetter } = usePageContext(); // NOTE: used for go-back button?
- const [submitData, submitDataSetter] = useWireTransferRequestType();
+ const [isRawPayto, setIsRawPayto] = useState(false);
+ // const [submitData, submitDataSetter] = useWireTransferRequestType();
+ const [iban, setIban] = useState<string | undefined>(undefined);
+ const [subject, setSubject] = useState<string | undefined>(undefined);
+ const [amount, setAmount] = useState<string | undefined>(undefined);
const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>(
undefined,
);
const { i18n } = useTranslationContext();
const ibanRegex = "^[A-Z][A-Z][0-9]+$";
- let transactionData: TransactionRequestType;
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (focus) ref.current?.focus();
- }, [focus, pageState.isRawPayto]);
+ }, [focus, isRawPayto]);
let parsedAmount = undefined;
+ const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/;
const errorsWire = undefinedIfEmpty({
- iban: !submitData?.iban
+ iban: !iban
? i18n.str`Missing IBAN`
- : !/^[A-Z0-9]*$/.test(submitData.iban)
+ : !IBAN_REGEX.test(iban)
? i18n.str`IBAN should have just uppercased letters and numbers`
: undefined,
- subject: !submitData?.subject ? i18n.str`Missing subject` : undefined,
- amount: !submitData?.amount
+ subject: !subject ? i18n.str`Missing subject` : undefined,
+ amount: !amount
? i18n.str`Missing amount`
- : !(parsedAmount = Amounts.parse(`${currency}:${submitData.amount}`))
+ : !(parsedAmount = Amounts.parse(`${currency}:${amount}`))
? i18n.str`Amount is not valid`
: Amounts.isZero(parsedAmount)
? i18n.str`Should be greater than 0`
: undefined,
});
- if (!pageState.isRawPayto)
+ const { createTransaction } = useAccessAPI();
+
+ if (!isRawPayto)
return (
<div>
<form
@@ -90,21 +107,18 @@ export function PaytoWireTransferForm({
type="text"
id="iban"
name="iban"
- value={submitData?.iban ?? ""}
+ value={iban ?? ""}
placeholder="CC0123456789"
required
pattern={ibanRegex}
onInput={(e): void => {
- submitDataSetter((submitData) => ({
- ...submitData,
- iban: e.currentTarget.value,
- }));
+ setIban(e.currentTarget.value);
}}
/>
<br />
<ShowInputErrorLabel
message={errorsWire?.iban}
- isDirty={submitData?.iban !== undefined}
+ isDirty={iban !== undefined}
/>
<br />
<label for="subject">{i18n.str`Transfer subject:`}</label>&nbsp;
@@ -113,19 +127,16 @@ export function PaytoWireTransferForm({
name="subject"
id="subject"
placeholder="subject"
- value={submitData?.subject ?? ""}
+ value={subject ?? ""}
required
onInput={(e): void => {
- submitDataSetter((submitData) => ({
- ...submitData,
- subject: e.currentTarget.value,
- }));
+ setSubject(e.currentTarget.value);
}}
/>
<br />
<ShowInputErrorLabel
message={errorsWire?.subject}
- isDirty={submitData?.subject !== undefined}
+ isDirty={subject !== undefined}
/>
<br />
<label for="amount">{i18n.str`Amount:`}</label>&nbsp;
@@ -146,18 +157,15 @@ export function PaytoWireTransferForm({
id="amount"
placeholder="amount"
required
- value={submitData?.amount ?? ""}
+ value={amount ?? ""}
onInput={(e): void => {
- submitDataSetter((submitData) => ({
- ...submitData,
- amount: e.currentTarget.value,
- }));
+ setAmount(e.currentTarget.value);
}}
/>
</div>
<ShowInputErrorLabel
message={errorsWire?.amount}
- isDirty={submitData?.amount !== undefined}
+ isDirty={amount !== undefined}
/>
</p>
@@ -169,43 +177,28 @@ export function PaytoWireTransferForm({
value="Send"
onClick={async (e) => {
e.preventDefault();
- if (
- typeof submitData === "undefined" ||
- typeof submitData.iban === "undefined" ||
- submitData.iban === "" ||
- typeof submitData.subject === "undefined" ||
- submitData.subject === "" ||
- typeof submitData.amount === "undefined" ||
- submitData.amount === ""
- ) {
- logger.error("Not all the fields were given.");
- pageStateSetter((prevState: PageStateType) => ({
- ...prevState,
-
- error: {
- title: i18n.str`Field(s) missing.`,
- },
- }));
+ if (!(iban && subject && amount)) {
return;
}
- transactionData = {
- paytoUri: `payto://iban/${
- submitData.iban
- }?message=${encodeURIComponent(submitData.subject)}`,
- amount: `${currency}:${submitData.amount}`,
- };
- return await createTransactionCall(
- transactionData,
- backend.state,
- pageStateSetter,
- () =>
- submitDataSetter((p) => ({
- amount: undefined,
- iban: undefined,
- subject: undefined,
- })),
- i18n,
- );
+ const ibanPayto = buildPayto("iban", iban, undefined);
+ ibanPayto.params.message = encodeURIComponent(subject);
+ const paytoUri = stringifyPaytoUri(ibanPayto);
+
+ await createTransaction({
+ paytoUri,
+ amount: `${currency}:${amount}`,
+ });
+ // return await createTransactionCall(
+ // transactionData,
+ // backend.state,
+ // pageStateSetter,
+ // () => {
+ // setAmount(undefined);
+ // setIban(undefined);
+ // setSubject(undefined);
+ // },
+ // i18n,
+ // );
}}
/>
<input
@@ -214,11 +207,9 @@ export function PaytoWireTransferForm({
value="Clear"
onClick={async (e) => {
e.preventDefault();
- submitDataSetter((p) => ({
- amount: undefined,
- iban: undefined,
- subject: undefined,
- }));
+ setAmount(undefined);
+ setIban(undefined);
+ setSubject(undefined);
}}
/>
</p>
@@ -227,11 +218,7 @@ export function PaytoWireTransferForm({
<a
href="/account"
onClick={() => {
- logger.trace("switch to raw payto form");
- pageStateSetter((prevState) => ({
- ...prevState,
- isRawPayto: true,
- }));
+ setIsRawPayto(true);
}}
>
{i18n.str`Want to try the raw payto://-format?`}
@@ -240,11 +227,23 @@ export function PaytoWireTransferForm({
</div>
);
+ const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput);
+
const errorsPayto = undefinedIfEmpty({
rawPaytoInput: !rawPaytoInput
- ? i18n.str`Missing payto address`
- : !parsePaytoUri(rawPaytoInput)
- ? i18n.str`Payto does not follow the pattern`
+ ? i18n.str`required`
+ : !parsed
+ ? i18n.str`does not follow the pattern`
+ : !parsed.params.amount
+ ? i18n.str`use the "amount" parameter to specify the amount to be transferred`
+ : Amounts.parse(parsed.params.amount) === undefined
+ ? i18n.str`the amount is not valid`
+ : !parsed.params.message
+ ? i18n.str`use the "message" parameter to specify a reference text for the transfer`
+ : !parsed.isKnown || parsed.targetType !== "iban"
+ ? i18n.str`only "IBAN" target are supported`
+ : !IBAN_REGEX.test(parsed.iban)
+ ? i18n.str`IBAN should have just uppercased letters and numbers`
: undefined,
});
@@ -296,25 +295,29 @@ export function PaytoWireTransferForm({
disabled={!!errorsPayto}
value={i18n.str`Send`}
onClick={async () => {
- // empty string evaluates to false.
if (!rawPaytoInput) {
logger.error("Didn't get any raw Payto string!");
return;
}
- transactionData = { paytoUri: rawPaytoInput };
- if (
- typeof transactionData.paytoUri === "undefined" ||
- transactionData.paytoUri.length === 0
- )
- return;
- return await createTransactionCall(
- transactionData,
- backend.state,
- pageStateSetter,
- () => rawPaytoInputSetter(undefined),
- i18n,
- );
+ try {
+ await createTransaction({
+ paytoUri: rawPaytoInput,
+ });
+ onSuccess();
+ rawPaytoInputSetter(undefined);
+ } catch (error) {
+ if (error instanceof RequestError) {
+ const errorData: SandboxBackend.SandboxError =
+ error.info.error;
+
+ onError({
+ title: i18n.str`Transfer creation gave response error`,
+ description: errorData.error.description,
+ debug: JSON.stringify(errorData),
+ });
+ }
+ }
}}
/>
</p>
@@ -322,11 +325,7 @@ export function PaytoWireTransferForm({
<a
href="/account"
onClick={() => {
- logger.trace("switch to wire-transfer-form");
- pageStateSetter((prevState) => ({
- ...prevState,
- isRawPayto: false,
- }));
+ setIsRawPayto(false);
}}
>
{i18n.str`Use wire-transfer form?`}
@@ -336,115 +335,3 @@ export function PaytoWireTransferForm({
</div>
);
}
-
-/**
- * Stores in the state a object representing a wire transfer,
- * in order to avoid losing the handle of the data entered by
- * the user in <input> fields. FIXME: name not matching the
- * purpose, as this is not a HTTP request body but rather the
- * state of the <input>-elements.
- */
-type WireTransferRequestTypeOpt = WireTransferRequestType | undefined;
-function useWireTransferRequestType(
- state?: WireTransferRequestType,
-): [WireTransferRequestTypeOpt, StateUpdater<WireTransferRequestTypeOpt>] {
- const ret = useLocalStorage(
- "wire-transfer-request-state",
- JSON.stringify(state),
- );
- const retObj: WireTransferRequestTypeOpt = ret[0]
- ? JSON.parse(ret[0])
- : ret[0];
- const retSetter: StateUpdater<WireTransferRequestTypeOpt> = function (val) {
- const newVal =
- val instanceof Function
- ? JSON.stringify(val(retObj))
- : JSON.stringify(val);
- ret[1](newVal);
- };
- return [retObj, retSetter];
-}
-
-/**
- * This function creates a new transaction. It reads a Payto
- * address entered by the user and POSTs it to the bank. No
- * sanity-check of the input happens before the POST as this is
- * already conducted by the backend.
- */
-async function createTransactionCall(
- req: TransactionRequestType,
- backendState: BackendState,
- pageStateSetter: StateUpdater<PageStateType>,
- /**
- * Optional since the raw payto form doesn't have
- * a stateful management of the input data yet.
- */
- cleanUpForm: () => void,
- i18n: InternationalizationAPI,
-): Promise<void> {
- if (backendState.status === "loggedOut") {
- logger.error("No credentials found.");
- pageStateSetter((prevState) => ({
- ...prevState,
-
- error: {
- title: i18n.str`No credentials found.`,
- },
- }));
- return;
- }
- let res: Response;
- try {
- const { username, password } = backendState;
- const headers = prepareHeaders(username, password);
- const url = new URL(
- `access-api/accounts/${backendState.username}/transactions`,
- backendState.url,
- );
- res = await fetch(url.href, {
- method: "POST",
- headers,
- body: JSON.stringify(req),
- });
- } catch (error) {
- logger.error("Could not POST transaction request to the bank", error);
- pageStateSetter((prevState) => ({
- ...prevState,
-
- error: {
- title: i18n.str`Could not create the wire transfer`,
- description: (error as any).error.description,
- debug: JSON.stringify(error),
- },
- }));
- return;
- }
- // POST happened, status not sure yet.
- if (!res.ok) {
- const response = await res.json();
- logger.error(
- `Transfer creation gave response error: ${response} (${res.status})`,
- );
- pageStateSetter((prevState) => ({
- ...prevState,
-
- error: {
- title: i18n.str`Transfer creation gave response error`,
- description: response.error.description,
- debug: JSON.stringify(response),
- },
- }));
- return;
- }
- // status is 200 OK here, tell the user.
- logger.trace("Wire transfer created!");
- pageStateSetter((prevState) => ({
- ...prevState,
-
- info: i18n.str`Wire transfer created!`,
- }));
-
- // Only at this point the input data can
- // be discarded.
- cleanUpForm();
-}
diff --git a/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx b/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx
index 7bf5c41c7..54a77b42a 100644
--- a/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx
+++ b/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx
@@ -15,91 +15,42 @@
*/
import { Logger } from "@gnu-taler/taler-util";
-import { useLocalStorage } from "@gnu-taler/web-util/lib/index.browser";
-import { ComponentChildren, Fragment, h, VNode } from "preact";
-import { route } from "preact-router";
+import {
+ HttpResponsePaginated,
+ useLocalStorage,
+ useTranslationContext,
+} from "@gnu-taler/web-util/lib/index.browser";
+import { Fragment, h, VNode } from "preact";
import { StateUpdater } from "preact/hooks";
-import useSWR, { SWRConfig } from "swr";
-import { PageStateType, usePageContext } from "../context/pageState.js";
-import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
-import { getBankBackendBaseUrl } from "../utils.js";
-import { BankFrame } from "./BankFrame.js";
import { Transactions } from "../components/Transactions/index.js";
+import { usePublicAccounts } from "../hooks/access.js";
const logger = new Logger("PublicHistoriesPage");
-export function PublicHistoriesPage(): VNode {
- return (
- <SWRWithoutCredentials baseUrl={getBankBackendBaseUrl()}>
- <BankFrame>
- <PublicHistories />
- </BankFrame>
- </SWRWithoutCredentials>
- );
-}
-
-function SWRWithoutCredentials({
- baseUrl,
- children,
-}: {
- children: ComponentChildren;
- baseUrl: string;
-}): VNode {
- logger.trace("Base URL", baseUrl);
- return (
- <SWRConfig
- value={{
- fetcher: (url: string) =>
- fetch(baseUrl + url || "").then((r) => {
- if (!r.ok) throw { status: r.status, json: r.json() };
+// export function PublicHistoriesPage2(): VNode {
+// return (
+// <BankFrame>
+// <PublicHistories />
+// </BankFrame>
+// );
+// }
- return r.json();
- }),
- }}
- >
- {children as any}
- </SWRConfig>
- );
+interface Props {
+ onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
}
/**
* Show histories of public accounts.
*/
-function PublicHistories(): VNode {
- const { pageState, pageStateSetter } = usePageContext();
+export function PublicHistoriesPage({ onLoadNotOk }: Props): VNode {
const [showAccount, setShowAccount] = useShowPublicAccount();
- const { data, error } = useSWR("access-api/public-accounts");
const { i18n } = useTranslationContext();
- if (typeof error !== "undefined") {
- switch (error.status) {
- case 404:
- logger.error("public accounts: 404", error);
- route("/account");
- pageStateSetter((prevState: PageStateType) => ({
- ...prevState,
+ const result = usePublicAccounts();
+ if (!result.ok) return onLoadNotOk(result);
- error: {
- title: i18n.str`List of public accounts was not found.`,
- debug: JSON.stringify(error),
- },
- }));
- break;
- default:
- logger.error("public accounts: non-404 error", error);
- route("/account");
- pageStateSetter((prevState: PageStateType) => ({
- ...prevState,
+ const { data } = result;
- error: {
- title: i18n.str`List of public accounts could not be retrieved.`,
- debug: JSON.stringify(error),
- },
- }));
- break;
- }
- }
- if (!data) return <p>Waiting public accounts list...</p>;
const txs: Record<string, h.JSX.Element> = {};
const accountsBar = [];
@@ -133,9 +84,7 @@ function PublicHistories(): VNode {
</a>
</li>,
);
- txs[account.accountLabel] = (
- <Transactions accountLabel={account.accountLabel} pageNumber={0} />
- );
+ txs[account.accountLabel] = <Transactions account={account.accountLabel} />;
}
return (
diff --git a/packages/demobank-ui/src/pages/QrCodeSection.tsx b/packages/demobank-ui/src/pages/QrCodeSection.tsx
index e02c6efb1..708e28657 100644
--- a/packages/demobank-ui/src/pages/QrCodeSection.tsx
+++ b/packages/demobank-ui/src/pages/QrCodeSection.tsx
@@ -21,10 +21,10 @@ import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
export function QrCodeSection({
talerWithdrawUri,
- abortButton,
+ onAbort,
}: {
talerWithdrawUri: string;
- abortButton: h.JSX.Element;
+ onAbort: () => void;
}): VNode {
const { i18n } = useTranslationContext();
useEffect(() => {
@@ -62,7 +62,10 @@ export function QrCodeSection({
</i18n.Translate>
</p>
<br />
- {abortButton}
+ <a
+ class="pure-button btn-cancel"
+ onClick={onAbort}
+ >{i18n.str`Abort`}</a>
</div>
</article>
</section>
diff --git a/packages/demobank-ui/src/pages/RegistrationPage.tsx b/packages/demobank-ui/src/pages/RegistrationPage.tsx
index 29f1bf5ee..247ef8d80 100644
--- a/packages/demobank-ui/src/pages/RegistrationPage.tsx
+++ b/packages/demobank-ui/src/pages/RegistrationPage.tsx
@@ -13,38 +13,36 @@
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 { Logger } from "@gnu-taler/taler-util";
-import { Fragment, h, VNode } from "preact";
-import { route } from "preact-router";
-import { StateUpdater, useState } from "preact/hooks";
-import { useBackendContext } from "../context/backend.js";
-import { PageStateType, usePageContext } from "../context/pageState.js";
+import { HttpStatusCode, Logger } from "@gnu-taler/taler-util";
import {
- InternationalizationAPI,
+ RequestError,
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
-import { BackendStateHandler } from "../hooks/backend.js";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { useBackendContext } from "../context/backend.js";
+import { PageStateType } from "../context/pageState.js";
+import { useTestingAPI } from "../hooks/access.js";
import { bankUiSettings } from "../settings.js";
-import { getBankBackendBaseUrl, undefinedIfEmpty } from "../utils.js";
-import { BankFrame } from "./BankFrame.js";
+import { undefinedIfEmpty } from "../utils.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
const logger = new Logger("RegistrationPage");
-export function RegistrationPage(): VNode {
+export function RegistrationPage({
+ onError,
+ onComplete,
+}: {
+ onComplete: () => void;
+ onError: (e: PageStateType["error"]) => void;
+}): VNode {
const { i18n } = useTranslationContext();
if (!bankUiSettings.allowRegistrations) {
return (
- <BankFrame>
- <p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p>
- </BankFrame>
+ <p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p>
);
}
- return (
- <BankFrame>
- <RegistrationForm />
- </BankFrame>
- );
+ return <RegistrationForm onComplete={onComplete} onError={onError} />;
}
export const USERNAME_REGEX = /^[a-z][a-zA-Z0-9]*$/;
@@ -53,13 +51,19 @@ export const PASSWORD_REGEX = /^[a-z0-9][a-zA-Z0-9]*$/;
/**
* Collect and submit registration data.
*/
-function RegistrationForm(): VNode {
+function RegistrationForm({
+ onComplete,
+ onError,
+}: {
+ onComplete: () => void;
+ onError: (e: PageStateType["error"]) => void;
+}): VNode {
const backend = useBackendContext();
- const { pageState, pageStateSetter } = usePageContext();
const [username, setUsername] = useState<string | undefined>();
const [password, setPassword] = useState<string | undefined>();
const [repeatPassword, setRepeatPassword] = useState<string | undefined>();
+ const { register } = useTestingAPI();
const { i18n } = useTranslationContext();
const errors = undefinedIfEmpty({
@@ -104,6 +108,7 @@ function RegistrationForm(): VNode {
name="register-un"
type="text"
placeholder="Username"
+ autocomplete="username"
value={username ?? ""}
onInput={(e): void => {
setUsername(e.currentTarget.value);
@@ -121,6 +126,7 @@ function RegistrationForm(): VNode {
name="register-pw"
id="register-pw"
placeholder="Password"
+ autocomplete="new-password"
value={password ?? ""}
required
onInput={(e): void => {
@@ -139,6 +145,7 @@ function RegistrationForm(): VNode {
style={{ marginBottom: 8 }}
name="register-repeat"
id="register-repeat"
+ autocomplete="new-password"
placeholder="Same password"
value={repeatPassword ?? ""}
required
@@ -155,19 +162,42 @@ function RegistrationForm(): VNode {
class="pure-button pure-button-primary btn-register"
type="submit"
disabled={!!errors}
- onClick={(e) => {
+ onClick={async (e) => {
e.preventDefault();
- if (!username || !password) return;
- registrationCall(
- { username, password },
- backend, // will store BE URL, if OK.
- pageStateSetter,
- i18n,
- );
- setUsername(undefined);
- setPassword(undefined);
- setRepeatPassword(undefined);
+ if (!username || !password) return;
+ try {
+ const credentials = { username, password };
+ await register(credentials);
+ setUsername(undefined);
+ setPassword(undefined);
+ setRepeatPassword(undefined);
+ backend.logIn(credentials);
+ onComplete();
+ } catch (error) {
+ if (error instanceof RequestError) {
+ const errorData: SandboxBackend.SandboxError =
+ error.info.error;
+ if (error.info.status === HttpStatusCode.Conflict) {
+ onError({
+ title: i18n.str`That username is already taken`,
+ description: errorData.error.description,
+ debug: JSON.stringify(error.info),
+ });
+ } else {
+ onError({
+ title: i18n.str`New registration gave response error`,
+ description: errorData.error.description,
+ debug: JSON.stringify(error.info),
+ });
+ }
+ } else if (error instanceof Error) {
+ onError({
+ title: i18n.str`Registration failed, please report`,
+ description: error.message,
+ });
+ }
+ }
}}
>
{i18n.str`Register`}
@@ -180,7 +210,7 @@ function RegistrationForm(): VNode {
setUsername(undefined);
setPassword(undefined);
setRepeatPassword(undefined);
- route("/account");
+ onComplete();
}}
>
{i18n.str`Cancel`}
@@ -192,83 +222,3 @@ function RegistrationForm(): VNode {
</Fragment>
);
}
-
-/**
- * This function requests /register.
- *
- * This function is responsible to change two states:
- * the backend's (to store the login credentials) and
- * the page's (to indicate a successful login or a problem).
- */
-async function registrationCall(
- req: { username: string; password: string },
- /**
- * FIXME: figure out if the two following
- * functions can be retrieved somewhat from
- * the state.
- */
- backend: BackendStateHandler,
- pageStateSetter: StateUpdater<PageStateType>,
- i18n: InternationalizationAPI,
-): Promise<void> {
- const url = getBankBackendBaseUrl();
-
- const headers = new Headers();
- headers.append("Content-Type", "application/json");
- const registerEndpoint = new URL("access-api/testing/register", url);
- let res: Response;
- try {
- res = await fetch(registerEndpoint.href, {
- method: "POST",
- body: JSON.stringify({
- username: req.username,
- password: req.password,
- }),
- headers,
- });
- } catch (error) {
- logger.error(
- `Could not POST new registration to the bank (${registerEndpoint.href})`,
- error,
- );
- pageStateSetter((prevState) => ({
- ...prevState,
-
- error: {
- title: i18n.str`Registration failed, please report`,
- debug: JSON.stringify(error),
- },
- }));
- return;
- }
- if (!res.ok) {
- const response = await res.json();
- if (res.status === 409) {
- pageStateSetter((prevState) => ({
- ...prevState,
-
- error: {
- title: i18n.str`That username is already taken`,
- debug: JSON.stringify(response),
- },
- }));
- } else {
- pageStateSetter((prevState) => ({
- ...prevState,
-
- error: {
- title: i18n.str`New registration gave response error`,
- debug: JSON.stringify(response),
- },
- }));
- }
- } else {
- // registration was ok
- backend.save({
- url,
- username: req.username,
- password: req.password,
- });
- route("/account");
- }
-}
diff --git a/packages/demobank-ui/src/pages/Routing.tsx b/packages/demobank-ui/src/pages/Routing.tsx
index 3c3aae0ce..a88af9b0b 100644
--- a/packages/demobank-ui/src/pages/Routing.tsx
+++ b/packages/demobank-ui/src/pages/Routing.tsx
@@ -14,21 +14,97 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+import {
+ HttpResponsePaginated,
+ useTranslationContext,
+} from "@gnu-taler/web-util/lib/index.browser";
import { createHashHistory } from "history";
import { h, VNode } from "preact";
import Router, { route, Route } from "preact-router";
import { useEffect } from "preact/hooks";
-import { AccountPage } from "./AccountPage.js";
+import { Loading } from "../components/Loading.js";
+import { PageStateType, usePageContext } from "../context/pageState.js";
+import { HomePage } from "./HomePage.js";
+import { BankFrame } from "./BankFrame.js";
import { PublicHistoriesPage } from "./PublicHistoriesPage.js";
import { RegistrationPage } from "./RegistrationPage.js";
+function handleNotOkResult(
+ safe: string,
+ saveError: (state: PageStateType["error"]) => void,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): <T, E>(result: HttpResponsePaginated<T, E>) => VNode {
+ return function handleNotOkResult2<T, E>(
+ result: HttpResponsePaginated<T, E>,
+ ): VNode {
+ if (result.clientError && result.isUnauthorized) {
+ route(safe);
+ return <Loading />;
+ }
+ if (result.clientError && result.isNotfound) {
+ route(safe);
+ return (
+ <div>Page not found, you are going to be redirected to {safe}</div>
+ );
+ }
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ saveError({
+ title: i18n.str`The backend reported a problem: HTTP status #${result.status}`,
+ description: i18n.str`Diagnostic from ${result.info?.url} is "${result.message}"`,
+ debug: JSON.stringify(result.error),
+ });
+ route(safe);
+ }
+ return <div />;
+ };
+}
+
export function Routing(): VNode {
const history = createHashHistory();
+ const { pageStateSetter } = usePageContext();
+
+ function saveError(error: PageStateType["error"]): void {
+ pageStateSetter((prev) => ({ ...prev, error }));
+ }
+ const { i18n } = useTranslationContext();
return (
<Router history={history}>
- <Route path="/public-accounts" component={PublicHistoriesPage} />
- <Route path="/register" component={RegistrationPage} />
- <Route path="/account" component={AccountPage} />
+ <Route
+ path="/public-accounts"
+ component={() => (
+ <BankFrame>
+ <PublicHistoriesPage
+ onLoadNotOk={handleNotOkResult("/account", saveError, i18n)}
+ />
+ </BankFrame>
+ )}
+ />
+ <Route
+ path="/register"
+ component={() => (
+ <BankFrame>
+ <RegistrationPage
+ onError={saveError}
+ onComplete={() => {
+ route("/account");
+ }}
+ />
+ </BankFrame>
+ )}
+ />
+ <Route
+ path="/account"
+ component={() => (
+ <BankFrame>
+ <HomePage
+ onRegister={() => {
+ route("/register");
+ }}
+ />
+ </BankFrame>
+ )}
+ />
<Route default component={Redirect} to="/account" />
</Router>
);
diff --git a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx
index a1b616657..2b2df3baa 100644
--- a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx
+++ b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx
@@ -14,36 +14,54 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { Logger } from "@gnu-taler/taler-util";
-import { h, VNode } from "preact";
-import { StateUpdater, useEffect, useRef } from "preact/hooks";
-import { useBackendContext } from "../context/backend.js";
-import { PageStateType, usePageContext } from "../context/pageState.js";
+import { Amounts, Logger } from "@gnu-taler/taler-util";
import {
- InternationalizationAPI,
+ RequestError,
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
-import { BackendState } from "../hooks/backend.js";
-import { prepareHeaders, validateAmount } from "../utils.js";
+import { h, VNode } from "preact";
+import { useEffect, useRef, useState } from "preact/hooks";
+import { PageStateType, usePageContext } from "../context/pageState.js";
+import { useAccessAPI } from "../hooks/access.js";
+import { undefinedIfEmpty } from "../utils.js";
+import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
const logger = new Logger("WalletWithdrawForm");
export function WalletWithdrawForm({
focus,
currency,
+ onError,
+ onSuccess,
}: {
- currency?: string;
+ currency: string;
focus?: boolean;
+ onError: (e: PageStateType["error"]) => void;
+ onSuccess: (
+ data: SandboxBackend.Access.BankAccountCreateWithdrawalResponse,
+ ) => void;
}): VNode {
- const backend = useBackendContext();
- const { pageState, pageStateSetter } = usePageContext();
+ // const backend = useBackendContext();
+ // const { pageState, pageStateSetter } = usePageContext();
const { i18n } = useTranslationContext();
- let submitAmount: string | undefined = "5.00";
+ const { createWithdrawal } = useAccessAPI();
+ const [amount, setAmount] = useState<string | undefined>("5.00");
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (focus) ref.current?.focus();
}, [focus]);
+
+ const amountFloat = amount ? parseFloat(amount) : undefined;
+ const errors = undefinedIfEmpty({
+ amount: !amountFloat
+ ? i18n.str`required`
+ : Number.isNaN(amountFloat)
+ ? i18n.str`should be a number`
+ : amountFloat < 0
+ ? i18n.str`should be positive`
+ : undefined,
+ });
return (
<form
id="reserve-form"
@@ -63,8 +81,8 @@ export function WalletWithdrawForm({
type="text"
readonly
class="currency-indicator"
- size={currency?.length ?? 5}
- maxLength={currency?.length}
+ size={currency.length}
+ maxLength={currency.length}
tabIndex={-1}
value={currency}
/>
@@ -74,14 +92,15 @@ export function WalletWithdrawForm({
ref={ref}
id="withdraw-amount"
name="withdraw-amount"
- value={submitAmount}
+ value={amount ?? ""}
onChange={(e): void => {
- // FIXME: validate using 'parseAmount()',
- // deactivate submit button as long as
- // amount is not valid
- submitAmount = e.currentTarget.value;
+ setAmount(e.currentTarget.value);
}}
/>
+ <ShowInputErrorLabel
+ message={errors?.amount}
+ isDirty={amount !== undefined}
+ />
</div>
</p>
<p>
@@ -90,22 +109,34 @@ export function WalletWithdrawForm({
id="select-exchange"
class="pure-button pure-button-primary"
type="submit"
+ disabled={!!errors}
value={i18n.str`Withdraw`}
- onClick={(e) => {
+ onClick={async (e) => {
e.preventDefault();
- submitAmount = validateAmount(submitAmount);
- /**
- * By invalid amounts, the validator prints error messages
- * on the console, and the browser colourizes the amount input
- * box to indicate a error.
- */
- if (!submitAmount && currency) return;
- createWithdrawalCall(
- `${currency}:${submitAmount}`,
- backend.state,
- pageStateSetter,
- i18n,
- );
+ if (!amountFloat) return;
+ try {
+ const result = await createWithdrawal({
+ amount: Amounts.stringify(
+ Amounts.fromFloat(amountFloat, currency),
+ ),
+ });
+
+ onSuccess(result.data);
+ } catch (error) {
+ if (error instanceof RequestError) {
+ onError({
+ title: i18n.str`Could not create withdrawal operation`,
+ description: (error as any).error.description,
+ debug: JSON.stringify(error),
+ });
+ }
+ if (error instanceof Error) {
+ onError({
+ title: i18n.str`Something when wrong trying to start the withdrawal`,
+ description: error.message,
+ });
+ }
+ }
}}
/>
</div>
@@ -114,84 +145,84 @@ export function WalletWithdrawForm({
);
}
-/**
- * This function creates a withdrawal operation via the Access API.
- *
- * After having successfully created the withdrawal operation, the
- * user should receive a QR code of the "taler://withdraw/" type and
- * supposed to scan it with their phone.
- *
- * TODO: (1) after the scan, the page should refresh itself and inform
- * the user about the operation's outcome. (2) use POST helper. */
-async function createWithdrawalCall(
- amount: string,
- backendState: BackendState,
- pageStateSetter: StateUpdater<PageStateType>,
- i18n: InternationalizationAPI,
-): Promise<void> {
- if (backendState?.status === "loggedOut") {
- logger.error("Page has a problem: no credentials found in the state.");
- pageStateSetter((prevState) => ({
- ...prevState,
-
- error: {
- title: i18n.str`No credentials given.`,
- },
- }));
- return;
- }
-
- let res: Response;
- try {
- const { username, password } = backendState;
- const headers = prepareHeaders(username, password);
-
- // Let bank generate withdraw URI:
- const url = new URL(
- `access-api/accounts/${backendState.username}/withdrawals`,
- backendState.url,
- );
- res = await fetch(url.href, {
- method: "POST",
- headers,
- body: JSON.stringify({ amount }),
- });
- } catch (error) {
- logger.trace("Could not POST withdrawal request to the bank", error);
- pageStateSetter((prevState) => ({
- ...prevState,
-
- error: {
- title: i18n.str`Could not create withdrawal operation`,
- description: (error as any).error.description,
- debug: JSON.stringify(error),
- },
- }));
- return;
- }
- if (!res.ok) {
- const response = await res.json();
- logger.error(
- `Withdrawal creation gave response error: ${response} (${res.status})`,
- );
- pageStateSetter((prevState) => ({
- ...prevState,
-
- error: {
- title: i18n.str`Withdrawal creation gave response error`,
- description: response.error.description,
- debug: JSON.stringify(response),
- },
- }));
- return;
- }
-
- logger.trace("Withdrawal operation created!");
- const resp = await res.json();
- pageStateSetter((prevState: PageStateType) => ({
- ...prevState,
- withdrawalInProgress: true,
- talerWithdrawUri: resp.taler_withdraw_uri,
- withdrawalId: resp.withdrawal_id,
- }));
-}
+// /**
+// * This function creates a withdrawal operation via the Access API.
+// *
+// * After having successfully created the withdrawal operation, the
+// * user should receive a QR code of the "taler://withdraw/" type and
+// * supposed to scan it with their phone.
+// *
+// * TODO: (1) after the scan, the page should refresh itself and inform
+// * the user about the operation's outcome. (2) use POST helper. */
+// async function createWithdrawalCall(
+// amount: string,
+// backendState: BackendState,
+// pageStateSetter: StateUpdater<PageStateType>,
+// i18n: InternationalizationAPI,
+// ): Promise<void> {
+// if (backendState?.status === "loggedOut") {
+// logger.error("Page has a problem: no credentials found in the state.");
+// pageStateSetter((prevState) => ({
+// ...prevState,
+
+// error: {
+// title: i18n.str`No credentials given.`,
+// },
+// }));
+// return;
+// }
+
+// let res: Response;
+// try {
+// const { username, password } = backendState;
+// const headers = prepareHeaders(username, password);
+
+// // Let bank generate withdraw URI:
+// const url = new URL(
+// `access-api/accounts/${backendState.username}/withdrawals`,
+// backendState.url,
+// );
+// res = await fetch(url.href, {
+// method: "POST",
+// headers,
+// body: JSON.stringify({ amount }),
+// });
+// } catch (error) {
+// logger.trace("Could not POST withdrawal request to the bank", error);
+// pageStateSetter((prevState) => ({
+// ...prevState,
+
+// error: {
+// title: i18n.str`Could not create withdrawal operation`,
+// description: (error as any).error.description,
+// debug: JSON.stringify(error),
+// },
+// }));
+// return;
+// }
+// if (!res.ok) {
+// const response = await res.json();
+// logger.error(
+// `Withdrawal creation gave response error: ${response} (${res.status})`,
+// );
+// pageStateSetter((prevState) => ({
+// ...prevState,
+
+// error: {
+// title: i18n.str`Withdrawal creation gave response error`,
+// description: response.error.description,
+// debug: JSON.stringify(response),
+// },
+// }));
+// return;
+// }
+
+// logger.trace("Withdrawal operation created!");
+// const resp = await res.json();
+// pageStateSetter((prevState: PageStateType) => ({
+// ...prevState,
+// withdrawalInProgress: true,
+// talerWithdrawUri: resp.taler_withdraw_uri,
+// withdrawalId: resp.withdrawal_id,
+// }));
+// }
diff --git a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx
index b87b77c83..4e5c621e2 100644
--- a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx
+++ b/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx
@@ -15,24 +15,29 @@
*/
import { Logger } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { Fragment, h, VNode } from "preact";
-import { StateUpdater, useMemo, useState } from "preact/hooks";
+import { useMemo, useState } from "preact/hooks";
import { useBackendContext } from "../context/backend.js";
-import { PageStateType, usePageContext } from "../context/pageState.js";
-import {
- InternationalizationAPI,
- useTranslationContext,
-} from "@gnu-taler/web-util/lib/index.browser";
-import { BackendState } from "../hooks/backend.js";
-import { prepareHeaders } from "../utils.js";
+import { usePageContext } from "../context/pageState.js";
+import { useAccessAPI } from "../hooks/access.js";
+import { undefinedIfEmpty } from "../utils.js";
+import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
const logger = new Logger("WithdrawalConfirmationQuestion");
+interface Props {
+ account: string;
+ withdrawalId: string;
+}
/**
* Additional authentication required to complete the operation.
* Not providing a back button, only abort.
*/
-export function WithdrawalConfirmationQuestion(): VNode {
+export function WithdrawalConfirmationQuestion({
+ account,
+ withdrawalId,
+}: Props): VNode {
const { pageState, pageStateSetter } = usePageContext();
const backend = useBackendContext();
const { i18n } = useTranslationContext();
@@ -42,10 +47,20 @@ export function WithdrawalConfirmationQuestion(): VNode {
a: Math.floor(Math.random() * 10),
b: Math.floor(Math.random() * 10),
};
- }, [pageState.withdrawalId]);
+ }, []);
+ const { confirmWithdrawal, abortWithdrawal } = useAccessAPI();
const [captchaAnswer, setCaptchaAnswer] = useState<string | undefined>();
-
+ const answer = parseInt(captchaAnswer ?? "", 10);
+ const errors = undefinedIfEmpty({
+ answer: !captchaAnswer
+ ? i18n.str`Answer the question before continue`
+ : Number.isNaN(answer)
+ ? i18n.str`The answer should be a number`
+ : answer !== captchaNumbers.a + captchaNumbers.b
+ ? i18n.str`The answer "${answer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.`
+ : undefined,
+ });
return (
<Fragment>
<h1 class="nav">{i18n.str`Confirm Withdrawal`}</h1>
@@ -82,33 +97,49 @@ export function WithdrawalConfirmationQuestion(): VNode {
setCaptchaAnswer(e.currentTarget.value);
}}
/>
+ <ShowInputErrorLabel
+ message={errors?.answer}
+ isDirty={captchaAnswer !== undefined}
+ />
</p>
<p>
<button
type="submit"
class="pure-button pure-button-primary btn-confirm"
+ disabled={!!errors}
onClick={async (e) => {
e.preventDefault();
- if (
- captchaAnswer ==
- (captchaNumbers.a + captchaNumbers.b).toString()
- ) {
- await confirmWithdrawalCall(
- backend.state,
- pageState.withdrawalId,
- pageStateSetter,
- i18n,
- );
- return;
+ try {
+ await confirmWithdrawal(withdrawalId);
+ pageStateSetter((prevState) => {
+ const { talerWithdrawUri, ...rest } = prevState;
+ return {
+ ...rest,
+ info: i18n.str`Withdrawal confirmed!`,
+ };
+ });
+ } catch (error) {
+ pageStateSetter((prevState) => ({
+ ...prevState,
+ error: {
+ title: i18n.str`Could not confirm the withdrawal`,
+ description: (error as any).error.description,
+ debug: JSON.stringify(error),
+ },
+ }));
}
- pageStateSetter((prevState: PageStateType) => ({
- ...prevState,
-
- error: {
- title: i18n.str`The answer "${captchaAnswer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.`,
- },
- }));
- setCaptchaAnswer(undefined);
+ // if (
+ // captchaAnswer ==
+ // (captchaNumbers.a + captchaNumbers.b).toString()
+ // ) {
+ // await confirmWithdrawalCall(
+ // backend.state,
+ // pageState.withdrawalId,
+ // pageStateSetter,
+ // i18n,
+ // );
+ // return;
+ // }
}}
>
{i18n.str`Confirm`}
@@ -118,12 +149,31 @@ export function WithdrawalConfirmationQuestion(): VNode {
class="pure-button pure-button-secondary btn-cancel"
onClick={async (e) => {
e.preventDefault();
- await abortWithdrawalCall(
- backend.state,
- pageState.withdrawalId,
- pageStateSetter,
- i18n,
- );
+ try {
+ await abortWithdrawal(withdrawalId);
+ pageStateSetter((prevState) => {
+ const { talerWithdrawUri, ...rest } = prevState;
+ return {
+ ...rest,
+ info: i18n.str`Withdrawal confirmed!`,
+ };
+ });
+ } catch (error) {
+ pageStateSetter((prevState) => ({
+ ...prevState,
+ error: {
+ title: i18n.str`Could not confirm the withdrawal`,
+ description: (error as any).error.description,
+ debug: JSON.stringify(error),
+ },
+ }));
+ }
+ // await abortWithdrawalCall(
+ // backend.state,
+ // pageState.withdrawalId,
+ // pageStateSetter,
+ // i18n,
+ // );
}}
>
{i18n.str`Cancel`}
@@ -156,188 +206,188 @@ export function WithdrawalConfirmationQuestion(): VNode {
* This function will set the confirmation status in the
* 'page state' and let the related components refresh.
*/
-async function confirmWithdrawalCall(
- backendState: BackendState,
- withdrawalId: string | undefined,
- pageStateSetter: StateUpdater<PageStateType>,
- i18n: InternationalizationAPI,
-): Promise<void> {
- if (backendState.status === "loggedOut") {
- logger.error("No credentials found.");
- pageStateSetter((prevState) => ({
- ...prevState,
+// async function confirmWithdrawalCall(
+// backendState: BackendState,
+// withdrawalId: string | undefined,
+// pageStateSetter: StateUpdater<PageStateType>,
+// i18n: InternationalizationAPI,
+// ): Promise<void> {
+// if (backendState.status === "loggedOut") {
+// logger.error("No credentials found.");
+// pageStateSetter((prevState) => ({
+// ...prevState,
- error: {
- title: i18n.str`No credentials found.`,
- },
- }));
- return;
- }
- if (typeof withdrawalId === "undefined") {
- logger.error("No withdrawal ID found.");
- pageStateSetter((prevState) => ({
- ...prevState,
+// error: {
+// title: i18n.str`No credentials found.`,
+// },
+// }));
+// return;
+// }
+// if (typeof withdrawalId === "undefined") {
+// logger.error("No withdrawal ID found.");
+// pageStateSetter((prevState) => ({
+// ...prevState,
- error: {
- title: i18n.str`No withdrawal ID found.`,
- },
- }));
- return;
- }
- let res: Response;
- try {
- const { username, password } = backendState;
- const headers = prepareHeaders(username, password);
- /**
- * NOTE: tests show that when a same object is being
- * POSTed, caching might prevent same requests from being
- * made. Hence, trying to POST twice the same amount might
- * get silently ignored.
- *
- * headers.append("cache-control", "no-store");
- * headers.append("cache-control", "no-cache");
- * headers.append("pragma", "no-cache");
- * */
+// error: {
+// title: i18n.str`No withdrawal ID found.`,
+// },
+// }));
+// return;
+// }
+// let res: Response;
+// try {
+// const { username, password } = backendState;
+// const headers = prepareHeaders(username, password);
+// /**
+// * NOTE: tests show that when a same object is being
+// * POSTed, caching might prevent same requests from being
+// * made. Hence, trying to POST twice the same amount might
+// * get silently ignored.
+// *
+// * headers.append("cache-control", "no-store");
+// * headers.append("cache-control", "no-cache");
+// * headers.append("pragma", "no-cache");
+// * */
- // Backend URL must have been stored _with_ a final slash.
- const url = new URL(
- `access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/confirm`,
- backendState.url,
- );
- res = await fetch(url.href, {
- method: "POST",
- headers,
- });
- } catch (error) {
- logger.error("Could not POST withdrawal confirmation to the bank", error);
- pageStateSetter((prevState) => ({
- ...prevState,
+// // Backend URL must have been stored _with_ a final slash.
+// const url = new URL(
+// `access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/confirm`,
+// backendState.url,
+// );
+// res = await fetch(url.href, {
+// method: "POST",
+// headers,
+// });
+// } catch (error) {
+// logger.error("Could not POST withdrawal confirmation to the bank", error);
+// pageStateSetter((prevState) => ({
+// ...prevState,
- error: {
- title: i18n.str`Could not confirm the withdrawal`,
- description: (error as any).error.description,
- debug: JSON.stringify(error),
- },
- }));
- return;
- }
- if (!res || !res.ok) {
- const response = await res.json();
- // assume not ok if res is null
- logger.error(
- `Withdrawal confirmation gave response error (${res.status})`,
- res.statusText,
- );
- pageStateSetter((prevState) => ({
- ...prevState,
+// error: {
+// title: i18n.str`Could not confirm the withdrawal`,
+// description: (error as any).error.description,
+// debug: JSON.stringify(error),
+// },
+// }));
+// return;
+// }
+// if (!res || !res.ok) {
+// const response = await res.json();
+// // assume not ok if res is null
+// logger.error(
+// `Withdrawal confirmation gave response error (${res.status})`,
+// res.statusText,
+// );
+// pageStateSetter((prevState) => ({
+// ...prevState,
- error: {
- title: i18n.str`Withdrawal confirmation gave response error`,
- debug: JSON.stringify(response),
- },
- }));
- return;
- }
- logger.trace("Withdrawal operation confirmed!");
- pageStateSetter((prevState) => {
- const { talerWithdrawUri, ...rest } = prevState;
- return {
- ...rest,
+// error: {
+// title: i18n.str`Withdrawal confirmation gave response error`,
+// debug: JSON.stringify(response),
+// },
+// }));
+// return;
+// }
+// logger.trace("Withdrawal operation confirmed!");
+// pageStateSetter((prevState) => {
+// const { talerWithdrawUri, ...rest } = prevState;
+// return {
+// ...rest,
- info: i18n.str`Withdrawal confirmed!`,
- };
- });
-}
+// info: i18n.str`Withdrawal confirmed!`,
+// };
+// });
+// }
-/**
- * Abort a withdrawal operation via the Access API's /abort.
- */
-async function abortWithdrawalCall(
- backendState: BackendState,
- withdrawalId: string | undefined,
- pageStateSetter: StateUpdater<PageStateType>,
- i18n: InternationalizationAPI,
-): Promise<void> {
- if (backendState.status === "loggedOut") {
- logger.error("No credentials found.");
- pageStateSetter((prevState) => ({
- ...prevState,
+// /**
+// * Abort a withdrawal operation via the Access API's /abort.
+// */
+// async function abortWithdrawalCall(
+// backendState: BackendState,
+// withdrawalId: string | undefined,
+// pageStateSetter: StateUpdater<PageStateType>,
+// i18n: InternationalizationAPI,
+// ): Promise<void> {
+// if (backendState.status === "loggedOut") {
+// logger.error("No credentials found.");
+// pageStateSetter((prevState) => ({
+// ...prevState,
- error: {
- title: i18n.str`No credentials found.`,
- },
- }));
- return;
- }
- if (typeof withdrawalId === "undefined") {
- logger.error("No withdrawal ID found.");
- pageStateSetter((prevState) => ({
- ...prevState,
+// error: {
+// title: i18n.str`No credentials found.`,
+// },
+// }));
+// return;
+// }
+// if (typeof withdrawalId === "undefined") {
+// logger.error("No withdrawal ID found.");
+// pageStateSetter((prevState) => ({
+// ...prevState,
- error: {
- title: i18n.str`No withdrawal ID found.`,
- },
- }));
- return;
- }
- let res: Response;
- try {
- const { username, password } = backendState;
- const headers = prepareHeaders(username, password);
- /**
- * NOTE: tests show that when a same object is being
- * POSTed, caching might prevent same requests from being
- * made. Hence, trying to POST twice the same amount might
- * get silently ignored. Needs more observation!
- *
- * headers.append("cache-control", "no-store");
- * headers.append("cache-control", "no-cache");
- * headers.append("pragma", "no-cache");
- * */
+// error: {
+// title: i18n.str`No withdrawal ID found.`,
+// },
+// }));
+// return;
+// }
+// let res: Response;
+// try {
+// const { username, password } = backendState;
+// const headers = prepareHeaders(username, password);
+// /**
+// * NOTE: tests show that when a same object is being
+// * POSTed, caching might prevent same requests from being
+// * made. Hence, trying to POST twice the same amount might
+// * get silently ignored. Needs more observation!
+// *
+// * headers.append("cache-control", "no-store");
+// * headers.append("cache-control", "no-cache");
+// * headers.append("pragma", "no-cache");
+// * */
- // Backend URL must have been stored _with_ a final slash.
- const url = new URL(
- `access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/abort`,
- backendState.url,
- );
- res = await fetch(url.href, { method: "POST", headers });
- } catch (error) {
- logger.error("Could not abort the withdrawal", error);
- pageStateSetter((prevState) => ({
- ...prevState,
+// // Backend URL must have been stored _with_ a final slash.
+// const url = new URL(
+// `access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/abort`,
+// backendState.url,
+// );
+// res = await fetch(url.href, { method: "POST", headers });
+// } catch (error) {
+// logger.error("Could not abort the withdrawal", error);
+// pageStateSetter((prevState) => ({
+// ...prevState,
- error: {
- title: i18n.str`Could not abort the withdrawal.`,
- description: (error as any).error.description,
- debug: JSON.stringify(error),
- },
- }));
- return;
- }
- if (!res.ok) {
- const response = await res.json();
- logger.error(
- `Withdrawal abort gave response error (${res.status})`,
- res.statusText,
- );
- pageStateSetter((prevState) => ({
- ...prevState,
+// error: {
+// title: i18n.str`Could not abort the withdrawal.`,
+// description: (error as any).error.description,
+// debug: JSON.stringify(error),
+// },
+// }));
+// return;
+// }
+// if (!res.ok) {
+// const response = await res.json();
+// logger.error(
+// `Withdrawal abort gave response error (${res.status})`,
+// res.statusText,
+// );
+// pageStateSetter((prevState) => ({
+// ...prevState,
- error: {
- title: i18n.str`Withdrawal abortion failed.`,
- description: response.error.description,
- debug: JSON.stringify(response),
- },
- }));
- return;
- }
- logger.trace("Withdrawal operation aborted!");
- pageStateSetter((prevState) => {
- const { ...rest } = prevState;
- return {
- ...rest,
+// error: {
+// title: i18n.str`Withdrawal abortion failed.`,
+// description: response.error.description,
+// debug: JSON.stringify(response),
+// },
+// }));
+// return;
+// }
+// logger.trace("Withdrawal operation aborted!");
+// pageStateSetter((prevState) => {
+// const { ...rest } = prevState;
+// return {
+// ...rest,
- info: i18n.str`Withdrawal aborted!`,
- };
- });
-}
+// info: i18n.str`Withdrawal aborted!`,
+// };
+// });
+// }
diff --git a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx
index 174c19288..fd91c0e1a 100644
--- a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx
+++ b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx
@@ -15,106 +15,67 @@
*/
import { Logger } from "@gnu-taler/taler-util";
+import {
+ HttpResponsePaginated,
+ useTranslationContext,
+} from "@gnu-taler/web-util/lib/index.browser";
import { Fragment, h, VNode } from "preact";
-import useSWR from "swr";
-import { PageStateType, usePageContext } from "../context/pageState.js";
-import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
+import { Loading } from "../components/Loading.js";
+import { usePageContext } from "../context/pageState.js";
+import { useWithdrawalDetails } from "../hooks/access.js";
import { QrCodeSection } from "./QrCodeSection.js";
import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js";
const logger = new Logger("WithdrawalQRCode");
+
+interface Props {
+ account: string;
+ withdrawalId: string;
+ talerWithdrawUri: string;
+ onAbort: () => void;
+ onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
+}
/**
* Offer the QR code (and a clickable taler://-link) to
* permit the passing of exchange and reserve details to
* the bank. Poll the backend until such operation is done.
*/
export function WithdrawalQRCode({
+ account,
withdrawalId,
talerWithdrawUri,
-}: {
- withdrawalId: string;
- talerWithdrawUri: string;
-}): VNode {
- // turns true when the wallet POSTed the reserve details:
- const { pageState, pageStateSetter } = usePageContext();
- const { i18n } = useTranslationContext();
- const abortButton = (
- <a
- class="pure-button btn-cancel"
- onClick={() => {
- pageStateSetter((prevState: PageStateType) => {
- return {
- ...prevState,
- withdrawalId: undefined,
- talerWithdrawUri: undefined,
- withdrawalInProgress: false,
- };
- });
- }}
- >{i18n.str`Abort`}</a>
- );
-
+ onAbort,
+ onLoadNotOk,
+}: Props): VNode {
logger.trace(`Showing withdraw URI: ${talerWithdrawUri}`);
- // waiting for the wallet:
-
- const { data, error } = useSWR(
- `integration-api/withdrawal-operation/${withdrawalId}`,
- { refreshInterval: 1000 },
- );
- if (typeof error !== "undefined") {
- logger.error(
- `withdrawal (${withdrawalId}) was never (correctly) created at the bank...`,
- error,
- );
- pageStateSetter((prevState: PageStateType) => ({
- ...prevState,
-
- error: {
- title: i18n.str`withdrawal (${withdrawalId}) was never (correctly) created at the bank...`,
- },
- }));
- return (
- <Fragment>
- <br />
- <br />
- {abortButton}
- </Fragment>
- );
+ const result = useWithdrawalDetails(account, withdrawalId);
+ if (!result.ok) {
+ return onLoadNotOk(result);
}
+ const { data } = result;
- // data didn't arrive yet and wallet didn't communicate:
- if (typeof data === "undefined")
- return <p>{i18n.str`Waiting the bank to create the operation...`}</p>;
-
- /**
- * Wallet didn't communicate withdrawal details yet:
- */
logger.trace("withdrawal status", data);
- if (data.aborted)
- pageStateSetter((prevState: PageStateType) => {
- const { withdrawalId, talerWithdrawUri, ...rest } = prevState;
- return {
- ...rest,
- withdrawalInProgress: false,
-
- error: {
- title: i18n.str`This withdrawal was aborted!`,
- },
- };
- });
+ if (data.aborted) {
+ //signal that this withdrawal is aborted
+ //will redirect to account info
+ onAbort();
+ return <Loading />;
+ }
if (!data.selection_done) {
return (
- <QrCodeSection
- talerWithdrawUri={talerWithdrawUri}
- abortButton={abortButton}
- />
+ <QrCodeSection talerWithdrawUri={talerWithdrawUri} onAbort={onAbort} />
);
}
/**
* Wallet POSTed the withdrawal details! Ask the
* user to authorize the operation (here CAPTCHA).
*/
- return <WithdrawalConfirmationQuestion />;
+ return (
+ <WithdrawalConfirmationQuestion
+ account={account}
+ withdrawalId={talerWithdrawUri}
+ />
+ );
}
diff --git a/packages/demobank-ui/src/scss/bank.scss b/packages/demobank-ui/src/scss/bank.scss
index e8a4d664c..c55dfe966 100644
--- a/packages/demobank-ui/src/scss/bank.scss
+++ b/packages/demobank-ui/src/scss/bank.scss
@@ -268,3 +268,10 @@ html {
h1.nav {
text-align: center;
}
+
+.pure-form > fieldset > label {
+ display: block;
+}
+.pure-form > fieldset > input[disabled] {
+ color: black !important;
+}
diff --git a/packages/demobank-ui/src/utils.ts b/packages/demobank-ui/src/utils.ts
index e1d35a2b5..0dc24e468 100644
--- a/packages/demobank-ui/src/utils.ts
+++ b/packages/demobank-ui/src/utils.ts
@@ -43,30 +43,42 @@ export function getIbanFromPayto(url: string): string {
return iban;
}
-const maybeRootPath = "https://bank.demo.taler.net/demobanks/default/";
-
-export function getBankBackendBaseUrl(): string {
- const overrideUrl = localStorage.getItem("bank-base-url");
- return canonicalizeBaseUrl(overrideUrl ? overrideUrl : maybeRootPath);
-}
-
export function undefinedIfEmpty<T extends object>(obj: T): T | undefined {
return Object.keys(obj).some((k) => (obj as any)[k] !== undefined)
? obj
: undefined;
}
+export type PartialButDefined<T> = {
+ [P in keyof T]: T[P] | undefined;
+};
+
+export type WithIntermediate<Type extends object> = {
+ [prop in keyof Type]: Type[prop] extends object ? WithIntermediate<Type[prop]> : (Type[prop] | undefined);
+}
+
+// export function partialWithObjects<T extends object>(obj: T | undefined, () => complete): WithIntermediate<T> {
+// const root = obj === undefined ? {} : obj;
+// return Object.entries(root).([key, value]) => {
+
+// })
+// return undefined as any
+// }
+
/**
* Craft headers with Authorization and Content-Type.
*/
-export function prepareHeaders(username?: string, password?: string): Headers {
- const headers = new Headers();
- if (username && password) {
- headers.append(
- "Authorization",
- `Basic ${window.btoa(`${username}:${password}`)}`,
- );
- }
- headers.append("Content-Type", "application/json");
- return headers;
-}
+// export function prepareHeaders(username?: string, password?: string): Headers {
+// const headers = new Headers();
+// if (username && password) {
+// headers.append(
+// "Authorization",
+// `Basic ${window.btoa(`${username}:${password}`)}`,
+// );
+// }
+// headers.append("Content-Type", "application/json");
+// return headers;
+// }
+
+export const PAGE_SIZE = 20;
+export const MAX_RESULT_SIZE = PAGE_SIZE * 2 - 1;