From a8c5a9696c1735a178158cbc9ac4f9bb4b6f013d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 8 Feb 2023 17:41:19 -0300 Subject: impl accout management and refactor --- packages/demobank-ui/package.json | 4 +- .../demobank-ui/src/components/Cashouts/index.ts | 69 ++ .../demobank-ui/src/components/Cashouts/state.ts | 44 ++ .../src/components/Cashouts/stories.tsx | 45 ++ .../demobank-ui/src/components/Cashouts/test.ts | 179 ++++++ .../demobank-ui/src/components/Cashouts/views.tsx | 66 ++ packages/demobank-ui/src/components/Loading.tsx | 24 +- .../src/components/Transactions/index.ts | 10 +- .../src/components/Transactions/state.ts | 105 ++- .../src/components/Transactions/test.ts | 9 +- packages/demobank-ui/src/components/app.tsx | 23 +- packages/demobank-ui/src/context/backend.ts | 4 +- packages/demobank-ui/src/context/pageState.ts | 21 +- packages/demobank-ui/src/declaration.d.ts | 362 ++++++++++- packages/demobank-ui/src/hooks/access.ts | 330 ++++++++++ packages/demobank-ui/src/hooks/async.ts | 1 - packages/demobank-ui/src/hooks/backend.ts | 195 +++++- packages/demobank-ui/src/hooks/circuit.ts | 317 +++++++++ packages/demobank-ui/src/pages/AccountPage.tsx | 283 +++------ packages/demobank-ui/src/pages/AdminPage.tsx | 707 +++++++++++++++++++++ packages/demobank-ui/src/pages/BankFrame.tsx | 42 +- packages/demobank-ui/src/pages/HomePage.tsx | 149 +++++ packages/demobank-ui/src/pages/LoginForm.tsx | 188 +++--- packages/demobank-ui/src/pages/PaymentOptions.tsx | 33 +- .../src/pages/PaytoWireTransferForm.tsx | 317 +++------ .../demobank-ui/src/pages/PublicHistoriesPage.tsx | 93 +-- packages/demobank-ui/src/pages/QrCodeSection.tsx | 9 +- .../demobank-ui/src/pages/RegistrationPage.tsx | 176 ++--- packages/demobank-ui/src/pages/Routing.tsx | 84 ++- .../demobank-ui/src/pages/WalletWithdrawForm.tsx | 259 ++++---- .../src/pages/WithdrawalConfirmationQuestion.tsx | 466 ++++++++------ .../demobank-ui/src/pages/WithdrawalQRCode.tsx | 111 ++-- packages/demobank-ui/src/scss/bank.scss | 7 + packages/demobank-ui/src/utils.ts | 48 +- 34 files changed, 3534 insertions(+), 1246 deletions(-) create mode 100644 packages/demobank-ui/src/components/Cashouts/index.ts create mode 100644 packages/demobank-ui/src/components/Cashouts/state.ts create mode 100644 packages/demobank-ui/src/components/Cashouts/stories.tsx create mode 100644 packages/demobank-ui/src/components/Cashouts/test.ts create mode 100644 packages/demobank-ui/src/components/Cashouts/views.tsx create mode 100644 packages/demobank-ui/src/hooks/access.ts create mode 100644 packages/demobank-ui/src/hooks/circuit.ts create mode 100644 packages/demobank-ui/src/pages/AdminPage.tsx create mode 100644 packages/demobank-ui/src/pages/HomePage.tsx 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 + */ + +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; + } + + 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 = { + 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 + */ + +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 + */ + +/** + * + * @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 + */ + +/** + * + * @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 + */ + +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 ( +
+ Could not load +
+ ); +} + +export function ReadyView({ cashouts }: State.Ready): VNode { + const { i18n } = useTranslationContext(); + return ( +
+ + + + + + + + + + + {cashouts.map((item, idx) => { + return ( + + + + + + + + ); + })} + +
{i18n.str`Created`}{i18n.str`Confirmed`}{i18n.str`Counterpart`}{i18n.str`Subject`}
{format(item.creation_time, "dd/MM/yyyy HH:mm:ss")} + {item.confirmation_time + ? format(item.confirmation_time, "dd/MM/yyyy HH:mm:ss") + : "-"} + {Amounts.stringifyValue(item.amount_credit)}{item.counterpart}{item.subject}
+
+ ); +} 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
loading...
; + return ( +
+ +
+ ); +} + +export function Spinner(): VNode { + return ( +
+
+
+
+
+
+ ); } 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 */ +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; } 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 = () => { - + + + @@ -58,4 +69,14 @@ const App: FunctionalComponent = () => { return globalLogLevel; }; +function localStorageProvider(): Map { + 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 */ +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] { @@ -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 + */ + +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> => { + const res = await request(`access-api/accounts/${account}/withdrawals`, { + method: "POST", + data, + contentType: "json" + }); + return res; + }; + const abortWithdrawal = async ( + id: string, + ): Promise> => { + const res = await request(`access-api/accounts/${account}/withdrawals/${id}`, { + method: "POST", + contentType: "json" + }); + await mutateAll(/.*accounts\/.*\/withdrawals\/.*/); + return res; + }; + const confirmWithdrawal = async ( + id: string, + ): Promise> => { + const res = await request(`access-api/accounts/${account}/withdrawals/${id}`, { + method: "POST", + contentType: "json" + }); + await mutateAll(/.*accounts\/.*\/withdrawals\/.*/); + return res; + }; + const createTransaction = async ( + data: SandboxBackend.Access.CreateBankAccountTransactionCreate + ): Promise> => { + const res = await request(`access-api/accounts/${account}/transactions`, { + method: "POST", + data, + contentType: "json" + }); + await mutateAll(/.*accounts\/.*\/transactions.*/); + return res; + }; + const deleteAccount = async ( + ): Promise> => { + const res = await request(`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> => { + const res = await noAuthRequest(`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>; +} + +export interface AccessAPI { + createWithdrawal: ( + data: SandboxBackend.Access.BankAccountCreateWithdrawalRequest, + ) => Promise>; + abortWithdrawal: ( + wid: string, + ) => Promise>; + confirmWithdrawal: ( + wid: string + ) => Promise>; + createTransaction: ( + data: SandboxBackend.Access.CreateBankAccountTransactionCreate + ) => Promise>; + deleteAccount: () => Promise>; +} + +export interface InstanceTemplateFilter { + //FIXME: add filter to the template list + position?: string; +} + + +export function useAccountDetails(account: string): HttpResponse { + const { fetcher } = useAuthenticatedBackend(); + + const { data, error } = useSWR< + HttpResponseOk, + HttpError + >([`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 { + const { fetcher } = useAuthenticatedBackend(); + + const { data, error } = useSWR< + HttpResponseOk, + HttpError + >([`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 { + const { fetcher } = useAuthenticatedBackend(); + + const { data, error } = useSWR< + HttpResponseOk, + HttpError + >([`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 { + const { paginatedFetcher } = usePublicBackend(); + + const [page, setPage] = useState(1); + + const { + data: afterData, + error: afterError, + isValidating: loadingAfter, + } = useSWR< + HttpResponseOk, + HttpError + >([`public-accounts`, args?.page, PAGE_SIZE], paginatedFetcher); + + const [lastAfter, setLastAfter] = useState< + HttpResponse + >({ 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 { + const { paginatedFetcher } = useAuthenticatedBackend(); + + const [page, setPage] = useState(1); + + const { + data: afterData, + error: afterError, + isValidating: loadingAfter, + } = useSWR< + HttpResponseOk, + HttpError + >([`access-api/accounts/${account}/transactions`, args?.page, PAGE_SIZE], paginatedFetcher); + + const [lastAfter, setLastAfter] = useState< + HttpResponse + >({ 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( }; 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 */ +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: ( + path: string, + options?: RequestOptions, + ) => Promise>; + fetcher: (endpoint: string) => Promise>; + multiFetcher: (endpoint: string[]) => Promise[]>; + paginatedFetcher: (args: [string, number, number]) => Promise>; + sandboxAccountsFetcher: (args: [string, number, number, string]) => Promise>; +} + + +export function usePublicBackend(): useBackendType { + const { state } = useBackendContext(); + const { request: requestHandler } = useApiContext(); + + const baseUrl = state.url + + const request = useCallback( + function requestImpl( + path: string, + options: RequestOptions = {}, + ): Promise> { + + return requestHandler(baseUrl, path, options); + }, + [baseUrl], + ); + + const fetcher = useCallback( + function fetcherImpl(endpoint: string): Promise> { + return requestHandler(baseUrl, endpoint); + }, + [baseUrl], + ); + const paginatedFetcher = useCallback( + function fetcherImpl([endpoint, page, size]: [string, number, number]): Promise> { + return requestHandler(baseUrl, endpoint, { params: { page: page || 1, size } }); + }, + [baseUrl], + ); + const multiFetcher = useCallback( + function multiFetcherImpl( + endpoints: string[], + ): Promise[]> { + return Promise.all( + endpoints.map((endpoint) => requestHandler(baseUrl, endpoint)), + ); + }, + [baseUrl], + ); + const sandboxAccountsFetcher = useCallback( + function fetcherImpl([endpoint, page, size, account]: [string, number, number, string]): Promise> { + return requestHandler(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( + path: string, + options: RequestOptions = {}, + ): Promise> { + + return requestHandler(baseUrl, path, { basicAuth: creds, ...options }); + }, + [baseUrl, creds], + ); + + const fetcher = useCallback( + function fetcherImpl(endpoint: string): Promise> { + return requestHandler(baseUrl, endpoint, { basicAuth: creds }); + }, + [baseUrl, creds], + ); + const paginatedFetcher = useCallback( + function fetcherImpl([endpoint, page = 0, size]: [string, number, number]): Promise> { + return requestHandler(baseUrl, endpoint, { basicAuth: creds, params: { page, size } }); + }, + [baseUrl, creds], + ); + const multiFetcher = useCallback( + function multiFetcherImpl( + endpoints: string[], + ): Promise[]> { + return Promise.all( + endpoints.map((endpoint) => requestHandler(baseUrl, endpoint, { basicAuth: creds })), + ); + }, + [baseUrl, creds], + ); + const sandboxAccountsFetcher = useCallback( + function fetcherImpl([endpoint, page, size, account]: [string, number, number, string]): Promise> { + return requestHandler(baseUrl, endpoint, { basicAuth: creds, params: { page: page || 1, size } }); + }, + [baseUrl], + ); + return { request, fetcher, paginatedFetcher, multiFetcher, sandboxAccountsFetcher }; +} + +export function useBackendConfig(): HttpResponse { + const { request } = usePublicBackend(); + + type Type = SandboxBackend.Config; + + const [result, setResult] = useState>({ loading: true }); + + useEffect(() => { + request(`/config`) + .then((data) => setResult(data)) + .catch((error) => setResult(error)); + }, [request]); + + return result; +} + +export function useMatchMutate(): ( + re: RegExp, + value?: unknown, +) => Promise { + 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 + */ + +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> => { + const res = await request(`circuit-api/accounts`, { + method: "POST", + data, + contentType: "json" + }); + return res; + }; + + const updateAccount = async ( + account: string, + data: SandboxBackend.Circuit.CircuitAccountReconfiguration, + ): Promise> => { + const res = await request(`circuit-api/accounts/${account}`, { + method: "PATCH", + data, + contentType: "json" + }); + return res; + }; + const deleteAccount = async ( + account: string, + ): Promise> => { + const res = await request(`circuit-api/accounts/${account}`, { + method: "DELETE", + contentType: "json" + }); + return res; + }; + const changePassword = async ( + account: string, + data: SandboxBackend.Circuit.AccountPasswordChange, + ): Promise> => { + const res = await request(`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> => { + const res = await request(`circuit-api/accounts/${account}`, { + method: "PATCH", + data, + contentType: "json" + }); + return res; + }; + const changePassword = async ( + data: SandboxBackend.Circuit.AccountPasswordChange, + ): Promise> => { + const res = await request(`circuit-api/accounts/${account}/auth`, { + method: "PATCH", + data, + contentType: "json" + }); + return res; + }; + + return { updateAccount, changePassword }; +} + +export interface AdminAccountAPI { + createAccount: ( + data: SandboxBackend.Circuit.CircuitAccountRequest, + ) => Promise>; + deleteAccount: (account: string) => Promise>; + + updateAccount: ( + account: string, + data: SandboxBackend.Circuit.CircuitAccountReconfiguration + ) => Promise>; + changePassword: ( + account: string, + data: SandboxBackend.Circuit.AccountPasswordChange + ) => Promise>; +} + +export interface CircuitAccountAPI { + updateAccount: ( + data: SandboxBackend.Circuit.CircuitAccountReconfiguration + ) => Promise>; + changePassword: ( + data: SandboxBackend.Circuit.AccountPasswordChange + ) => Promise>; +} + + +export interface InstanceTemplateFilter { + //FIXME: add filter to the template list + position?: string; +} + + +export function useMyAccountDetails(): HttpResponse { + 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, + HttpError + >([`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 { + const { fetcher } = useAuthenticatedBackend(); + + const { data, error } = useSWR< + HttpResponseOk, + RequestError + >([`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 { + const { sandboxAccountsFetcher } = useAuthenticatedBackend(); + const [page, setPage] = useState(0); + + const { + data: afterData, + error: afterError, + // isValidating: loadingAfter, + } = useSWR< + HttpResponseOk, + RequestError + >([`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 + // >({ 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, + RequestError + >([`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[], + RequestError + >([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 */ -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 ( - -

{i18n.str`Welcome to ${bankUiSettings.bankName}!`}

- -
- ); - } - - return ( - - - - ); -} - -/** - * 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 ( - { - 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} - - ); +interface Props { + account: string; + onLoadNotOk: (error: HttpResponsePaginated) => 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(); - // } - - 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

Profile not found...

; - } - case HttpStatusCode.Unauthorized: - case HttpStatusCode.Forbidden: { - backend.clear(); - setPageState((prevState: PageStateType) => ({ - ...prevState, - error: { - title: i18n.str`Wrong credentials given.`, - }, - })); - return

Wrong credentials...

; - } - default: { - backend.clear(); - setPageState((prevState: PageStateType) => ({ - ...prevState, - error: { - title: i18n.str`Account information could not be retrieved.`, - debug: JSON.stringify(error), - }, - })); - return

Unknown problem...

; - } - } + 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 ( - - - +
Payto from server is not valid "{data.paytoUri}"
); } - const balanceValue = !balance ? undefined : Amounts.stringifyValue(balance); + const accountNumber = payto.iban; + const balanceIsDebit = data.balance.credit_debit_indicator == "debit"; return ( - +

Welcome, - {accountNumber - ? `${accountLabel} (${accountNumber})` - : accountLabel} - ! + {accountNumber ? `${account} (${accountNumber})` : account}!

@@ -239,7 +85,10 @@ function Account({ accountLabel }: { accountLabel: string }): VNode { ) : (
{balanceIsDebit ? - : null} - {`${balanceValue}`}  + {`${Amounts.stringifyValue( + balance, + )}`} +   {`${balance.currency}`}
)} @@ -248,34 +97,56 @@ function Account({ accountLabel }: { accountLabel: string }): VNode {

{i18n.str`Payments`}

- +
)} -
-
-

{i18n.str`Latest transactions:`}

- -
+ +
+
- + ); } -// function useTransactionPageNumber(): [number, StateUpdater] { -// const ret = useNotNullLocalStorage("transaction-page", "0"); -// const retObj = JSON.parse(ret[0]); -// const retSetter: StateUpdater = 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 ( +
+
+
+ + +
+ {tab === "transactions" && ( +
+

{i18n.str`Latest transactions`}

+ +
+ )} + {tab === "cashouts" && ( +
+

{i18n.str`Latest cashouts`}

+ +
+ )} +
+
+ ); +} 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 + */ + +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: (error: HttpResponsePaginated) => VNode; +} +/** + * Query account information and show QR code if there is pending withdrawal + */ +export function AdminPage({ onLoadNotOk }: Props): VNode { + const [account, setAccount] = useState(); + const [showDetails, setShowDetails] = useState(); + const [updatePassword, setUpdatePassword] = useState(); + 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
; + if (!result.ok) { + return onLoadNotOk(result); + } + + const { customers } = result.data; + + if (showDetails) { + return ( + { + showInfoMessage(i18n.str`Account updated`); + setShowDetails(undefined); + }} + onClear={() => { + setShowDetails(undefined); + }} + /> + ); + } + if (updatePassword) { + return ( + { + showInfoMessage(i18n.str`Password changed`); + setUpdatePassword(undefined); + }} + onClear={() => { + setUpdatePassword(undefined); + }} + /> + ); + } + if (createAccount) { + return ( + setCreateAccount(false)} + onCreateSuccess={(password) => { + showInfoMessage( + i18n.str`Account created with password "${password}"`, + ); + setCreateAccount(false); + }} + /> + ); + } + return ( + +
+

+ Admin panel +

+
+ +

+

+
+
+ { + e.preventDefault(); + + setCreateAccount(true); + }} + /> +
+
+

