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