+ +
+ +
+
+ ); +} + +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 { + const emptyAccount = { + cashout_address: undefined, + iban: undefined, + name: undefined, + username: undefined, + contact_data: undefined, + }; + const emptyContact = { + email: undefined, + phone: undefined, + }; + + const initial: PartialButDefined = + 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: (error: HttpResponsePaginated) => VNode; + onClear: () => void; + onUpdateSuccess: () => void; + account: string; +}): VNode { + const { i18n } = useTranslationContext(); + const result = useAccountDetails(account); + const { changePassword } = useAdminAccountAPI(); + const [password, setPassword] = useState(); + const [repeat, setRepeat] = useState(); + const [error, saveError] = useState(); + + if (result.clientError) { + if (result.isNotfound) return
account not found
; + } + 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 ( +
+
+

+ Admin panel +

+
+ {error && ( + saveError(undefined)} /> + )} + +
+
+ + +
+
+ + { + setPassword(e.currentTarget.value); + }} + /> + +
+
+ + { + setRepeat(e.currentTarget.value); + }} + /> + +
+
+

+

+
+ { + e.preventDefault(); + onClear(); + }} + /> +
+
+ { + e.preventDefault(); + if (!!errors || !password) return; + try { + const r = await changePassword(account, { + new_password: password, + }); + onUpdateSuccess(); + } catch (error) { + handleError(error, saveError, i18n); + } + }} + /> +
+
+

+
+ ); +} + +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(); + return ( +
+
+

+ Admin panel +

+
+ {error && ( + saveError(undefined)} /> + )} + + setSubmitAccount(a)} + /> + +

+

+
+ { + e.preventDefault(); + onClose(); + }} + /> +
+
+ { + 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); + } + }} + /> +
+
+

+
+ ); +} + +function ShowAccountDetails({ + account, + onClear, + onUpdateSuccess, + onLoadNotOk, +}: { + onLoadNotOk: (error: HttpResponsePaginated) => 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(); + + if (result.clientError) { + if (result.isNotfound) return
account not found
; + } + if (!result.ok) { + return onLoadNotOk(result); + } + + return ( +
+
+

+ Admin panel +

+
+ {error && ( + saveError(undefined)} /> + )} + setSubmitAccount(a)} + /> + +

+

+
+ { + e.preventDefault(); + onClear(); + }} + /> +
+
+ { + 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); + } + } + }} + /> +
+
+

+
+ ); +} + +/** + * 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(undefined); + const { i18n } = useTranslationContext(); + + function updateForm(newForm: typeof initial): void { + const parsed = !newForm.cashout_address + ? undefined + : parsePaytoUri(newForm.cashout_address); + + const validationResult = undefinedIfEmpty({ + 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.username = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + +
+
+ + { + form.name = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + +
+
+ + { + form.iban = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + +
+
+ + { + form.contact_data.email = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + +
+
+ + { + form.contact_data.phone = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + +
+
+ + { + form.cashout_address = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + +
+
+ ); +} + +function handleError( + error: unknown, + saveError: (e: ErrorMessage) => void, + i18n: ReturnType["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({
- + {pageState.error && ( + { + pageStateSetter((prev) => ({ ...prev, error: undefined })); + }} + /> + )} {backend.state.status === "loggedIn" ? logOut : null} {children} @@ -136,33 +147,34 @@ function maybeDemoContent(content: VNode): VNode { return ; } -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 (

- {pageState.error.title} + {error.title}

{ - pageStateSetter((prev) => ({ ...prev, error: undefined })); + onClick={(e) => { + e.preventDefault(); + onClear(); }} />
-

{pageState.error.description}

+

{error.description}

); - 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 + */ + +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 ; + } + + const { withdrawalId, talerWithdrawUri } = pageState; + + if (talerWithdrawUri && withdrawalId) { + return ( + + ); + } + + if (backend.state.isUserAdministrator) { + return ( + + ); + } + + return ( + + ); +} + +function handleNotOkResult( + account: string, + onErrorHandler: (state: PageStateType["error"]) => void, + i18n: ReturnType["i18n"], + onRegister: () => void, +): (result: HttpResponsePaginated) => VNode { + return function handleNotOkResult2( + result: HttpResponsePaginated, + ): VNode { + if (result.clientError && result.isUnauthorized) { + onErrorHandler({ + title: i18n.str`Wrong credentials for "${account}"`, + }); + return ; + } + if (result.clientError && result.isNotfound) { + onErrorHandler({ + title: i18n.str`Username or account label "${account}" not found`, + }); + return ; + } + if (result.loading) return ; + 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 ; + } + return
; + }; +} 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 */ -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(); const [password, setPassword] = useState(); @@ -52,107 +50,93 @@ export function LoginForm(): VNode { }); return ( - ); + 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), + }); + } + } }} />

@@ -322,11 +325,7 @@ export function PaytoWireTransferForm({
{ - 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({
); } - -/** - * 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 fields. FIXME: name not matching the - * purpose, as this is not a HTTP request body but rather the - * state of the -elements. - */ -type WireTransferRequestTypeOpt = WireTransferRequestType | undefined; -function useWireTransferRequestType( - state?: WireTransferRequestType, -): [WireTransferRequestTypeOpt, StateUpdater] { - const ret = useLocalStorage( - "wire-transfer-request-state", - JSON.stringify(state), - ); - const retObj: WireTransferRequestTypeOpt = ret[0] - ? JSON.parse(ret[0]) - : ret[0]; - const retSetter: StateUpdater = 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, - /** - * Optional since the raw payto form doesn't have - * a stateful management of the input data yet. - */ - cleanUpForm: () => void, - i18n: InternationalizationAPI, -): Promise { - 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 ( - - - - - - ); -} - -function SWRWithoutCredentials({ - baseUrl, - children, -}: { - children: ComponentChildren; - baseUrl: string; -}): VNode { - logger.trace("Base URL", baseUrl); - return ( - - fetch(baseUrl + url || "").then((r) => { - if (!r.ok) throw { status: r.status, json: r.json() }; +// export function PublicHistoriesPage2(): VNode { +// return ( +// +// +// +// ); +// } - return r.json(); - }), - }} - > - {children as any} - - ); +interface Props { + onLoadNotOk: (error: HttpResponsePaginated) => 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

Waiting public accounts list...

; const txs: Record = {}; const accountsBar = []; @@ -133,9 +84,7 @@ function PublicHistories(): VNode {
, ); - txs[account.accountLabel] = ( - - ); + txs[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({


- {abortButton} + {i18n.str`Abort`}
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 */ -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 ( - -

{i18n.str`Currently, the bank is not accepting new registrations!`}

-
+

{i18n.str`Currently, the bank is not accepting new registrations!`}

); } - return ( - - - - ); + return ; } 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(); const [password, setPassword] = useState(); const [repeatPassword, setRepeatPassword] = useState(); + 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 { ); } - -/** - * 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, - i18n: InternationalizationAPI, -): Promise { - 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 */ +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["i18n"], +): (result: HttpResponsePaginated) => VNode { + return function handleNotOkResult2( + result: HttpResponsePaginated, + ): VNode { + if (result.clientError && result.isUnauthorized) { + route(safe); + return ; + } + if (result.clientError && result.isNotfound) { + route(safe); + return ( +
Page not found, you are going to be redirected to {safe}
+ ); + } + if (result.loading) return ; + 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
; + }; +} + export function Routing(): VNode { const history = createHashHistory(); + const { pageStateSetter } = usePageContext(); + + function saveError(error: PageStateType["error"]): void { + pageStateSetter((prev) => ({ ...prev, error })); + } + const { i18n } = useTranslationContext(); return ( - - - + ( + + + + )} + /> + ( + + { + route("/account"); + }} + /> + + )} + /> + ( + + { + route("/register"); + }} + /> + + )} + /> ); 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 */ -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("5.00"); const ref = useRef(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 ( @@ -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); }} /> +

@@ -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, + }); + } + } }} />

@@ -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, - i18n: InternationalizationAPI, -): Promise { - 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, +// i18n: InternationalizationAPI, +// ): Promise { +// 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(); - + 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 (

{i18n.str`Confirm Withdrawal`}

@@ -82,33 +97,49 @@ export function WithdrawalConfirmationQuestion(): VNode { setCaptchaAnswer(e.currentTarget.value); }} /> +