diff --git a/Makefile b/Makefile index 45deb0d2c..7b4003b74 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,11 @@ git-archive-all = ./build-system/taler-build-scripts/archive-with-submodules/git include .config.mk +# Let recursive Makefiles know that they're being invoked +# from the top-level makefile. +export TOPLEVEL := yes +export TOP_DESTDIR := $(abspath $(DESTDIR)) + .PHONY: compile compile: pnpm install -r --frozen-lockfile @@ -121,18 +126,18 @@ lint: install: pnpm install --frozen-lockfile pnpm run compile - make -C packages/taler-wallet-cli TOPLEVEL=yes install-nodeps - make -C packages/anastasis-cli TOPLEVEL=yes install-nodeps - make -C packages/taler-harness TOPLEVEL=yes install-nodeps - make -C packages/demobank-ui TOPLEVEL=yes install-nodeps - make -C packages/merchant-backoffice-ui TOPLEVEL=yes install-nodeps - make -C packages/aml-backoffice-ui TOPLEVEL=yes install-nodeps + $(MAKE) -C packages/taler-wallet-cli install-nodeps + $(MAKE) -C packages/anastasis-cli install-nodeps + $(MAKE) -C packages/taler-harness install-nodeps + $(MAKE) -C packages/demobank-ui install-nodeps + $(MAKE) -C packages/merchant-backoffice-ui install-nodeps + $(MAKE) -C packages/aml-backoffice-ui install-nodeps .PHONY: install-tools # Install taler-wallet-cli, anastasis-cli and taler-harness install-tools: pnpm install --frozen-lockfile --filter @gnu-taler/taler-wallet-cli... --filter @gnu-taler/anastasis-cli... --filter @gnu-taler/taler-harness... pnpm run --filter @gnu-taler/taler-wallet-cli... --filter @gnu-taler/anastasis-cli... --filter @gnu-taler/taler-harness... compile - make -C packages/taler-wallet-cli TOPLEVEL=yes install-nodeps - make -C packages/anastasis-cli TOPLEVEL=yes install-nodeps - make -C packages/taler-harness TOPLEVEL=yes install-nodeps + $(MAKE) -C packages/taler-wallet-cli install-nodeps + $(MAKE) -C packages/anastasis-cli install-nodeps + $(MAKE) -C packages/taler-harness install-nodeps diff --git a/contrib/copy-backend-into-prebuilt.sh b/contrib/copy-backend-into-prebuilt.sh index b9fa2a68e..383871ac6 100755 --- a/contrib/copy-backend-into-prebuilt.sh +++ b/contrib/copy-backend-into-prebuilt.sh @@ -1,6 +1,6 @@ #!/bin/bash -[ ! -d prebuilt ] && echo 'directory "prebuilt" not found. first checkout the prebuilt branch into a prebuilt directory' && exit 1 +[ ! -d prebuilt ] && git worktree add -f prebuilt prebuilt && exit 1 for file in depleted_tip.en.html offer_refund.en.html offer_tip.en.html request_payment.en.html show_order_details.en.html; do cp packages/merchant-backend-ui/dist/pages/$file prebuilt/backend/ diff --git a/contrib/copy-backoffice-into-prebuilt.sh b/contrib/copy-backoffice-into-prebuilt.sh index aecebf01b..d21b91096 100755 --- a/contrib/copy-backoffice-into-prebuilt.sh +++ b/contrib/copy-backoffice-into-prebuilt.sh @@ -1,6 +1,6 @@ #!/bin/bash -[ ! -d prebuilt ] && echo 'directory "prebuilt" not found. first checkout the prebuilt branch into a prebuilt directory' && exit 1 +[ ! -d prebuilt ] && git worktree add -f prebuilt prebuilt && exit 1 find packages/merchant-backoffice-ui/dist/prod/ -type f -printf '%P\n' | sort > prebuilt/backoffice/bof diff --git a/contrib/copy-demobank-into-prebuilt.sh b/contrib/copy-demobank-into-prebuilt.sh index f5292a767..755b66150 100755 --- a/contrib/copy-demobank-into-prebuilt.sh +++ b/contrib/copy-demobank-into-prebuilt.sh @@ -1,6 +1,6 @@ #!/bin/bash -[ ! -d prebuilt ] && echo 'directory "prebuilt" not found. first checkout the prebuilt branch into a prebuilt directory' && exit 1 +[ ! -d prebuilt ] && git worktree add -f prebuilt prebuilt && exit 1 find packages/demobank-ui/dist/prod/ -type f -printf '%P\n' | sort > prebuilt/demobank/bof diff --git a/packages/aml-backoffice-ui/Makefile b/packages/aml-backoffice-ui/Makefile index 2653ce92f..64f9f83d1 100644 --- a/packages/aml-backoffice-ui/Makefile +++ b/packages/aml-backoffice-ui/Makefile @@ -3,6 +3,7 @@ ifeq ($(TOPLEVEL), yes) $(info top-level build) -include ../../.config.mk + override DESTDIR := $(TOP_DESTDIR) else $(info package-level build) -include ../../.config.mk @@ -15,7 +16,7 @@ $(info prefix is $(prefix)) all: @echo run \'make install\' to install -spa_dir=$(prefix)/share/taler/aml-backoffice-ui +spa_dir=$(DESTDIR)$(prefix)/share/taler/aml-backoffice-ui .PHONY: install-nodeps install-nodeps: diff --git a/packages/anastasis-cli/Makefile b/packages/anastasis-cli/Makefile index 292f7000f..724a5e40d 100644 --- a/packages/anastasis-cli/Makefile +++ b/packages/anastasis-cli/Makefile @@ -3,6 +3,7 @@ ifeq ($(TOPLEVEL), yes) $(info top-level build) -include ../../.config.mk + override DESTDIR := $(TOP_DESTDIR) else $(info package-level build) -include ../../.config.mk @@ -20,19 +21,19 @@ warn-noprefix: @echo "no prefix configured, did you run ./configure?" install: warn-noprefix else -install_target = $(prefix)/lib/anastasis-cli +bindir = $(prefix)/bin +libdir = $(prefix)/lib/anastasis-cli +nodedir = $(libdir)/node_modules/anastasis-cli .PHONY: install install-nodeps deps install-nodeps: ./build-node.mjs - install -d $(prefix)/bin - install -d $(install_target)/bin - install -d $(install_target)/node_modules/anastasis-cli - install -d $(install_target)/node_modules/anastasis-cli/bin - install -d $(install_target)/node_modules/anastasis-cli/dist - install ./dist/anastasis-cli-bundled.cjs $(install_target)/node_modules/anastasis-cli/dist/ - install ./dist/anastasis-cli-bundled.cjs.map $(install_target)/node_modules/anastasis-cli/dist/ - install ./bin/anastasis-cli.mjs $(install_target)/node_modules/anastasis-cli/bin/ - ln -sf $(install_target)/node_modules/anastasis-cli/bin/anastasis-cli.mjs $(prefix)/bin/anastasis-cli + install -d $(DESTDIR)$(bindir) + install -d $(DESTDIR)$(nodedir)/bin + install -d $(DESTDIR)$(nodedir)/dist + install ./dist/anastasis-cli-bundled.cjs $(DESTDIR)$(nodedir)/dist/ + install ./dist/anastasis-cli-bundled.cjs.map $(DESTDIR)$(nodedir)/dist/ + install ./bin/anastasis-cli.mjs $(DESTDIR)$(nodedir)/bin/ + ln -sf ../lib/anastasis-cli/node_modules/anastasis-cli/bin/anastasis-cli.mjs $(DESTDIR)$(bindir)/anastasis-cli deps: pnpm install --frozen-lockfile --filter @gnu-taler/anastasis-cli... install: diff --git a/packages/demobank-ui/Makefile b/packages/demobank-ui/Makefile index 8e41cc7c6..2399cc427 100644 --- a/packages/demobank-ui/Makefile +++ b/packages/demobank-ui/Makefile @@ -3,6 +3,7 @@ ifeq ($(TOPLEVEL), yes) $(info top-level build) -include ../../.config.mk + override DESTDIR := $(TOP_DESTDIR) else $(info package-level build) -include ../../.config.mk @@ -15,7 +16,7 @@ $(info prefix is $(prefix)) all: @echo run \'make install\' to install -spa_dir=$(prefix)/share/taler/demobank-ui +spa_dir=$(DESTDIR)$(prefix)/share/taler/demobank-ui .PHONY: deps deps: diff --git a/packages/demobank-ui/README.md b/packages/demobank-ui/README.md index 1732b5f38..877799748 100644 --- a/packages/demobank-ui/README.md +++ b/packages/demobank-ui/README.md @@ -36,11 +36,18 @@ to the default settings: ``` globalThis.talerDemobankSettings = { - backendBaseURL: "https://bank.demo.taler.net/demobanks/default/", + // location of libeufin server + backendBaseURL: "https://bank.demo.taler.net/", allowRegistrations: true, bankName: "Taler Bank", // Show explainer text and navbar to other demo sites showDemoNav: true, + // href value of the icon in the top left + iconLinkURL: "https://demo.taler.net/", + // show the button "create random user" in registration form + allowRandomAccountCreation: true, + // do not create random password for random users + simplePasswordForRandomAccounts: true, // Names and links for other demo sites to show in the navbar demoSites: [ ["Landing", "https://demo.taler.net/"], diff --git a/packages/demobank-ui/build.mjs b/packages/demobank-ui/build.mjs index 22b91803a..64ddc3774 100755 --- a/packages/demobank-ui/build.mjs +++ b/packages/demobank-ui/build.mjs @@ -21,8 +21,8 @@ await build({ type: "production", source: { js: ["src/index.tsx"], - assets: [{base:"src",files:["src/index.html"]}], + assets: [{ base: "src", files: ["src/index.html"] }], }, destination: "./dist/prod", - css: "sass", + css: "postcss", }); diff --git a/packages/demobank-ui/dev.mjs b/packages/demobank-ui/dev.mjs index 8b870451b..f29a05e49 100755 --- a/packages/demobank-ui/dev.mjs +++ b/packages/demobank-ui/dev.mjs @@ -18,17 +18,17 @@ import { serve } from "@gnu-taler/web-util/node"; import { initializeDev } from "@gnu-taler/web-util/build"; -const devEntryPoints = ["src/stories.tsx", "src/index.tsx"]; +const devEntryPoints = ["src/stories.tsx", "src/index.tsx", "src/demobank-ui-settings.js"]; const build = initializeDev({ type: "development", source: { js: devEntryPoints, - assets: [{base:"src",files:["src/index.html"]}], + assets: [{ base: "src", files: ["src/index.html"] }], }, destination: "./dist/dev", public: "/app", - css: "sass", + css: "postcss", }); await build(); diff --git a/packages/demobank-ui/package.json b/packages/demobank-ui/package.json index 8b999aeed..17059afeb 100644 --- a/packages/demobank-ui/package.json +++ b/packages/demobank-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/demobank-ui", - "version": "0.1.0", + "version": "0.9.3-dev.27", "license": "AGPL-3.0-OR-LATER", "type": "module", "scripts": { @@ -46,6 +46,9 @@ "devDependencies": { "@creativebulma/bulma-tooltip": "^1.2.0", "@gnu-taler/pogen": "^0.0.5", + "@tailwindcss/forms": "^0.5.3", + "@tailwindcss/typography": "^0.5.9", + "autoprefixer": "^10.4.14", "@types/chai": "^4.3.0", "@types/history": "^4.7.8", "@types/mocha": "^10.0.1", @@ -62,6 +65,7 @@ "po2json": "^0.4.5", "preact-render-to-string": "^5.2.6", "sass": "1.56.1", + "tailwindcss": "^3.3.2", "typescript": "5.2.2" }, "pogen": { diff --git a/packages/demobank-ui/postcss.config.js b/packages/demobank-ui/postcss.config.js new file mode 100644 index 000000000..2e7af2b7f --- /dev/null +++ b/packages/demobank-ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/packages/demobank-ui/src/assets/logo-2021.svg b/packages/demobank-ui/src/assets/logo-2021.svg new file mode 100644 index 000000000..8c5ff3e5b --- /dev/null +++ b/packages/demobank-ui/src/assets/logo-2021.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/demobank-ui/src/components/Attention.tsx b/packages/demobank-ui/src/components/Attention.tsx new file mode 100644 index 000000000..3313e5796 --- /dev/null +++ b/packages/demobank-ui/src/components/Attention.tsx @@ -0,0 +1,59 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { assertUnreachable } from "./Routing.js"; + +interface Props { + type?: "info" | "success" | "warning" | "danger", + onClose?: () => void, + title: TranslatedString, + children?: ComponentChildren , +} +export function Attention({ type = "info", title, children, onClose }: Props): VNode { + return
+
+
+
+ + {(() => { + switch (type) { + case "info": + return + case "warning": + return + case "danger": + return + case "success": + return + default: + assertUnreachable(type) + } + })()} + +
+
+

+ {title} +

+
+ {children} +
+
+ {onClose && +
+ +
+ } +
+
+ +
+} diff --git a/packages/demobank-ui/src/components/Cashouts/views.tsx b/packages/demobank-ui/src/components/Cashouts/views.tsx index 4b7649fb6..a32deb266 100644 --- a/packages/demobank-ui/src/components/Cashouts/views.tsx +++ b/packages/demobank-ui/src/components/Cashouts/views.tsx @@ -19,6 +19,7 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { State } from "./index.js"; import { format } from "date-fns"; import { Amounts } from "@gnu-taler/taler-util"; +import { RenderAmount } from "../../pages/PaytoWireTransferForm.js"; export function LoadingUriView({ error }: State.LoadingUriError): VNode { const { i18n } = useTranslationContext(); @@ -62,8 +63,8 @@ export function ReadyView({ cashouts, onSelected }: State.Ready): VNode { ? format(item.confirmation_time, "dd/MM/yyyy HH:mm:ss") : "-"} - {Amounts.stringifyValue(item.amount_debit)} - {Amounts.stringifyValue(item.amount_credit)} + + {item.status} + + + + ) +}; + +export function CopiedIcon(): VNode { + return ( + + + + ) +}; + +export function CopyButton({ getContent }: { getContent: () => string }): VNode { + const [copied, setCopied] = useState(false); + function copyText(): void { + navigator.clipboard.writeText(getContent() || ""); + setCopied(true); + } + useEffect(() => { + if (copied) { + setTimeout(() => { + setCopied(false); + }, 1000); + } + }, [copied]); + + if (!copied) { + return ( + + ); + } + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/packages/demobank-ui/src/scss/_misc.scss b/packages/demobank-ui/src/components/ErrorLoading.tsx similarity index 52% rename from packages/demobank-ui/src/scss/_misc.scss rename to packages/demobank-ui/src/components/ErrorLoading.tsx index 65bd28dbd..ee62671ce 100644 --- a/packages/demobank-ui/src/scss/_misc.scss +++ b/packages/demobank-ui/src/components/ErrorLoading.tsx @@ -1,6 +1,7 @@ +/* /* This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + (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 @@ -14,37 +15,15 @@ GNU Taler; see the file COPYING. If not, see */ -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ +import { HttpError, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { Attention } from "./Attention.js"; +import { TranslatedString } from "@gnu-taler/taler-util"; -.is-user-avatar { - &.has-max-width { - max-width: $size-base * 7; - } - - &.is-aligned-center { - margin: 0 auto; - } - - img { - margin: 0 auto; - border-radius: $radius-rounded; - } -} - -.icon.has-update-mark { - position: relative; - - &:after { - content: ""; - width: $icon-update-mark-size; - height: $icon-update-mark-size; - position: absolute; - top: 1px; - right: 1px; - background-color: $icon-update-mark-color; - border-radius: $radius-rounded; - } +export function ErrorLoading({ error }: { error: HttpError }): VNode { + const { i18n } = useTranslationContext() + return ( +

Got status "{error.info.status}" on {error.info.url}

+
+ ); } diff --git a/packages/demobank-ui/src/components/LangSelector.tsx b/packages/demobank-ui/src/components/LangSelector.tsx index ca4411682..c1d0f64ef 100644 --- a/packages/demobank-ui/src/components/LangSelector.tsx +++ b/packages/demobank-ui/src/components/LangSelector.tsx @@ -42,11 +42,11 @@ function getLangName(s: keyof LangsNames | string): string { return String(s); } -// FIXME: explain "like py". -export function LangSelectorLikePy(): VNode { +export function LangSelector(): VNode { const [updatingLang, setUpdatingLang] = useState(false); const { lang, changeLanguage } = useTranslationContext(); const [hidden, setHidden] = useState(true); + useEffect(() => { function bodyKeyPress(event: KeyboardEvent) { if (event.code === "Escape") setHidden(true); @@ -62,51 +62,49 @@ export function LangSelectorLikePy(): VNode { }; }, []); return ( - -
{ - ev.preventDefault(); - setHidden((h) => !h); - ev.stopPropagation(); - }} - > - {getLangName(lang)} - -
-
- - +
); } diff --git a/packages/demobank-ui/src/components/QR.tsx b/packages/demobank-ui/src/components/QR.tsx index c1c159ef8..945a08867 100644 --- a/packages/demobank-ui/src/components/QR.tsx +++ b/packages/demobank-ui/src/components/QR.tsx @@ -33,7 +33,6 @@ export function QR({ text }: { text: string }): VNode { return (
+ */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { createHashHistory } from "history"; +import { Fragment, VNode, h } from "preact"; +import { Route, Router, route } from "preact-router"; +import { useEffect } from "preact/hooks"; +import { useBackendContext } from "../context/backend.js"; +import { BankFrame } from "../pages/BankFrame.js"; +import { HomePage, WithdrawalOperationPage } from "../pages/HomePage.js"; +import { LoginForm } from "../pages/LoginForm.js"; +import { PublicHistoriesPage } from "../pages/PublicHistoriesPage.js"; +import { RegistrationPage } from "../pages/RegistrationPage.js"; +import { AdminHome } from "../pages/admin/Home.js"; +import { BusinessAccount } from "../pages/business/Home.js"; +import { bankUiSettings } from "../settings.js"; + +export function Routing(): VNode { + const history = createHashHistory(); + const backend = useBackendContext(); + const {i18n} = useTranslationContext(); + + if (backend.state.status === "loggedOut") { + return + + ( + +
+

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

+
+ + { + route("/register"); + }} + /> +
+ )} + /> + } + /> + ( + { + route("/account"); + }} + /> + )} + /> + {bankUiSettings.allowRegistrations && + ( + { + route("/account"); + }} + onCancel={() => { + route("/account"); + }} + /> + )} + /> + } + +
+
+ } + const { isUserAdministrator, username } = backend.state + + return ( + + + ( + { + route("/account"); + }} + /> + )} + /> + } + /> + { + if (isUserAdministrator) { + return { + route("/register"); + }} + />; + } else { + return { + route(`/operation/${wopid}`); + }} + goToBusinessAccount={() => { + route("/business"); + }} + onRegister={() => { + route("/register"); + }} + /> + } + }} + /> + ( + { + route("/account"); + }} + onRegister={() => { + route("/register"); + }} + onLoadNotOk={() => { + route("/account"); + }} + /> + )} + /> + + + + ); +} + +function Redirect({ to }: { to: string }): VNode { + useEffect(() => { + route(to, true); + }, []); + return
being redirected to {to}
; +} + +export function assertUnreachable(x: never): never { + throw new Error("Didn't expect to get here"); +} diff --git a/packages/demobank-ui/src/pages/ShowInputErrorLabel.tsx b/packages/demobank-ui/src/components/ShowInputErrorLabel.tsx similarity index 87% rename from packages/demobank-ui/src/pages/ShowInputErrorLabel.tsx rename to packages/demobank-ui/src/components/ShowInputErrorLabel.tsx index dacffe20a..c5840cad9 100644 --- a/packages/demobank-ui/src/pages/ShowInputErrorLabel.tsx +++ b/packages/demobank-ui/src/components/ShowInputErrorLabel.tsx @@ -24,6 +24,6 @@ export function ShowInputErrorLabel({ isDirty: boolean; }): VNode { if (message && isDirty) - return
{message}
; - return ; + return
{message}
; + return
; } diff --git a/packages/demobank-ui/src/components/Transactions/index.ts b/packages/demobank-ui/src/components/Transactions/index.ts index 46b38ce74..9df1a70e5 100644 --- a/packages/demobank-ui/src/components/Transactions/index.ts +++ b/packages/demobank-ui/src/components/Transactions/index.ts @@ -46,6 +46,8 @@ export namespace State { status: "ready"; error: undefined; transactions: Transaction[]; + onPrev?: () => void; + onNext?: () => void; } } diff --git a/packages/demobank-ui/src/components/Transactions/state.ts b/packages/demobank-ui/src/components/Transactions/state.ts index 09c039055..4b62b005e 100644 --- a/packages/demobank-ui/src/components/Transactions/state.ts +++ b/packages/demobank-ui/src/components/Transactions/state.ts @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see */ -import { AbsoluteTime, Amounts } from "@gnu-taler/taler-util"; +import { AbsoluteTime, Amounts, parsePaytoUri } from "@gnu-taler/taler-util"; import { useTransactions } from "../../hooks/access.js"; import { Props, State, Transaction } from "./index.js"; @@ -34,45 +34,19 @@ export function useComponentState({ account }: Props): State { } const transactions = result.data.transactions - .map((item: unknown) => { - if ( - !item || - typeof item !== "object" || - !("direction" in item) || - !("creditorIban" in item) || - !("debtorIban" in item) || - !("date" in item) || - !("subject" in item) || - !("currency" in item) || - !("amount" in item) - ) { - //not valid - return; - } - const anyItem = item as any; - if ( - !(typeof anyItem.creditorIban === "string") || - !(typeof anyItem.debtorIban === "string") || - !(typeof anyItem.date === "string") || - !(typeof anyItem.subject === "string") || - !(typeof anyItem.currency === "string") || - !(typeof anyItem.amount === "string") - ) { - return; - } + .map((tx) => { - const negative = anyItem.direction === "DBIT"; - const counterpart = negative ? anyItem.creditorIban : anyItem.debtorIban; + const negative = tx.direction === "debit"; + const cp = parsePaytoUri(negative ? tx.creditor_payto_uri : tx.debtor_payto_uri); + const counterpart = (cp === undefined || !cp.isKnown ? undefined : + cp.targetType === "iban" ? cp.iban : + cp.targetType === "x-taler-bank" ? cp.account : + cp.targetType === "bitcoin" ? `${cp.targetPath.substring(0, 6)}...` : undefined) ?? + "unkown"; - let date = anyItem.date ? parseInt(anyItem.date, 10) : 0; - if (isNaN(date) || !isFinite(date)) { - date = 0; - } - const when: AbsoluteTime = !date - ? AbsoluteTime.never() - : AbsoluteTime.fromMilliseconds(date); - const amount = Amounts.parse(`${anyItem.currency}:${anyItem.amount}`); - const subject = anyItem.subject; + const when = AbsoluteTime.fromProtocolTimestamp(tx.date); + const amount = Amounts.parse(tx.amount); + const subject = tx.subject; return { negative, counterpart, @@ -87,5 +61,7 @@ export function useComponentState({ account }: Props): State { status: "ready", error: undefined, transactions, + onNext: result.isReachingEnd ? undefined : result.loadMore, + onPrev: result.isReachingStart ? undefined : result.loadMorePrev, }; } diff --git a/packages/demobank-ui/src/components/Transactions/views.tsx b/packages/demobank-ui/src/components/Transactions/views.tsx index 34d078c16..696fb59f3 100644 --- a/packages/demobank-ui/src/components/Transactions/views.tsx +++ b/packages/demobank-ui/src/components/Transactions/views.tsx @@ -14,11 +14,13 @@ GNU Taler; see the file COPYING. If not, see */ -import { h, VNode } from "preact"; +import { Fragment, h, VNode } from "preact"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { State } from "./index.js"; -import { format } from "date-fns"; +import { format, isToday } from "date-fns"; import { Amounts } from "@gnu-taler/taler-util"; +import { useEffect, useRef } from "preact/hooks"; +import { RenderAmount } from "../../pages/PaytoWireTransferForm.js"; export function LoadingUriView({ error }: State.LoadingUriError): VNode { const { i18n } = useTranslationContext(); @@ -30,45 +32,104 @@ export function LoadingUriView({ error }: State.LoadingUriError): VNode { ); } -export function ReadyView({ transactions }: State.Ready): VNode { +export function ReadyView({ transactions, onNext, onPrev }: State.Ready): VNode { const { i18n } = useTranslationContext(); + if (!transactions.length) return
+ const txByDate = transactions.reduce((prev, cur) => { + const d = cur.when.t_ms === "never" + ? "" + : format(cur.when.t_ms, "dd/MM/yyyy") + if (!prev[d]) { + prev[d] = [] + } + prev[d].push(cur) + return prev + }, {} as Record) return ( -
- - - - - - - - - - - {transactions.map((item, idx) => { - return ( - - - - - - - ); - })} - -
{i18n.str`Date`}{i18n.str`Amount`}{i18n.str`Counterpart`}{i18n.str`Subject`}
- {item.when.t_ms === "never" - ? "" - : format(item.when.t_ms, "dd/MM/yyyy HH:mm:ss")} - - {item.negative ? "-" : ""} - {item.amount ? ( - `${Amounts.stringifyValue(item.amount)} ${ - item.amount.currency - }` - ) : ( - <invalid value> - )} - {item.counterpart}{item.subject}
+
+
+
+

Latest transactions

+
+
+
+ + + + + + + + + + + {Object.entries(txByDate).map(([date, txs], idx) => { + return + + + + {txs.map(item => { + const time = item.when.t_ms === "never" ? "" : format(item.when.t_ms, "HH:mm:ss") + const amount = + { } + + return ( + + + + + ) + })} + + + })} + + +
{i18n.str`Date`}{i18n.str`Subject`}
+ {date} +
+
{time}
+
+
Amount
+
+ {item.negative ? i18n.str`sent` : i18n.str`received`} {item.amount ? ( + + ) : ( + <{i18n.str`invalid value`}> + )}
+ +
Counterpart
+
+ {item.negative ? i18n.str`to` : i18n.str`from`} {item.counterpart} +
+
+
{item.subject}
+ + +
); } diff --git a/packages/demobank-ui/src/components/app.tsx b/packages/demobank-ui/src/components/app.tsx index ea86da518..7cf658681 100644 --- a/packages/demobank-ui/src/components/app.tsx +++ b/packages/demobank-ui/src/components/app.tsx @@ -15,16 +15,23 @@ */ import { + LibtoolVersion, getGlobalLogLevel, setGlobalLogLevelFromString, } from "@gnu-taler/taler-util"; -import { TranslationProvider } from "@gnu-taler/web-util/browser"; -import { FunctionalComponent, h } from "preact"; +import { TranslationProvider, useApiContext } from "@gnu-taler/web-util/browser"; +import { ComponentChildren, Fragment, FunctionalComponent, VNode, h } from "preact"; import { SWRConfig } from "swr"; -import { BackendStateProvider } from "../context/backend.js"; +import { BackendStateProvider, useBackendContext } from "../context/backend.js"; import { strings } from "../i18n/strings.js"; -import { Routing } from "../pages/Routing.js"; - +import { Routing } from "./Routing.js"; +import { useEffect, useState } from "preact/hooks"; +import { Loading } from "./Loading.js"; +import { getInitialBackendBaseURL } from "../hooks/backend.js"; +import { BANK_INTEGRATION_PROTOCOL_VERSION, useConfigState } from "../hooks/config.js"; +import { ErrorLoading } from "./ErrorLoading.js"; +import { BankFrame } from "../pages/BankFrame.js"; +import { ConfigStateProvider } from "../context/config.js"; const WITH_LOCAL_STORAGE_CACHE = false; /** @@ -48,22 +55,44 @@ const App: FunctionalComponent = () => { return ( - - - + + + + + - + ); }; (window as any).setGlobalLogLevelFromString = setGlobalLogLevelFromString; (window as any).getGlobalLevel = getGlobalLogLevel; +function VersionCheck({ children }: { children: ComponentChildren }): VNode { + const checked = useConfigState() + + if (checked === undefined) { + return + } + if (checked.type === "wrong") { + return + the bank backend is not supported. supported version "{BANK_INTEGRATION_PROTOCOL_VERSION}", server version "{checked}" + + } + if (checked.type === "ok") { + return {children} + } + + return + + +} + function localStorageProvider(): Map { const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]")); diff --git a/packages/demobank-ui/src/context/backend.ts b/packages/demobank-ui/src/context/backend.ts index b311ddbb0..eae187c6d 100644 --- a/packages/demobank-ui/src/context/backend.ts +++ b/packages/demobank-ui/src/context/backend.ts @@ -34,6 +34,9 @@ const initial: Type = { logOut() { null; }, + expired() { + null; + }, logIn(info) { null; }, @@ -65,6 +68,7 @@ export const BackendStateProviderTesting = ({ const value: BackendStateHandler = { state, logIn: () => {}, + expired: () => {}, logOut: () => {}, }; diff --git a/packages/demobank-ui/src/scss/_title-bar.scss b/packages/demobank-ui/src/context/config.ts similarity index 52% rename from packages/demobank-ui/src/scss/_title-bar.scss rename to packages/demobank-ui/src/context/config.ts index 932f8e65d..a2cde18eb 100644 --- a/packages/demobank-ui/src/scss/_title-bar.scss +++ b/packages/demobank-ui/src/context/config.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + (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 @@ -14,37 +14,39 @@ GNU Taler; see the file COPYING. If not, see */ +import { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext } from "preact/hooks"; + /** * * @author Sebastian Javier Marchano (sebasjm) */ -section.section.is-title-bar { - padding: $default-padding; - border-bottom: $light-border; +export type Type = Required; - ul { - li { - display: inline-block; - padding: 0 $default-padding * 0.5 0 0; - font-size: $default-padding; - color: $title-bar-color; +const initial: Type = { + name: "", + version: "0:0:0", + currency_fraction_digits: 2, + currency_fraction_limit: 2, + fiat_currency: "", + have_cashout: false, +}; +const Context = createContext(initial); - &:after { - display: inline-block; - content: "/"; - padding-left: $default-padding * 0.5; - } +export const useConfigContext = (): Type => useContext(Context); - &:last-child { - padding-right: 0; - font-weight: 900; - color: $title-bar-active-color; +export const ConfigStateProvider = ({ + value, + children, +}: { + value: Type, + children: ComponentChildren; +}): VNode => { + + return h(Context.Provider, { + value, + children, + }); +}; - &:after { - display: none; - } - } - } - } -} diff --git a/packages/demobank-ui/src/declaration.d.ts b/packages/demobank-ui/src/declaration.d.ts index 462287c59..5c55cfade 100644 --- a/packages/demobank-ui/src/declaration.d.ts +++ b/packages/demobank-ui/src/declaration.d.ts @@ -74,7 +74,9 @@ type HashCode = string; type EddsaPublicKey = string; type EddsaSignature = string; type WireTransferIdentifierRawP = string; -type RelativeTime = Duration; +type RelativeTime = { + d_us: number | "forever" +}; type ImageDataUrl = string; interface WithId { @@ -99,20 +101,33 @@ type Amount = string; type UUID = string; type Integer = number; -interface Balance { - amount: Amount; - credit_debit_indicator: "credit" | "debit"; -} - 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; + // If 'true', the server provides local currency + // conversion support. + // If missing or false, some parts of the API + // are not supported and return 404. + have_cashout?: boolean; + + // Fiat currency. That is the currency in which + // cash-out operations ultimately wire money. + // Only applicable if have_cashout=true. + fiat_currency?: string; + + // How many digits should the amounts be rendered + // with by default. Small capitals should + // be used to render fractions beyond the number + // given here (like on gas stations). + currency_fraction_digits?: number; + + // How many decimal digits an operation can + // have. Wire transfers with more decimal + // digits will not be accepted. + currency_fraction_limit?: number; } interface RatiosAndFees { // Exchange rate to buy the circuit currency from fiat. @@ -126,7 +141,7 @@ namespace SandboxBackend { } export interface SandboxError { - error: SandboxErrorDetail; + error?: SandboxErrorDetail; } interface SandboxErrorDetail { // String enum classifying the error. @@ -152,26 +167,12 @@ namespace SandboxBackend { 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: Balance; - // payto://-URI of the account. (New) - paytoUri: string; - // Number indicating the max debit allowed for the requesting user. - debitThreshold: Amount; - } + type EmailAddress = string; + type PhoneNumber = string; + + namespace CoreBank { + interface BankAccountCreateWithdrawalRequest { // Amount to withdraw. amount: Amount; @@ -213,28 +214,24 @@ namespace SandboxBackend { } interface BankAccountTransactionInfo { - creditorIban: string; - creditorBic: string; // Optional - creditorName: string; + creditor_payto_uri: string; + debtor_payto_uri: string; - debtorIban: string; - debtorBic: string; - debtorName: string; + amount: Amount; + direction: "debit" | "credit"; - 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 + row_id: number; + date: Timestamp; } + interface CreateBankAccountTransactionCreate { // Address in the Payto format of the wire transfer receiver. // It needs at least the 'message' query string parameter. - paytoUri: string; + payto_uri: string; // Transaction amount (in the $currency:x.y format), optional. // However, when not given, its value must occupy the 'amount' @@ -243,11 +240,143 @@ namespace SandboxBackend { amount?: string; } - interface BankRegistrationRequest { + interface RegisterAccountRequest { + // Username username: string; + // Password. password: string; + + // Legal name of the account owner + name: string; + + // Defaults to false. + is_public?: boolean; + + // Is this a taler exchange account? + // If true: + // - incoming transactions to the account that do not + // have a valid reserve public key are automatically + // - the account provides the taler-wire-gateway-api endpoints + // Defaults to false. + is_taler_exchange?: boolean; + + // Addresses where to send the TAN for transactions. + // Currently only used for cashouts. + // If missing, cashouts will fail. + // In the future, might be used for other transactions + // as well. + challenge_contact_data?: ChallengeContactData; + + // 'payto' address pointing a bank account + // external to the libeufin-bank. + // Payments will be sent to this bank account + // when the user wants to convert the local currency + // back to fiat currency outside libeufin-bank. + cashout_payto_uri?: string; + + // Internal payto URI of this bank account. + // Used mostly for testing. + internal_payto_uri?: string; } + interface ChallengeContactData { + + // E-Mail address + email?: EmailAddress; + + // Phone number. + phone?: PhoneNumber; + } + + interface AccountReconfiguration { + + // Addresses where to send the TAN for transactions. + // Currently only used for cashouts. + // If missing, cashouts will fail. + // In the future, might be used for other transactions + // as well. + challenge_contact_data?: ChallengeContactData; + + // 'payto' address pointing a bank account + // external to the libeufin-bank. + // Payments will be sent to this bank account + // when the user wants to convert the local currency + // back to fiat currency outside libeufin-bank. + cashout_address?: string; + + // Legal name associated with $username. + // When missing, the old name is kept. + name?: string; + + // If present, change the is_exchange configuration. + // See RegisterAccountRequest + is_exchange?: boolean; + } + + + interface AccountPasswordChange { + + // New password. + new_password: string; + } + interface PublicAccountsResponse { + public_accounts: PublicAccount[]; + } + interface PublicAccount { + payto_uri: string; + + balance: Balance; + + // The account name (=username) of the + // libeufin-bank account. + account_name: string; + } + + interface ListBankAccountsResponse { + accounts: AccountMinimalData[]; + } + interface Balance { + amount: Amount; + credit_debit_indicator: "credit" | "debit"; + } + interface AccountMinimalData { + // Username + username: string; + + // Legal name of the account owner. + name: string; + + // current balance of the account + balance: Balance; + + // Number indicating the max debit allowed for the requesting user. + debit_threshold: Amount; + } + + interface AccountData { + // Legal name of the account owner. + name: string; + + // Available balance on the account. + balance: Balance; + + // payto://-URI of the account. + payto_uri: string; + + // Number indicating the max debit allowed for the requesting user. + debit_threshold: Amount; + + contact_data?: ChallengeContactData; + + // 'payto' address pointing the bank account + // where to send cashouts. This field is optional + // because not all the accounts are required to participate + // in the merchants' circuit. One example is the exchange: + // that never cashouts. Registering these accounts can + // be done via the access API. + cashout_payto_uri?: string; + } + } namespace Circuit { diff --git a/packages/demobank-ui/src/demobank-ui-settings.js b/packages/demobank-ui/src/demobank-ui-settings.js new file mode 100644 index 000000000..8a0961831 --- /dev/null +++ b/packages/demobank-ui/src/demobank-ui-settings.js @@ -0,0 +1,21 @@ +// Values for development environment + +/** + * Global settings for the demobank UI. + */ +localStorage.setItem("bank-base-url", "http://bank.taler.test/"); + +globalThis.talerDemobankSettings = { + backendBaseURL: "http://bank.taler.test/", + allowRegistrations: true, + showDemoNav: true, + simplePasswordForRandomAccounts: true, + allowRandomAccountCreation: true, + bankName: "Taler DEVELOPMENT Bank", + // Names and links for other demo sites to show in the navbar + demoSites: [ + ["Exchange", "https://Exchnage.taler.test/"], + ["Bank", "https://bank-ui.taler.test/"], + ["Merchant", "https://merchant.taler.test/"], + ], +}; diff --git a/packages/demobank-ui/src/forms/simplest.ts b/packages/demobank-ui/src/forms/simplest.ts new file mode 100644 index 000000000..54b6b1c65 --- /dev/null +++ b/packages/demobank-ui/src/forms/simplest.ts @@ -0,0 +1,66 @@ +import { + AbsoluteTime, + AmountJson, + TranslatedString +} from "@gnu-taler/taler-util"; +import { DoubleColumnForm, FormState } from "@gnu-taler/web-util/browser"; + +export namespace Data { + export interface WithResolution { + when: AbsoluteTime; + threshold: AmountJson; + state: string; + } + export interface Form extends WithResolution { + comment: string; + } +} + +const design: DoubleColumnForm = [ + { + title: "Simple form" as TranslatedString, + fields: [ + { + type: "textArea", + props: { + name: "comment", + label: "Comments" as TranslatedString, + }, + }, + ], + }, + { + title: "Resolution" as TranslatedString, + description: `Current state is and threshold at ` as TranslatedString, + fields: [ + { + type: "date", + props: { + name: "when", + label: "Decision Time" as TranslatedString, + }, + }, + { + type: "amount", + props: { + name: "threshold", + label: "New threshold" as TranslatedString, + }, + }, + ], + } + , +]; + +function formBehavior(v: Partial): FormState { + return { + when: { + disabled: true, + }, + threshold: { + // disabled: v.state === AmlExchangeBackend.AmlState.frozen, + }, + }; +} + + diff --git a/packages/demobank-ui/src/hooks/access.ts b/packages/demobank-ui/src/hooks/access.ts index b8b6ab899..154c43ae6 100644 --- a/packages/demobank-ui/src/hooks/access.ts +++ b/packages/demobank-ui/src/hooks/access.ts @@ -44,13 +44,13 @@ export function useAccessAPI(): AccessAPI { const account = state.username; const createWithdrawal = async ( - data: SandboxBackend.Access.BankAccountCreateWithdrawalRequest, + data: SandboxBackend.CoreBank.BankAccountCreateWithdrawalRequest, ): Promise< - HttpResponseOk + HttpResponseOk > => { const res = - await request( - `access-api/accounts/${account}/withdrawals`, + await request( + `accounts/${account}/withdrawals`, { method: "POST", data, @@ -60,21 +60,21 @@ export function useAccessAPI(): AccessAPI { return res; }; const createTransaction = async ( - data: SandboxBackend.Access.CreateBankAccountTransactionCreate, + data: SandboxBackend.CoreBank.CreateBankAccountTransactionCreate, ): Promise> => { const res = await request( - `access-api/accounts/${account}/transactions`, + `accounts/${account}/transactions`, { method: "POST", data, contentType: "json", }, ); - await mutateAll(/.*accounts\/.*\/transactions.*/); + await mutateAll(/.*accounts\/.*/); return res; }; const deleteAccount = async (): Promise> => { - const res = await request(`access-api/accounts/${account}`, { + const res = await request(`accounts/${account}`, { method: "DELETE", contentType: "json", }); @@ -94,7 +94,7 @@ export function useAccessAnonAPI(): AccessAnonAPI { const { request } = useAuthenticatedBackend(); const abortWithdrawal = async (id: string): Promise> => { - const res = await request(`access-api/withdrawals/${id}/abort`, { + const res = await request(`withdrawals/${id}/abort`, { method: "POST", contentType: "json", }); @@ -104,7 +104,7 @@ export function useAccessAnonAPI(): AccessAnonAPI { const confirmWithdrawal = async ( id: string, ): Promise> => { - const res = await request(`access-api/withdrawals/${id}/confirm`, { + const res = await request(`withdrawals/${id}/confirm`, { method: "POST", contentType: "json", }); @@ -122,9 +122,10 @@ export function useTestingAPI(): TestingAPI { const mutateAll = useMatchMutate(); const { request: noAuthRequest } = usePublicBackend(); const register = async ( - data: SandboxBackend.Access.BankRegistrationRequest, + data: SandboxBackend.CoreBank.RegisterAccountRequest, ): Promise> => { - const res = await noAuthRequest(`access-api/testing/register`, { + // FIXME: This API is deprecated. The normal account registration API should be used instead. + const res = await noAuthRequest(`accounts`, { method: "POST", data, contentType: "json", @@ -138,18 +139,18 @@ export function useTestingAPI(): TestingAPI { export interface TestingAPI { register: ( - data: SandboxBackend.Access.BankRegistrationRequest, + data: SandboxBackend.CoreBank.RegisterAccountRequest, ) => Promise>; } export interface AccessAPI { createWithdrawal: ( - data: SandboxBackend.Access.BankAccountCreateWithdrawalRequest, + data: SandboxBackend.CoreBank.BankAccountCreateWithdrawalRequest, ) => Promise< - HttpResponseOk + HttpResponseOk >; createTransaction: ( - data: SandboxBackend.Access.CreateBankAccountTransactionCreate, + data: SandboxBackend.CoreBank.CreateBankAccountTransactionCreate, ) => Promise>; deleteAccount: () => Promise>; } @@ -166,15 +167,15 @@ export interface InstanceTemplateFilter { export function useAccountDetails( account: string, ): HttpResponse< - SandboxBackend.Access.BankAccountBalanceResponse, + SandboxBackend.CoreBank.AccountData, SandboxBackend.SandboxError > { const { fetcher } = useAuthenticatedBackend(); const { data, error } = useSWR< - HttpResponseOk, + HttpResponseOk, RequestError - >([`access-api/accounts/${account}`], fetcher, { + >([`accounts/${account}`], fetcher, { refreshInterval: 0, refreshWhenHidden: false, revalidateOnFocus: false, @@ -186,28 +187,8 @@ export function useAccountDetails( keepPreviousData: true, }); - //FIXME: remove optional when libeufin sandbox has implemented the feature - if (data && typeof data.data.debitThreshold === "undefined") { - data.data.debitThreshold = "0"; - } - //FIXME: sandbox server should return amount string if (data) { - const isAmount = Amounts.parse(data.data.debitThreshold); - if (isAmount) { - //server response with correct format - return data; - } - const { currency } = Amounts.parseOrThrow(data.data.balance.amount); - const clone = structuredClone(data); - - const theNumber = Number.parseInt(data.data.debitThreshold, 10); - const value = Number.isNaN(theNumber) ? 0 : theNumber; - clone.data.debitThreshold = Amounts.stringify({ - currency, - value: value, - fraction: 0, - }); - return clone; + return data; } if (error) return error.cause; return { loading: true }; @@ -217,15 +198,15 @@ export function useAccountDetails( export function useWithdrawalDetails( wid: string, ): HttpResponse< - SandboxBackend.Access.BankAccountGetWithdrawalResponse, + SandboxBackend.CoreBank.BankAccountGetWithdrawalResponse, SandboxBackend.SandboxError > { const { fetcher } = useAuthenticatedBackend(); const { data, error } = useSWR< - HttpResponseOk, + HttpResponseOk, RequestError - >([`access-api/withdrawals/${wid}`], fetcher, { + >([`withdrawals/${wid}`], fetcher, { refreshInterval: 1000, refreshWhenHidden: false, revalidateOnFocus: false, @@ -247,15 +228,15 @@ export function useTransactionDetails( account: string, tid: string, ): HttpResponse< - SandboxBackend.Access.BankAccountTransactionInfo, + SandboxBackend.CoreBank.BankAccountTransactionInfo, SandboxBackend.SandboxError > { - const { fetcher } = useAuthenticatedBackend(); + const { paginatedFetcher } = useAuthenticatedBackend(); const { data, error } = useSWR< - HttpResponseOk, + HttpResponseOk, RequestError - >([`access-api/accounts/${account}/transactions/${tid}`], fetcher, { + >([`accounts/${account}/transactions/${tid}`], paginatedFetcher, { refreshInterval: 0, refreshWhenHidden: false, revalidateOnFocus: false, @@ -274,13 +255,13 @@ export function useTransactionDetails( } interface PaginationFilter { - page: number; + // page: number; } export function usePublicAccounts( args?: PaginationFilter, ): HttpResponsePaginated< - SandboxBackend.Access.PublicAccountsResponse, + SandboxBackend.CoreBank.PublicAccountsResponse, SandboxBackend.SandboxError > { const { paginatedFetcher } = usePublicBackend(); @@ -292,13 +273,13 @@ export function usePublicAccounts( error: afterError, isValidating: loadingAfter, } = useSWR< - HttpResponseOk, + HttpResponseOk, RequestError - >([`access-api/public-accounts`, args?.page, PAGE_SIZE], paginatedFetcher); + >([`public-accounts`, page, PAGE_SIZE], paginatedFetcher); const [lastAfter, setLastAfter] = useState< HttpResponse< - SandboxBackend.Access.PublicAccountsResponse, + SandboxBackend.CoreBank.PublicAccountsResponse, SandboxBackend.SandboxError > >({ loading: true }); @@ -311,7 +292,7 @@ export function usePublicAccounts( // 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; + afterData && afterData.data.public_accounts.length < PAGE_SIZE; const isReachingStart = false; const pagination = { @@ -319,7 +300,7 @@ export function usePublicAccounts( isReachingStart, loadMore: () => { if (!afterData || isReachingEnd) return; - if (afterData.data.publicAccounts.length < MAX_RESULT_SIZE) { + if (afterData.data.public_accounts.length < MAX_RESULT_SIZE) { setPage(page + 1); } }, @@ -328,12 +309,12 @@ export function usePublicAccounts( }, }; - const publicAccounts = !afterData + const public_accounts = !afterData ? [] - : (afterData || lastAfter).data.publicAccounts; - if (loadingAfter) return { loading: true, data: { publicAccounts } }; + : (afterData || lastAfter).data.public_accounts; + if (loadingAfter) return { loading: true, data: { public_accounts } }; if (afterData) { - return { ok: true, data: { publicAccounts }, ...pagination }; + return { ok: true, data: { public_accounts }, ...pagination }; } return { loading: true }; } @@ -348,28 +329,36 @@ export function useTransactions( account: string, args?: PaginationFilter, ): HttpResponsePaginated< - SandboxBackend.Access.BankAccountTransactionsResponse, + SandboxBackend.CoreBank.BankAccountTransactionsResponse, SandboxBackend.SandboxError > { const { paginatedFetcher } = useAuthenticatedBackend(); - const [page, setPage] = useState(1); + const [start, setStart] = useState(); const { data: afterData, error: afterError, isValidating: loadingAfter, } = useSWR< - HttpResponseOk, + HttpResponseOk, RequestError >( - [`access-api/accounts/${account}/transactions`, args?.page, PAGE_SIZE], - paginatedFetcher, + [`accounts/${account}/transactions`, start, PAGE_SIZE], + paginatedFetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + refreshWhenOffline: false, + // revalidateOnMount: false, + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + } ); const [lastAfter, setLastAfter] = useState< HttpResponse< - SandboxBackend.Access.BankAccountTransactionsResponse, + SandboxBackend.CoreBank.BankAccountTransactionsResponse, SandboxBackend.SandboxError > >({ loading: true }); @@ -385,19 +374,23 @@ export function useTransactions( // 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 isReachingStart = start == undefined; const pagination = { isReachingEnd, isReachingStart, loadMore: () => { if (!afterData || isReachingEnd) return; - if (afterData.data.transactions.length < MAX_RESULT_SIZE) { - setPage(page + 1); - } + // if (afterData.data.transactions.length < MAX_RESULT_SIZE) { + const l = afterData.data.transactions[afterData.data.transactions.length-1] + setStart(String(l.row_id)); + // } }, loadMorePrev: () => { - null; + if (!afterData || isReachingStart) return; + // if (afterData.data.transactions.length < MAX_RESULT_SIZE) { + setStart(undefined) + // } }, }; diff --git a/packages/demobank-ui/src/hooks/backend.ts b/packages/demobank-ui/src/hooks/backend.ts index 4b60d1b6c..889618646 100644 --- a/packages/demobank-ui/src/hooks/backend.ts +++ b/packages/demobank-ui/src/hooks/backend.ts @@ -40,21 +40,24 @@ import { useCallback, useEffect, useState } from "preact/hooks"; import { useSWRConfig } from "swr"; import { useBackendContext } from "../context/backend.js"; import { bankUiSettings } from "../settings.js"; +import { AccessToken } from "./useCredentialsChecker.js"; /** * Has the information to reach and * authenticate at the bank's backend. */ -export type BackendState = LoggedIn | LoggedOut; +export type BackendState = LoggedIn | LoggedOut | Expired; -export interface BackendCredentials { - username: string; - password: string; -} - -interface LoggedIn extends BackendCredentials { +interface LoggedIn { status: "loggedIn"; isUserAdministrator: boolean; + username: string; + token: AccessToken; +} +interface Expired { + status: "expired"; + isUserAdministrator: boolean; + username: string; } interface LoggedOut { status: "loggedOut"; @@ -64,10 +67,17 @@ export const codecForBackendStateLoggedIn = (): Codec => buildCodecForObject() .property("status", codecForConstString("loggedIn")) .property("username", codecForString()) - .property("password", codecForString()) + .property("token", codecForString() as Codec) .property("isUserAdministrator", codecForBoolean()) .build("BackendState.LoggedIn"); +export const codecForBackendStateExpired = (): Codec => + buildCodecForObject() + .property("status", codecForConstString("expired")) + .property("username", codecForString()) + .property("isUserAdministrator", codecForBoolean()) + .build("BackendState.Expired"); + export const codecForBackendStateLoggedOut = (): Codec => buildCodecForObject() .property("status", codecForConstString("loggedOut")) @@ -78,6 +88,7 @@ export const codecForBackendState = (): Codec => .discriminateOn("status") .alternative("loggedIn", codecForBackendStateLoggedIn()) .alternative("loggedOut", codecForBackendStateLoggedOut()) + .alternative("expired", codecForBackendStateExpired()) .build("BackendState"); export function getInitialBackendBaseURL(): string { @@ -85,18 +96,27 @@ export function getInitialBackendBaseURL(): string { typeof localStorage !== "undefined" ? localStorage.getItem("bank-base-url") : undefined; + let result: string; if (!overrideUrl) { //normal path if (!bankUiSettings.backendBaseURL) { console.error( "ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'", ); - return canonicalizeBaseUrl(window.origin); + result = window.origin + } else { + result = bankUiSettings.backendBaseURL; } - return canonicalizeBaseUrl(bankUiSettings.backendBaseURL); + } else { + // testing/development path + result = overrideUrl + } + try { + return canonicalizeBaseUrl(result) + } catch (e) { + //fall back + return canonicalizeBaseUrl(window.origin) } - // testing/development path - return canonicalizeBaseUrl(overrideUrl); } export const defaultState: BackendState = { @@ -106,7 +126,8 @@ export const defaultState: BackendState = { export interface BackendStateHandler { state: BackendState; logOut(): void; - logIn(info: BackendCredentials): void; + expired(): void; + logIn(info: {username: string, token: AccessToken}): void; } const BACKEND_STATE_KEY = buildStorageKey( @@ -124,12 +145,22 @@ export function useBackendState(): BackendStateHandler { BACKEND_STATE_KEY, defaultState, ); + const mutateAll = useMatchMutate(); return { state, logOut() { update(defaultState); }, + expired() { + if (state.status === "loggedOut") return; + const nextState: BackendState = { + status: "expired", + username: state.username, + isUserAdministrator: state.username === "admin", + }; + update(nextState); + }, logIn(info) { //admin is defined by the username const nextState: BackendState = { @@ -138,6 +169,7 @@ export function useBackendState(): BackendStateHandler { isUserAdministrator: info.username === "admin", }; update(nextState); + mutateAll(/.*/) }, }; } @@ -150,7 +182,7 @@ interface useBackendType { fetcher: (endpoint: string) => Promise>; multiFetcher: (endpoint: string[][]) => Promise[]>; paginatedFetcher: ( - args: [string, number, number], + args: [string, string | undefined, number], ) => Promise>; sandboxAccountsFetcher: ( args: [string, number, number, string], @@ -179,13 +211,15 @@ export function usePublicBackend(): useBackendType { [baseUrl], ); const paginatedFetcher = useCallback( - function fetcherImpl([endpoint, page, size]: [ + function fetcherImpl([endpoint, start, size]: [ string, - number, + string | undefined, number, ]): Promise> { + const delta = -1 * size //descending order + const params = start ? { delta, start } : { delta } return requestHandler(baseUrl, endpoint, { - params: { page: page || 1, size }, + params, }); }, [baseUrl], @@ -247,35 +281,12 @@ interface InvalidationResult { error: unknown; } -export function useCredentialsChecker() { - const { request } = useApiContext(); - const baseUrl = getInitialBackendBaseURL(); - //check against account details endpoint - //while sandbox backend doesn't have a login endpoint - return async function testLogin( - username: string, - password: string, - ): Promise { - try { - await request(baseUrl, `access-api/accounts/${username}/`, { - basicAuth: { username, password }, - preventCache: true, - }); - return { valid: true }; - } catch (error) { - if (error instanceof RequestError) { - return { valid: false, requestError: true, cause: error.cause }; - } - return { valid: false, requestError: false, error }; - } - }; -} - export function useAuthenticatedBackend(): useBackendType { const { state } = useBackendContext(); const { request: requestHandler } = useApiContext(); - const creds = state.status === "loggedIn" ? state : undefined; + // FIXME: libeufin returns 400 insteand of 401 if there is no auth token + const creds = state.status === "loggedIn" ? state.token : "secret-token:a"; const baseUrl = getInitialBackendBaseURL(); const request = useCallback( @@ -283,26 +294,28 @@ export function useAuthenticatedBackend(): useBackendType { path: string, options: RequestOptions = {}, ): Promise> { - return requestHandler(baseUrl, path, { basicAuth: creds, ...options }); + return requestHandler(baseUrl, path, { token: creds, ...options }); }, [baseUrl, creds], ); const fetcher = useCallback( function fetcherImpl(endpoint: string): Promise> { - return requestHandler(baseUrl, endpoint, { basicAuth: creds }); + return requestHandler(baseUrl, endpoint, { token: creds }); }, [baseUrl, creds], ); const paginatedFetcher = useCallback( - function fetcherImpl([endpoint, page = 1, size]: [ + function fetcherImpl([endpoint, start, size]: [ string, - number, + string | undefined, number, ]): Promise> { + const delta = -1 * size //descending order + const params = start ? { delta, start } : { delta } return requestHandler(baseUrl, endpoint, { - basicAuth: creds, - params: { page, size }, + token: creds, + params, }); }, [baseUrl, creds], @@ -313,7 +326,7 @@ export function useAuthenticatedBackend(): useBackendType { > { return Promise.all( endpoints.map((endpoint) => - requestHandler(baseUrl, endpoint, { basicAuth: creds }), + requestHandler(baseUrl, endpoint, { token: creds }), ), ); }, @@ -327,7 +340,7 @@ export function useAuthenticatedBackend(): useBackendType { string, ]): Promise> { return requestHandler(baseUrl, endpoint, { - basicAuth: creds, + token: creds, params: { page: page || 1, size }, }); }, @@ -339,7 +352,7 @@ export function useAuthenticatedBackend(): useBackendType { HttpResponseOk > { return requestHandler(baseUrl, endpoint, { - basicAuth: creds, + token: creds, params: { account }, }); }, diff --git a/packages/demobank-ui/src/hooks/circuit.ts b/packages/demobank-ui/src/hooks/circuit.ts index 06557b77f..5dba60951 100644 --- a/packages/demobank-ui/src/hooks/circuit.ts +++ b/packages/demobank-ui/src/hooks/circuit.ts @@ -33,6 +33,7 @@ import { // FIX default import https://github.com/microsoft/TypeScript/issues/49189 import _useSWR, { SWRHook } from "swr"; import { AmountJson, Amounts } from "@gnu-taler/taler-util"; +import { AccessToken } from "./useCredentialsChecker.js"; const useSWR = _useSWR as unknown as SWRHook; export function useAdminAccountAPI(): AdminAccountAPI { @@ -90,7 +91,8 @@ export function useAdminAccountAPI(): AdminAccountAPI { await mutateAll(/.*/); logIn({ username: account, - password: data.new_password, + //FIXME: change password api + token: data.new_password as AccessToken, }); } return res; @@ -215,14 +217,15 @@ export interface CircuitAccountAPI { async function getBusinessStatus( request: ReturnType["request"], - basicAuth: { username: string; password: string }, + username: string, + token: AccessToken, ): Promise { try { const url = getInitialBackendBaseURL(); const result = await request( url, - `circuit-api/accounts/${basicAuth.username}`, - { basicAuth }, + `circuit-api/accounts/${username}`, + { token }, ); return result.ok; } catch (error) { @@ -264,10 +267,10 @@ type CashoutEstimators = { export function useEstimator(): CashoutEstimators { const { state } = useBackendContext(); const { request } = useApiContext(); - const basicAuth = - state.status === "loggedOut" + const creds = + state.status !== "loggedIn" ? undefined - : { username: state.username, password: state.password }; + : state.token; return { estimateByCredit: async (amount, fee, rate) => { const zeroBalance = Amounts.zeroOfCurrency(fee.currency); @@ -282,7 +285,7 @@ export function useEstimator(): CashoutEstimators { url, `circuit-api/cashouts/estimates`, { - basicAuth, + token: creds, params: { amount_credit: Amounts.stringify(amount), }, @@ -313,7 +316,7 @@ export function useEstimator(): CashoutEstimators { url, `circuit-api/cashouts/estimates`, { - basicAuth, + token: creds, params: { amount_debit: Amounts.stringify(amount), }, @@ -337,13 +340,13 @@ export function useBusinessAccountFlag(): boolean | undefined { const { state } = useBackendContext(); const { request } = useApiContext(); const creds = - state.status === "loggedOut" + state.status !== "loggedIn" ? undefined - : { username: state.username, password: state.password }; + : {user: state.username, token: state.token}; useEffect(() => { if (!creds) return; - getBusinessStatus(request, creds) + getBusinessStatus(request, creds.user, creds.token) .then((result) => { setIsBusiness(result); }) @@ -432,7 +435,7 @@ export function useBusinessAccounts( HttpResponseOk, RequestError >( - [`circuit-api/accounts`, args?.page, PAGE_SIZE, args?.account], + [`accounts`, args?.page, PAGE_SIZE, args?.account], sandboxAccountsFetcher, { refreshInterval: 0, diff --git a/packages/demobank-ui/src/hooks/config.ts b/packages/demobank-ui/src/hooks/config.ts new file mode 100644 index 000000000..a3bd294db --- /dev/null +++ b/packages/demobank-ui/src/hooks/config.ts @@ -0,0 +1,59 @@ +import { LibtoolVersion } from "@gnu-taler/taler-util"; +import { ErrorType, HttpError, HttpResponseServerError, RequestError, useApiContext } from "@gnu-taler/web-util/browser"; +import { useEffect, useState } from "preact/hooks"; +import { getInitialBackendBaseURL } from "./backend.js"; + +/** + * Protocol version spoken with the bank. + * + * Uses libtool's current:revision:age versioning. + */ +export const BANK_INTEGRATION_PROTOCOL_VERSION = "0:0:0"; + +async function getConfigState( + request: ReturnType["request"], +): Promise { + const url = getInitialBackendBaseURL(); + const result = await request(url, `config`); + return result.data; +} + +export type ConfigResult = undefined + | { type: "ok", result: Required } + | { type: "wrong", result: SandboxBackend.Config } + | { type: "error", result: HttpError } + +export function useConfigState(): ConfigResult { + const [checked, setChecked] = useState() + const { request } = useApiContext(); + + useEffect(() => { + getConfigState(request) + .then((result) => { + const r = LibtoolVersion.compare(BANK_INTEGRATION_PROTOCOL_VERSION, result.version) + if (r?.compatible) { + const complete: Required = { + currency_fraction_digits: result.currency_fraction_digits ?? 2, + currency_fraction_limit: result.currency_fraction_limit ?? 2, + fiat_currency: "", + have_cashout: result.have_cashout ?? false, + name: result.name, + version: result.version, + } + setChecked({ type: "ok", result: complete }); + } else { + setChecked({ type: "wrong", result }) + } + }) + .catch((error: unknown) => { + if (error instanceof RequestError) { + const result = error.cause + setChecked({ type: "error", result }); + } + }); + }, []); + + return checked; +} + + diff --git a/packages/demobank-ui/src/hooks/notification.ts b/packages/demobank-ui/src/hooks/notification.ts deleted file mode 100644 index 9bf621b41..000000000 --- a/packages/demobank-ui/src/hooks/notification.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { TranslatedString } from "@gnu-taler/taler-util"; -import { memoryMap } from "@gnu-taler/web-util/browser"; -import { StateUpdater, useEffect, useState } from "preact/hooks"; - -export type NotificationMessage = ErrorNotification | InfoNotification; - -//FIXME: this should not be exported since every notification -// goes throw notify function -export interface ErrorMessage { - description?: string; - title: TranslatedString; - debug?: string; -} - -interface ErrorNotification { - type: "error"; - error: ErrorMessage; -} -interface InfoNotification { - type: "info"; - info: TranslatedString; -} - -const storage = memoryMap(); -const NOTIFICATION_KEY = "notification"; - -export function onNotificationUpdate( - handler: (newValue: NotificationMessage | undefined) => void, -) { - return storage.onUpdate(NOTIFICATION_KEY, () => { - const newValue = storage.get(NOTIFICATION_KEY); - handler(newValue); - }); -} - -export function notifyError(error: ErrorMessage) { - storage.set(NOTIFICATION_KEY, { type: "error", error }); -} -export function notifyInfo(info: TranslatedString) { - storage.set(NOTIFICATION_KEY, { type: "info", info }); -} - -export function useNotifications(): [ - NotificationMessage | undefined, - StateUpdater, -] { - const [value, setter] = useState(); - useEffect(() => { - return storage.onUpdate(NOTIFICATION_KEY, () => { - setter(storage.get(NOTIFICATION_KEY)); - }); - }); - return [value, setter]; -} diff --git a/packages/demobank-ui/src/hooks/settings.ts b/packages/demobank-ui/src/hooks/settings.ts index 46b31bf2a..ad853f9d7 100644 --- a/packages/demobank-ui/src/hooks/settings.ts +++ b/packages/demobank-ui/src/hooks/settings.ts @@ -15,8 +15,12 @@ */ import { + AmountString, Codec, buildCodecForObject, + codecForAmountString, + codecForBoolean, + codecForNumber, codecForString, codecOptional, } from "@gnu-taler/taler-util"; @@ -24,15 +28,33 @@ import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; interface Settings { currentWithdrawalOperationId: string | undefined; + showWithdrawalSuccess: boolean; + showDemoDescription: boolean; + showInstallWallet: boolean; + maxWithdrawalAmount: number; + fastWithdrawal: boolean; + showDebugInfo: boolean; } export const codecForSettings = (): Codec => buildCodecForObject() .property("currentWithdrawalOperationId", codecOptional(codecForString())) + .property("showWithdrawalSuccess", (codecForBoolean())) + .property("showDemoDescription", (codecForBoolean())) + .property("showInstallWallet", (codecForBoolean())) + .property("fastWithdrawal", (codecForBoolean())) + .property("showDebugInfo", (codecForBoolean())) + .property("maxWithdrawalAmount", codecForNumber()) .build("Settings"); const defaultSettings: Settings = { currentWithdrawalOperationId: undefined, + showWithdrawalSuccess: true, + showDemoDescription: true, + showInstallWallet: true, + maxWithdrawalAmount: 25, + fastWithdrawal: false, + showDebugInfo: false, }; const DEMOBANK_SETTINGS_KEY = buildStorageKey( diff --git a/packages/demobank-ui/src/hooks/useCredentialsChecker.ts b/packages/demobank-ui/src/hooks/useCredentialsChecker.ts new file mode 100644 index 000000000..b3dedb654 --- /dev/null +++ b/packages/demobank-ui/src/hooks/useCredentialsChecker.ts @@ -0,0 +1,135 @@ +import { AbsoluteTime, HttpStatusCode } from "@gnu-taler/taler-util"; +import { ErrorType, HttpError, RequestError, useApiContext } from "@gnu-taler/web-util/browser"; +import { getInitialBackendBaseURL } from "./backend.js"; + +export function useCredentialsChecker() { + const { request } = useApiContext(); + const baseUrl = getInitialBackendBaseURL(); + //check against instance details endpoint + //while merchant backend doesn't have a login endpoint + async function requestNewLoginToken( + username: string, + password: string, + ): Promise { + const data: LoginTokenRequest = { + scope: "readwrite" as "write", //FIX: different than merchant + duration: { + // d_us: "forever" //FIX: should return shortest + d_us: 60 * 60 * 24 * 7 * 1000 * 1000 + }, + refreshable: true, + } + try { + const response = await request(baseUrl, `accounts/${username}/token`, { + method: "POST", + basicAuth: { + username, + password, + }, + data, + contentType: "json" + }); + return { valid: true, token: `secret-token:${response.data.access_token}` as AccessToken, expiration: response.data.expiration }; + } catch (error) { + if (error instanceof RequestError) { + return { valid: false, cause: error.cause }; + } + + return { + valid: false, cause: { + type: ErrorType.UNEXPECTED, + loading: false, + info: { + hasToken: true, + status: 0, + options: {}, + url: `/private/token`, + payload: {} + }, + exception: error, + message: (error instanceof Error ? error.message : "unpexepected error") + } + }; + } + }; + + async function refreshLoginToken( + baseUrl: string, + token: LoginToken + ): Promise { + + if (AbsoluteTime.isExpired(AbsoluteTime.fromProtocolTimestamp(token.expiration))) { + return { + valid: false, cause: { + type: ErrorType.CLIENT, + status: HttpStatusCode.Unauthorized, + message: "login token expired, login again.", + info: { + hasToken: true, + status: 401, + options: {}, + url: `/private/token`, + payload: {} + }, + payload: {} + }, + } + } + + return requestNewLoginToken(baseUrl, token.token) + } + return { requestNewLoginToken, refreshLoginToken } +} + +export interface LoginToken { + token: AccessToken, + expiration: Timestamp, +} +// token used to get loginToken +// must forget after used +declare const __ac_token: unique symbol; +export type AccessToken = string & { + [__ac_token]: true; +}; + +type YesOrNo = "yes" | "no"; +export type LoginResult = { + valid: true; + token: AccessToken; + expiration: Timestamp; +} | { + valid: false; + cause: HttpError<{}>; +} + + +// DELETE /private/instances/$INSTANCE +export interface LoginTokenRequest { + // Scope of the token (which kinds of operations it will allow) + scope: "readonly" | "write"; + + // Server may impose its own upper bound + // on the token validity duration + duration?: RelativeTime; + + // Can this token be refreshed? + // Defaults to false. + refreshable?: boolean; +} +export interface LoginTokenSuccessResponse { + // The login token that can be used to access resources + // that are in scope for some time. Must be prefixed + // with "Bearer " when used in the "Authorization" HTTP header. + // Will already begin with the RFC 8959 prefix. + access_token: AccessToken; + + // Scope of the token (which kinds of operations it will allow) + scope: "readonly" | "write"; + + // Server may impose its own upper bound + // on the token validity duration + expiration: Timestamp; + + // Can this token be refreshed? + refreshable: boolean; +} diff --git a/packages/demobank-ui/src/index.html b/packages/demobank-ui/src/index.html index e21e1fccc..315985648 100644 --- a/packages/demobank-ui/src/index.html +++ b/packages/demobank-ui/src/index.html @@ -16,27 +16,28 @@ @author Sebastian Javier Marchano --> - - - - - - - - - - - Demobank - - - - - - - -
- - + + + + + + + + + + + + Demobank + + + + + + + + +
+ + + \ No newline at end of file diff --git a/packages/demobank-ui/src/index.tsx b/packages/demobank-ui/src/index.tsx index 2e0f740fe..b7d69fd2d 100644 --- a/packages/demobank-ui/src/index.tsx +++ b/packages/demobank-ui/src/index.tsx @@ -16,7 +16,7 @@ import App from "./components/app.js"; import { h, render } from "preact"; -import "./scss/main.scss"; +import "./scss/main.css" const app = document.getElementById("app"); diff --git a/packages/demobank-ui/src/pages/AccountPage.tsx b/packages/demobank-ui/src/pages/AccountPage.tsx deleted file mode 100644 index 820c59984..000000000 --- a/packages/demobank-ui/src/pages/AccountPage.tsx +++ /dev/null @@ -1,170 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -import { Amounts, HttpStatusCode, parsePaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util"; -import { - ErrorType, - HttpResponsePaginated, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; -import { Transactions } from "../components/Transactions/index.js"; -import { useBackendContext } from "../context/backend.js"; -import { useAccountDetails } from "../hooks/access.js"; -import { LoginForm } from "./LoginForm.js"; -import { PaymentOptions } from "./PaymentOptions.js"; -import { notifyError } from "../hooks/notification.js"; -import { useEffect, useState } from "preact/hooks"; - -interface Props { - account: string; - onLoadNotOk: ( - error: HttpResponsePaginated, - ) => VNode; -} - -export const CopyIcon = (): VNode => ( - - - - -); - -export const CopiedIcon = (): VNode => ( - - - -); - -function CopyButton({ getContent }: { getContent: () => string }): VNode { - const [copied, setCopied] = useState(false); - function copyText(): void { - navigator.clipboard.writeText(getContent() || ""); - setCopied(true); - } - useEffect(() => { - if (copied) { - setTimeout(() => { - setCopied(false); - }, 1000); - } - }, [copied]); - - if (!copied) { - return ( - - ); - } - return ( -
- -
- ); -} - - -/** - * Query account information and show QR code if there is pending withdrawal - */ -export function AccountPage({ account, onLoadNotOk }: Props): VNode { - const result = useAccountDetails(account); - const backend = useBackendContext(); - const { i18n } = useTranslationContext(); - - if (!result.ok) { - if (result.loading || result.type === ErrorType.TIMEOUT) { - return onLoadNotOk(result); - } - //logout if there is any error, not if loading - backend.logOut(); - if (result.status === HttpStatusCode.NotFound) { - notifyError({ - title: i18n.str`Username or account label "${account}" not found`, - }); - return ; - } - return onLoadNotOk(result); - } - - const { data } = result; - const balance = Amounts.parseOrThrow(data.balance.amount); - const debitThreshold = Amounts.parseOrThrow(data.debitThreshold); - const payto = parsePaytoUri(data.paytoUri); - if (!payto || !payto.isKnown || payto.targetType !== "iban") { - return ( -
Payto from server is not valid "{data.paytoUri}"
- ); - } - const balanceIsDebit = data.balance.credit_debit_indicator == "debit"; - const limit = balanceIsDebit - ? Amounts.sub(debitThreshold, balance).amount - : Amounts.add(balance, debitThreshold).amount; - return ( - -
-

- - Welcome, {account} ({payto.iban})! stringifyPaytoUri(payto)} /> - -

-
- -
-
-

{i18n.str`Bank account balance`}

- {!balance ? ( -
- Waiting server response... -
- ) : ( -
- {balanceIsDebit ? - : null} - {`${Amounts.stringifyValue(balance)}`} -   - {`${balance.currency}`} -
- )} -
-
-
-
-

{i18n.str`Payments`}

- -
-
- -
-
-

{i18n.str`Latest transactions`}

- -
-
-
- ); -} diff --git a/packages/demobank-ui/src/pages/AccountPage/index.ts b/packages/demobank-ui/src/pages/AccountPage/index.ts new file mode 100644 index 000000000..9230fb6b1 --- /dev/null +++ b/packages/demobank-ui/src/pages/AccountPage/index.ts @@ -0,0 +1,92 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { HttpError, HttpResponseOk, HttpResponsePaginated, utils } from "@gnu-taler/web-util/browser"; +import { AbsoluteTime, AmountJson, PaytoUriIBAN, PaytoUriTalerBank } from "@gnu-taler/taler-util"; +import { Loading } from "../../components/Loading.js"; +import { useComponentState } from "./state.js"; +import { ReadyView, InvalidIbanView } from "./views.js"; +import { VNode } from "preact"; +import { LoginForm } from "../LoginForm.js"; +import { ErrorLoading } from "../../components/ErrorLoading.js"; + +export interface Props { + account: string; + onLoadNotOk: ( + error: HttpResponsePaginated, + ) => VNode; + goToBusinessAccount: () => void; + goToConfirmOperation: (id: string) => void; +} + +export type State = State.Loading | State.LoadingError | State.Ready | State.InvalidIban | State.UserNotFound; + +export namespace State { + export interface Loading { + status: "loading"; + error: undefined; + } + + export interface LoadingError { + status: "loading-error"; + error: HttpError; + } + + export interface BaseInfo { + error: undefined; + } + + export interface Ready extends BaseInfo { + status: "ready"; + error: undefined; + account: string, + limit: AmountJson, + goToBusinessAccount: () => void; + goToConfirmOperation: (id: string) => void; + } + + export interface InvalidIban { + status: "invalid-iban", + error: HttpResponseOk; + } + + export interface UserNotFound { + status: "error-user-not-found", + error: HttpError; + onRegister?: () => void; + } +} + +export interface Transaction { + negative: boolean; + counterpart: string; + when: AbsoluteTime; + amount: AmountJson | undefined; + subject: string; +} + +const viewMapping: utils.StateViewMap = { + loading: Loading, + "error-user-not-found": LoginForm, + "invalid-iban": InvalidIbanView, + "loading-error": ErrorLoading, + ready: ReadyView, +}; + +export const AccountPage = utils.compose( + (p: Props) => useComponentState(p), + viewMapping, +); diff --git a/packages/demobank-ui/src/pages/AccountPage/state.ts b/packages/demobank-ui/src/pages/AccountPage/state.ts new file mode 100644 index 000000000..ca7e1d447 --- /dev/null +++ b/packages/demobank-ui/src/pages/AccountPage/state.ts @@ -0,0 +1,92 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { Amounts, HttpStatusCode, parsePaytoUri } from "@gnu-taler/taler-util"; +import { ErrorType, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { useBackendContext } from "../../context/backend.js"; +import { useAccountDetails } from "../../hooks/access.js"; +import { Props, State } from "./index.js"; + +export function useComponentState({ account, goToBusinessAccount, goToConfirmOperation }: Props): State { + const result = useAccountDetails(account); + const backend = useBackendContext(); + const { i18n } = useTranslationContext(); + + if (result.loading) { + return { + status: "loading", + error: undefined, + }; + } + + if (!result.ok) { + if (result.loading || result.type === ErrorType.TIMEOUT) { + return { + status: "loading-error", + error: result, + }; + } + //logout if there is any error, not if loading + // backend.logOut(); + if (result.status === HttpStatusCode.NotFound) { + notifyError(i18n.str`Username or account label "${account}" not found`, undefined); + return { + status: "error-user-not-found", + error: result, + }; + } + if (result.status === HttpStatusCode.Unauthorized) { + notifyError(i18n.str`Authorization denied`, i18n.str`Maybe the session has expired, login again.`); + return { + status: "error-user-not-found", + error: result, + }; + } + return { + status: "loading-error", + error: result, + }; + } + + const { data } = result; + + const balance = Amounts.parseOrThrow(data.balance.amount); + + const debitThreshold = Amounts.parseOrThrow(data.debit_threshold); + const payto = parsePaytoUri(data.payto_uri); + + if (!payto || !payto.isKnown || (payto.targetType !== "iban" && payto.targetType !== "x-taler-bank")) { + return { + status: "invalid-iban", + error: result + }; + } + + const balanceIsDebit = data.balance.credit_debit_indicator == "debit"; + const limit = balanceIsDebit + ? Amounts.sub(debitThreshold, balance).amount + : Amounts.add(balance, debitThreshold).amount; + + + return { + status: "ready", + goToBusinessAccount, + goToConfirmOperation, + error: undefined, + account, + limit, + }; +} diff --git a/packages/demobank-ui/src/scss/_footer.scss b/packages/demobank-ui/src/pages/AccountPage/stories.tsx similarity index 76% rename from packages/demobank-ui/src/scss/_footer.scss rename to packages/demobank-ui/src/pages/AccountPage/stories.tsx index 112522ed8..f3828a5d6 100644 --- a/packages/demobank-ui/src/scss/_footer.scss +++ b/packages/demobank-ui/src/pages/AccountPage/stories.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + (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 @@ -19,17 +19,11 @@ * @author Sebastian Javier Marchano (sebasjm) */ -footer.footer { - .logo { - img { - width: auto; - height: $footer-logo-height; - } - } -} +import * as tests from "@gnu-taler/web-util/testing"; +import { ReadyView } from "./views.js"; -@include mobile { - .footer-copyright { - text-align: center; - } -} +export default { + title: "account page", +}; + +export const Ready = tests.createExample(ReadyView, {}); diff --git a/packages/demobank-ui/src/scss/_mixins.scss b/packages/demobank-ui/src/pages/AccountPage/test.ts similarity index 63% rename from packages/demobank-ui/src/scss/_mixins.scss rename to packages/demobank-ui/src/pages/AccountPage/test.ts index b52e590e3..588b84c35 100644 --- a/packages/demobank-ui/src/scss/_mixins.scss +++ b/packages/demobank-ui/src/pages/AccountPage/test.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + (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 @@ -19,16 +19,14 @@ * @author Sebastian Javier Marchano (sebasjm) */ -@mixin transition($t) { - transition: $t 250ms ease-in-out 50ms; -} +import * as tests from "@gnu-taler/web-util/testing"; +import { SwrMockEnvironment } from "@gnu-taler/web-util/testing"; +import { expect } from "chai"; +import { CASHOUT_API_EXAMPLE } from "../../endpoints.js"; +import { Props } from "./index.js"; +import { useComponentState } from "./state.js"; -@mixin icon-with-update-mark($icon-base-width) { - .icon { - width: $icon-base-width; - - &.has-update-mark:after { - right: ($icon-base-width / 2) - 0.85; - } - } -} +describe("Account states", () => { + it("should do some tests", async () => { + }); +}); diff --git a/packages/demobank-ui/src/pages/AccountPage/views.tsx b/packages/demobank-ui/src/pages/AccountPage/views.tsx new file mode 100644 index 000000000..483cb579a --- /dev/null +++ b/packages/demobank-ui/src/pages/AccountPage/views.tsx @@ -0,0 +1,93 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { Attention } from "../../components/Attention.js"; +import { Transactions } from "../../components/Transactions/index.js"; +import { useBusinessAccountDetails } from "../../hooks/circuit.js"; +import { useSettings } from "../../hooks/settings.js"; +import { PaymentOptions } from "../PaymentOptions.js"; +import { State } from "./index.js"; + +export function InvalidIbanView({ error }: State.InvalidIban) { + return ( +
Payto from server is not valid "{error.data.payto_uri}"
+ ); +} + +const IS_PUBLIC_ACCOUNT_ENABLED = false + +function ShowDemoInfo(): VNode { + const { i18n } = useTranslationContext(); + const [settings, updateSettings] = useSettings(); + if (!settings.showDemoDescription) return + return { + updateSettings("showDemoDescription", false); + }}> + {IS_PUBLIC_ACCOUNT_ENABLED ? ( + + This part of the demo shows how a bank that supports Taler + directly would work. In addition to using your own bank + account, you can also see the transaction history of some{" "} + Public Accounts. + + ) : ( + + This part of the demo shows how a bank that supports Taler + directly would work. + + )} + +} + +export function ReadyView({ account, limit, goToBusinessAccount, goToConfirmOperation }: State.Ready): VNode<{}> { + const { i18n } = useTranslationContext(); + + return + + + + + + + ; +} + +function MaybeBusinessButton({ + account, + onClick, +}: { + account: string; + onClick: () => void; +}): VNode { + const { i18n } = useTranslationContext(); + const result = useBusinessAccountDetails(account); + if (!result.ok) return ; + return ( +
+ +
+ ); +} diff --git a/packages/demobank-ui/src/pages/AdminPage.tsx b/packages/demobank-ui/src/pages/AdminPage.tsx deleted file mode 100644 index ce0feebce..000000000 --- a/packages/demobank-ui/src/pages/AdminPage.tsx +++ /dev/null @@ -1,1064 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -import { Amounts, HttpStatusCode, parsePaytoUri } from "@gnu-taler/taler-util"; -import { - ErrorType, - HttpResponsePaginated, - RequestError, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { Cashouts } from "../components/Cashouts/index.js"; -import { useBackendContext } from "../context/backend.js"; -import { useAccountDetails } from "../hooks/access.js"; -import { - useAdminAccountAPI, - useBusinessAccountDetails, - useBusinessAccounts, -} from "../hooks/circuit.js"; -import { - buildRequestErrorMessage, - PartialButDefined, - RecursivePartial, - undefinedIfEmpty, - validateIBAN, - WithIntermediate, -} from "../utils.js"; -import { ErrorBannerFloat } from "./BankFrame.js"; -import { ShowCashoutDetails } from "./BusinessAccount.js"; -import { handleNotOkResult } from "./HomePage.js"; -import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; -import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; -import { ErrorMessage, notifyInfo } from "../hooks/notification.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 { - onRegister: () => void; -} -/** - * Query account information and show QR code if there is pending withdrawal - */ -export function AdminPage({ onRegister }: Props): VNode { - const [account, setAccount] = useState(); - const [showDetails, setShowDetails] = useState(); - const [showCashouts, setShowCashouts] = useState(); - const [updatePassword, setUpdatePassword] = useState(); - const [removeAccount, setRemoveAccount] = useState(); - const [showCashoutDetails, setShowCashoutDetails] = useState< - string | undefined - >(); - - const [createAccount, setCreateAccount] = useState(false); - - const result = useBusinessAccounts({ account }); - const { i18n } = useTranslationContext(); - - if (result.loading) return
; - if (!result.ok) { - return handleNotOkResult(i18n, onRegister)(result); - } - - const { customers } = result.data; - - if (showCashoutDetails) { - return ( - { - setShowCashoutDetails(undefined); - }} - /> - ); - } - - if (showCashouts) { - return ( -
-
-

- Cashout for account {showCashouts} -

-
- { - setShowCashouts(id); - setShowCashouts(undefined); - }} - /> -

- { - e.preventDefault(); - setShowCashouts(undefined); - }} - /> -

-
- ); - } - - if (showDetails) { - return ( - { - setUpdatePassword(showDetails); - setShowDetails(undefined); - }} - onUpdateSuccess={() => { - notifyInfo(i18n.str`Account updated`); - setShowDetails(undefined); - }} - onClear={() => { - setShowDetails(undefined); - }} - /> - ); - } - if (removeAccount) { - return ( - { - notifyInfo(i18n.str`Account removed`); - setRemoveAccount(undefined); - }} - onClear={() => { - setRemoveAccount(undefined); - }} - /> - ); - } - if (updatePassword) { - return ( - { - notifyInfo(i18n.str`Password changed`); - setUpdatePassword(undefined); - }} - onClear={() => { - setUpdatePassword(undefined); - }} - /> - ); - } - if (createAccount) { - return ( - setCreateAccount(false)} - onCreateSuccess={(password) => { - notifyInfo( - i18n.str`Account created with password "${password}". The user must change the password on the next login.`, - ); - setCreateAccount(false); - }} - /> - ); - } - - return ( - -
-

- Admin panel -

-
- -

-

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

- - -
- {!customers.length ? ( -
- ) : ( -
-

{i18n.str`Accounts:`}

-
- - - - - - - - - - - {customers.map((item, idx) => { - const balance = !item.balance - ? undefined - : Amounts.parse(item.balance.amount); - const balanceIsDebit = - item.balance && - item.balance.credit_debit_indicator == "debit"; - return ( - - - - - - - ); - })} - -
{i18n.str`Username`}{i18n.str`Name`}{i18n.str`Balance`}{i18n.str`Actions`}
- { - e.preventDefault(); - setShowDetails(item.username); - }} - > - {item.username} - - {item.name} - {!balance ? ( - i18n.str`unknown` - ) : ( - - {balanceIsDebit ? - : null} - {`${Amounts.stringifyValue( - balance, - )}`} -   - {`${balance.currency}`} - - )} - - { - e.preventDefault(); - setUpdatePassword(item.username); - }} - > - change password - -   - { - e.preventDefault(); - setShowCashouts(item.username); - }} - > - cashouts - -   - { - e.preventDefault(); - setRemoveAccount(item.username); - }} - > - remove - -
-
-
- )} -
-
- ); -} - -function AdminAccount({ onRegister }: { onRegister: () => void }): VNode { - const { i18n } = useTranslationContext(); - const r = useBackendContext(); - const account = r.state.status === "loggedIn" ? r.state.username : "admin"; - const result = useAccountDetails(account); - - if (!result.ok) { - return handleNotOkResult(i18n, onRegister)(result); - } - const { data } = result; - const balance = Amounts.parseOrThrow(data.balance.amount); - const debitThreshold = Amounts.parseOrThrow(result.data.debitThreshold); - const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit"; - const limit = balanceIsDebit - ? Amounts.sub(debitThreshold, balance).amount - : Amounts.add(balance, debitThreshold).amount; - if (!balance) return ; - return ( - -
-
-

{i18n.str`Bank account balance`}

- {!balance ? ( -
- Waiting server response... -
- ) : ( -
- {balanceIsDebit ? - : null} - {`${Amounts.stringifyValue(balance)}`} -   - {`${balance.currency}`} -
- )} -
-
- { - notifyInfo(i18n.str`Wire transfer created!`); - }} - /> -
- ); -} - -const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/; -const EMAIL_REGEX = - /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; -const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/; - -function initializeFromTemplate( - account: SandboxBackend.Circuit.CircuitAccountData | undefined, -): WithIntermediate { - const emptyAccount = { - cashout_address: undefined, - iban: undefined, - name: undefined, - username: undefined, - contact_data: undefined, - }; - const emptyContact = { - email: undefined, - phone: undefined, - }; - - const initial: PartialButDefined = - structuredClone(account) ?? emptyAccount; - if (typeof initial.contact_data === "undefined") { - initial.contact_data = emptyContact; - } - initial.contact_data.email; - return initial as any; -} - -export function UpdateAccountPassword({ - account, - onClear, - onUpdateSuccess, - onLoadNotOk, -}: { - onLoadNotOk: ( - error: HttpResponsePaginated, - ) => VNode; - onClear: () => void; - onUpdateSuccess: () => void; - account: string; -}): VNode { - const { i18n } = useTranslationContext(); - const result = useBusinessAccountDetails(account); - const { changePassword } = useAdminAccountAPI(); - const [password, setPassword] = useState(); - const [repeat, setRepeat] = useState(); - const [error, saveError] = useState(); - - if (!result.ok) { - if (result.loading || result.type === ErrorType.TIMEOUT) { - return onLoadNotOk(result); - } - if (result.status === HttpStatusCode.NotFound) { - return
account not found
; - } - 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 ( -
-
-

- Update password for {account} -

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

-

-
- { - e.preventDefault(); - onClear(); - }} - /> -
-
- { - e.preventDefault(); - if (!!errors || !password) return; - try { - const r = await changePassword(account, { - new_password: password, - }); - onUpdateSuccess(); - } catch (error) { - if (error instanceof RequestError) { - saveError(buildRequestErrorMessage(i18n, error.cause)); - } else { - saveError({ - title: i18n.str`Operation failed, please report`, - description: - error instanceof Error - ? error.message - : JSON.stringify(error), - }); - } - } - }} - /> -
-
-

-
-
- ); -} - -function CreateNewAccount({ - onClose, - onCreateSuccess, -}: { - onClose: () => void; - onCreateSuccess: (password: string) => void; -}): VNode { - const { i18n } = useTranslationContext(); - const { createAccount } = useAdminAccountAPI(); - const [submitAccount, setSubmitAccount] = useState< - SandboxBackend.Circuit.CircuitAccountData | undefined - >(); - const [error, saveError] = useState(); - return ( -
-
-

- New account -

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

-

-
- { - e.preventDefault(); - onClose(); - }} - /> -
-
- { - e.preventDefault(); - - if (!submitAccount) return; - try { - const account: SandboxBackend.Circuit.CircuitAccountRequest = - { - cashout_address: submitAccount.cashout_address, - contact_data: submitAccount.contact_data, - internal_iban: submitAccount.iban, - name: submitAccount.name, - username: submitAccount.username, - password: randomPassword(), - }; - - await createAccount(account); - onCreateSuccess(account.password); - } catch (error) { - if (error instanceof RequestError) { - saveError( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.Forbidden - ? i18n.str`The rights to perform the operation are not sufficient` - : status === HttpStatusCode.BadRequest - ? i18n.str`Input data was invalid` - : status === HttpStatusCode.Conflict - ? i18n.str`At least one registration detail was not available` - : undefined, - }), - ); - } else { - saveError({ - title: i18n.str`Operation failed, please report`, - description: - error instanceof Error - ? error.message - : JSON.stringify(error), - }); - } - } - }} - /> -
-
-

-
-
- ); -} - -export function ShowAccountDetails({ - account, - onClear, - onUpdateSuccess, - onLoadNotOk, - onChangePassword, -}: { - onLoadNotOk: ( - error: HttpResponsePaginated, - ) => VNode; - onClear?: () => void; - onChangePassword: () => void; - onUpdateSuccess: () => void; - account: string; -}): VNode { - const { i18n } = useTranslationContext(); - const result = useBusinessAccountDetails(account); - const { updateAccount } = useAdminAccountAPI(); - const [update, setUpdate] = useState(false); - const [submitAccount, setSubmitAccount] = useState< - SandboxBackend.Circuit.CircuitAccountData | undefined - >(); - const [error, saveError] = useState(); - - if (!result.ok) { - if (result.loading || result.type === ErrorType.TIMEOUT) { - return onLoadNotOk(result); - } - if (result.status === HttpStatusCode.NotFound) { - return
account not found
; - } - return onLoadNotOk(result); - } - - return ( -
-
-

- Business account details -

-
- {error && ( - saveError(undefined)} /> - )} -
- setSubmitAccount(a)} - /> - -
-
- {onClear ? ( - { - e.preventDefault(); - onClear(); - }} - /> - ) : undefined} -
-
-
- { - e.preventDefault(); - onChangePassword(); - }} - /> -
-
- { - 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) { - if (error instanceof RequestError) { - saveError( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.Forbidden - ? i18n.str`The rights to change the account are not sufficient` - : status === HttpStatusCode.NotFound - ? i18n.str`The username was not found` - : undefined, - }), - ); - } else { - saveError({ - title: i18n.str`Operation failed, please report`, - description: - error instanceof Error - ? error.message - : JSON.stringify(error), - }); - } - } - } - }} - /> -
-
-
-

-
-
- ); -} - -function RemoveAccount({ - account, - onClear, - onUpdateSuccess, - onLoadNotOk, -}: { - onLoadNotOk: ( - error: HttpResponsePaginated, - ) => VNode; - onClear: () => void; - onUpdateSuccess: () => void; - account: string; -}): VNode { - const { i18n } = useTranslationContext(); - const result = useAccountDetails(account); - const { deleteAccount } = useAdminAccountAPI(); - const [error, saveError] = useState(); - - if (!result.ok) { - if (result.loading || result.type === ErrorType.TIMEOUT) { - return onLoadNotOk(result); - } - if (result.status === HttpStatusCode.NotFound) { - return
account not found
; - } - return onLoadNotOk(result); - } - - const balance = Amounts.parse(result.data.balance.amount); - if (!balance) { - return
there was an error reading the balance
; - } - const isBalanceEmpty = Amounts.isZero(balance); - return ( -
-
-

- Remove account: {account} -

-
- {!isBalanceEmpty && ( - saveError(undefined)} - /> - )} - {error && ( - saveError(undefined)} /> - )} - -

-

-
- { - e.preventDefault(); - onClear(); - }} - /> -
-
- { - e.preventDefault(); - try { - const r = await deleteAccount(account); - onUpdateSuccess(); - } catch (error) { - if (error instanceof RequestError) { - saveError( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.Forbidden - ? i18n.str`The administrator specified a institutional username` - : status === HttpStatusCode.NotFound - ? i18n.str`The username was not found` - : status === HttpStatusCode.PreconditionFailed - ? i18n.str`Balance was not zero` - : undefined, - }), - ); - } else { - saveError({ - title: i18n.str`Operation failed, please report`, - description: - error instanceof Error - ? error.message - : JSON.stringify(error), - }); - } - } - }} - /> -
-
-

-
- ); -} -/** - * 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< - RecursivePartial | undefined - >(undefined); - const { i18n } = useTranslationContext(); - - function updateForm(newForm: typeof initial): void { - const parsed = !newForm.cashout_address - ? undefined - : parsePaytoUri(newForm.cashout_address); - - const errors = undefinedIfEmpty>({ - cashout_address: !newForm.cashout_address - ? i18n.str`required` - : !parsed - ? i18n.str`does not follow the pattern` - : !parsed.isKnown || parsed.targetType !== "iban" - ? i18n.str`only "IBAN" target are supported` - : !IBAN_REGEX.test(parsed.iban) - ? i18n.str`IBAN should have just uppercased letters and numbers` - : validateIBAN(parsed.iban, i18n), - contact_data: undefinedIfEmpty({ - email: !newForm.contact_data?.email - ? i18n.str`required` - : !EMAIL_REGEX.test(newForm.contact_data.email) - ? i18n.str`it should be an email` - : undefined, - phone: !newForm.contact_data?.phone - ? i18n.str`required` - : !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 - ? undefined //optional field - : !IBAN_REGEX.test(newForm.iban) - ? i18n.str`IBAN should have just uppercased letters and numbers` - : validateIBAN(newForm.iban, i18n), - name: !newForm.name ? i18n.str`required` : undefined, - username: !newForm.username ? i18n.str`required` : undefined, - }); - setErrors(errors); - setForm(newForm); - onChange(errors === undefined ? (newForm as any) : undefined); - } - - return ( -
-
- - { - form.username = e.currentTarget.value; - updateForm(structuredClone(form)); - }} - />{" "} - -
-
- - { - form.name = e.currentTarget.value; - updateForm(structuredClone(form)); - }} - /> - -
- {purpose !== "create" && ( -
- - { - form.iban = e.currentTarget.value; - updateForm(structuredClone(form)); - }} - /> - -
- )} -
- - { - form.contact_data.email = e.currentTarget.value; - updateForm(structuredClone(form)); - }} - /> - -
-
- - { - form.contact_data.phone = e.currentTarget.value; - updateForm(structuredClone(form)); - }} - /> - -
-
- - { - form.cashout_address = "payto://iban/" + e.currentTarget.value; - updateForm(structuredClone(form)); - }} - /> - -
-
- ); -} diff --git a/packages/demobank-ui/src/pages/BankFrame.tsx b/packages/demobank-ui/src/pages/BankFrame.tsx index dc61f1302..6ab6ba3e4 100644 --- a/packages/demobank-ui/src/pages/BankFrame.tsx +++ b/packages/demobank-ui/src/pages/BankFrame.tsx @@ -14,283 +14,362 @@ GNU Taler; see the file COPYING. If not, see */ -import { Logger, TranslatedString } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { ComponentChildren, Fragment, h, VNode } from "preact"; -import { StateUpdater, useEffect, useState } from "preact/hooks"; -import talerLogo from "../assets/logo-white.svg"; -import { LangSelectorLikePy as LangSelector } from "../components/LangSelector.js"; +import { Amounts, Logger, TranslatedString, parsePaytoUri } from "@gnu-taler/taler-util"; +import { notifyError, notifyException, useNotifications, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { useEffect, useErrorBoundary, useState } from "preact/hooks"; +import logo from "../assets/logo-2021.svg"; +import { Attention } from "../components/Attention.js"; +import { CopyButton } from "../components/CopyButton.js"; +import { LangSelector } from "../components/LangSelector.js"; import { useBackendContext } from "../context/backend.js"; -import { useBusinessAccountDetails } from "../hooks/circuit.js"; -import { bankUiSettings } from "../settings.js"; +import { useAccountDetails } from "../hooks/access.js"; import { useSettings } from "../hooks/settings.js"; -import { ErrorMessage, onNotificationUpdate } from "../hooks/notification.js"; +import { bankUiSettings } from "../settings.js"; +import { RenderAmount } from "./PaytoWireTransferForm.js"; -const IS_PUBLIC_ACCOUNT_ENABLED = false; const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; const versionText = VERSION ? GIT_HASH - ? `Version ${VERSION} (${GIT_HASH.substring(0, 8)})` + ? + Version {VERSION} ({GIT_HASH.substring(0, 8)}) + : VERSION : ""; -const logger = new Logger("BankFrame"); - -function MaybeBusinessButton({ - account, - onClick, -}: { - account: string; - onClick: () => void; -}): VNode { - const { i18n } = useTranslationContext(); - const result = useBusinessAccountDetails(account); - if (!result.ok) return ; - return ( - { - e.preventDefault(); - onClick(); - }} - >{i18n.str`Business Profile`} - ); -} export function BankFrame({ children, - goToBusinessAccount, + account, }: { + account?: string, children: ComponentChildren; - goToBusinessAccount?: () => void; }): VNode { const { i18n } = useTranslationContext(); const backend = useBackendContext(); const [settings, updateSettings] = useSettings(); + const [open, setOpen] = useState(false) + + const [error, resetError] = useErrorBoundary(); + + useEffect(() => { + if (error) { + const desc = (error instanceof Error ? error.stack : String(error)) as TranslatedString + if (error instanceof Error) { + notifyException(i18n.str`Internal error, please report.`, error) + } else { + notifyError(i18n.str`Internal error, please report.`, String(error) as TranslatedString) + } + resetError() + } + }, [error]) const demo_sites = []; - for (const i in bankUiSettings.demoSites) - demo_sites.push( - - {bankUiSettings.demoSites[i][0]} - , - ); - - return ( - -
- -
-

- - {bankUiSettings.bankName} - -

- {maybeDemoContent( -

- {IS_PUBLIC_ACCOUNT_ENABLED ? ( - - This part of the demo shows how a bank that supports Taler - directly would work. In addition to using your own bank - account, you can also see the transaction history of some{" "} - Public Accounts. - - ) : ( - - This part of the demo shows how a bank that supports Taler - directly would work. - - )} -

, - )} -
-
- -
- - {children} -
- -
- ); -} - -function maybeDemoContent(content: VNode): VNode { - if (bankUiSettings.showDemoNav) { - return content; + if (bankUiSettings.demoSites) { + for (const i in bankUiSettings.demoSites) + demo_sites.push( + + {bankUiSettings.demoSites[i][0]} + , + ); } - return ; -} -export function ErrorBannerFloat({ - error, - onClear, -}: { - error: ErrorMessage; - onClear?: () => void; -}): VNode { - return ( -
- -
- ); -} + return (
+
+ + + {account && +
+
+
+

+
+

+
+
+
+ +
+ } +
+ + +
+
+
+ {children} +
+
+
+ +
+
+ ); } +function MaybeShowDebugInfo({ info }: { info: any }): VNode { + const [settings] = useSettings() + if (settings.showDebugInfo) { + return
+      {info}
+    
+ } + return +} + + +function StatusBanner(): VNode { + const notifs = useNotifications() + if (notifs.length === 0) return + return
{ + notifs.map(n => { + switch (n.message.type) { + case "error": + return { + n.remove() + }}> + {n.message.description && +
+ {n.message.description} +
+ } + + {/* + show debug info + + {n.message.debug && +
+ {n.message.debug} +
+ } */} +
+ case "info": + return { + n.remove(); + }} /> + } + })} +
+ +} + function TestingTag(): VNode { const testingUrl = localStorage.getItem("bank-base-url"); if (!testingUrl) return ; return ( - +

Testing with {testingUrl}{" "} stop testing - +

); } + +function Footer() { + const { i18n } = useTranslationContext() + return ( +
+
+
+

+ + Learn more about GNU Taler + +

+
+
+

+ Copyright © 2014—2023 Taler Systems SA. {versionText}{" "} + +

+
+
+ ); +} + +function WelcomeAccount({ account }: { account: string }): VNode { + const { i18n } = useTranslationContext(); + + const result = useAccountDetails(account); + if (!result.ok) return
+ + const payto = parsePaytoUri(result.data.payto_uri) + if (!payto) return
+ + const accountNumber = !payto.isKnown ? undefined : payto.targetType === "iban" ? payto.iban : payto.targetType === "x-taler-bank" ? payto.account : undefined; + return + Welcome, {account} {accountNumber !== undefined ? + + ({accountNumber} result.data.payto_uri} />) + + : }! + + +} + +function AccountBalance({ account }: { account: string }): VNode { + const result = useAccountDetails(account); + if (!result.ok) return
+ + return +} diff --git a/packages/demobank-ui/src/pages/HomePage.tsx b/packages/demobank-ui/src/pages/HomePage.tsx index 93a9bdfae..95144f086 100644 --- a/packages/demobank-ui/src/pages/HomePage.tsx +++ b/packages/demobank-ui/src/pages/HomePage.tsx @@ -17,6 +17,7 @@ import { HttpStatusCode, Logger, + TranslatedString, parseWithdrawUri, stringifyWithdrawUri, } from "@gnu-taler/taler-util"; @@ -24,18 +25,18 @@ import { ErrorType, HttpResponse, HttpResponsePaginated, + notify, + notifyError, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { Loading } from "../components/Loading.js"; -import { useBackendContext } from "../context/backend.js"; import { getInitialBackendBaseURL } from "../hooks/backend.js"; -import { notifyError, notifyInfo } from "../hooks/notification.js"; import { useSettings } from "../hooks/settings.js"; -import { AccountPage } from "./AccountPage.js"; -import { AdminPage } from "./AdminPage.js"; +import { AccountPage } from "./AccountPage/index.js"; import { LoginForm } from "./LoginForm.js"; import { WithdrawalQRCode } from "./WithdrawalQRCode.js"; +import { route } from "preact-router"; const logger = new Logger("AccountPage"); @@ -51,73 +52,66 @@ const logger = new Logger("AccountPage"); */ export function HomePage({ onRegister, - onPendingOperationFound, + account, + goToConfirmOperation, + goToBusinessAccount, }: { - onPendingOperationFound: (id: string) => void; + account: string, onRegister: () => void; + goToBusinessAccount: () => void; + goToConfirmOperation: (id: string) => void; }): VNode { - const backend = useBackendContext(); - const [settings] = useSettings(); const { i18n } = useTranslationContext(); - if (backend.state.status === "loggedOut") { - return ; - } - - if (settings.currentWithdrawalOperationId) { - onPendingOperationFound(settings.currentWithdrawalOperationId); - return ; - } - - if (backend.state.isUserAdministrator) { - return ; - } - return ( ); } export function WithdrawalOperationPage({ operationId, - onLoadNotOk, onContinue, }: { operationId: string; - onLoadNotOk: () => void; onContinue: () => void; }): VNode { //FIXME: libeufin sandbox should return show to create the integration api endpoint //or return withdrawal uri from response + const baseUrl = getInitialBackendBaseURL() const uri = stringifyWithdrawUri({ - bankIntegrationApiBaseUrl: `${getInitialBackendBaseURL()}/integration-api`, + bankIntegrationApiBaseUrl: `${baseUrl}/taler-integration`, withdrawalOperationId: operationId, }); const parsedUri = parseWithdrawUri(uri); const { i18n } = useTranslationContext(); + const [settings, updateSettings] = useSettings(); if (!parsedUri) { - notifyError({ - title: i18n.str`The Withdrawal URI is not valid: "${uri}"`, - }); + notifyError( + i18n.str`The Withdrawal URI is not valid`, + uri as TranslatedString + ); return ; } return ( { + updateSettings("currentWithdrawalOperationId", undefined) + onContinue() + }} /> ); } export function handleNotOkResult( i18n: ReturnType["i18n"], - onRegister?: () => void, ): ( result: | HttpResponsePaginated @@ -125,53 +119,53 @@ export function handleNotOkResult( ) => VNode { return function handleNotOkResult2( result: - | HttpResponsePaginated - | HttpResponse, + | HttpResponsePaginated + | HttpResponse, ): VNode { if (result.loading) return ; if (!result.ok) { switch (result.type) { case ErrorType.TIMEOUT: { - notifyError({ - title: i18n.str`Request timeout, try again later.`, - }); + notifyError(i18n.str`Request timeout, try again later.`, undefined); break; } case ErrorType.CLIENT: { if (result.status === HttpStatusCode.Unauthorized) { - notifyError({ - title: i18n.str`Wrong credentials`, - }); - return ; + notifyError(i18n.str`Wrong credentials`, undefined); + return ; } const errorData = result.payload; - notifyError({ - title: i18n.str`Could not load due to a client error`, - description: errorData.error.description, + notify({ + type: "error", + title: i18n.str`Could not load due to a request error`, + description: i18n.str`Request to url "${result.info.url}" returned ${result.info.status}`, debug: JSON.stringify(result), }); break; } case ErrorType.SERVER: { - notifyError({ + notify({ + type: "error", title: i18n.str`Server returned with error`, - description: result.payload.error.description, + description: result.payload?.error?.description as TranslatedString, debug: JSON.stringify(result.payload), }); break; } case ErrorType.UNREADABLE: { - notifyError({ + notify({ + type: "error", title: i18n.str`Unexpected error.`, - description: `Response from ${result.info?.url} is unreadable, http status: ${result.status}`, + description: i18n.str`Response from ${result.info?.url} is unreadable, http status: ${result.status}`, debug: JSON.stringify(result), }); break; } case ErrorType.UNEXPECTED: { - notifyError({ + notify({ + type: "error", title: i18n.str`Unexpected error.`, - description: `Diagnostic from ${result.info?.url} is "${result.message}"`, + description: i18n.str`Diagnostic from ${result.info?.url} is "${result.message}"`, debug: JSON.stringify(result), }); break; @@ -180,7 +174,7 @@ export function handleNotOkResult( assertUnreachable(result); } } - + // route("/") return
error
; } return
; diff --git a/packages/demobank-ui/src/pages/LoginForm.tsx b/packages/demobank-ui/src/pages/LoginForm.tsx index d2cb1bd8e..3ea94b899 100644 --- a/packages/demobank-ui/src/pages/LoginForm.tsx +++ b/packages/demobank-ui/src/pages/LoginForm.tsx @@ -14,199 +14,249 @@ GNU Taler; see the file COPYING. If not, see */ -import { HttpStatusCode } from "@gnu-taler/taler-util"; -import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util"; +import { ErrorType, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; +import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js"; import { useBackendContext } from "../context/backend.js"; -import { useCredentialsChecker } from "../hooks/backend.js"; -import { ErrorMessage } from "../hooks/notification.js"; +import { useCredentialsChecker } from "../hooks/useCredentialsChecker.js"; import { bankUiSettings } from "../settings.js"; import { undefinedIfEmpty } from "../utils.js"; -import { ErrorBannerFloat } from "./BankFrame.js"; -import { USERNAME_REGEX } from "./RegistrationPage.js"; -import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; +import { doAutoFocus } from "./PaytoWireTransferForm.js"; + /** * Collect and submit login data. */ export function LoginForm({ onRegister }: { onRegister?: () => void }): VNode { const backend = useBackendContext(); - const [username, setUsername] = useState(); + const currentUser = backend.state.status !== "loggedOut" ? backend.state.username : undefined + const [username, setUsername] = useState(currentUser); const [password, setPassword] = useState(); const { i18n } = useTranslationContext(); - const testLogin = useCredentialsChecker(); - const [error, saveError] = useState(); + const { requestNewLoginToken, refreshLoginToken } = useCredentialsChecker(); + + + /** + * Register form may be shown in the initialization step. + * If this is an error when usgin the app the registration + * callback is not set + */ + const isSessionExpired = !onRegister + + // useEffect(() => { + // if (backend.state.status === "loggedIn") { + // backend.expired() + // } + // },[]) const ref = useRef(null); useEffect(function focusInput() { + //FIXME: show invalidate session and allow relogin + if (isSessionExpired) { + localStorage.removeItem("backend-state"); + window.location.reload() + } ref.current?.focus(); }, []); + const [busy, setBusy] = useState>() const errors = undefinedIfEmpty({ username: !username ? i18n.str`Missing username` - : !USERNAME_REGEX.test(username) - ? i18n.str`Use letters and numbers only, and start with a lowercase letter` + // : !USERNAME_REGEX.test(username) + // ? i18n.str`Use letters and numbers only, and start with a lowercase letter` : undefined, password: !password ? i18n.str`Missing password` : undefined, - }); + }) ?? busy; + + function saveError({ title, description, debug }: { title: TranslatedString, description?: TranslatedString, debug?: any }) { + notifyError(title, description, debug) + } + + async function doLogout() { + backend.logOut() + } + + async function doLogin() { + if (!username || !password) return; + setBusy({}) + const result = await requestNewLoginToken(username, password); + if (result.valid) { + backend.logIn({ username, token: result.token }); + } else { + const { cause } = result; + switch (cause.type) { + case ErrorType.CLIENT: { + if (cause.status === HttpStatusCode.Unauthorized) { + saveError({ + title: i18n.str`Wrong credentials for "${username}"`, + }); + } else + if (cause.status === HttpStatusCode.NotFound) { + saveError({ + title: i18n.str`Account not found`, + }); + } else { + saveError({ + title: i18n.str`Could not load due to a request error`, + description: i18n.str`Request to url "${cause.info.url}" returned ${cause.info.status}`, + debug: JSON.stringify(cause.payload), + }); + } + break; + } + case ErrorType.SERVER: { + saveError({ + title: i18n.str`Server had a problem, try again later or report.`, + // description: cause.payload.error.description, + debug: JSON.stringify(cause.payload), + }); + break; + } + case ErrorType.TIMEOUT: { + saveError({ + title: i18n.str`Request timeout, try again later.`, + }); + break; + } + case ErrorType.UNREADABLE: { + saveError({ + title: i18n.str`Unexpected error.`, + description: `Response from ${cause.info?.url} is unreadable, http status: ${cause.status}` as TranslatedString, + debug: JSON.stringify(cause), + }); + break; + } + default: { + saveError({ + title: i18n.str`Unexpected error, please report.`, + description: `Diagnostic from ${cause.info?.url} is "${cause.message}"` as TranslatedString, + debug: JSON.stringify(cause), + }); + break; + } + } + // backend.logOut(); + } + setPassword(undefined); + setBusy(undefined) + } return ( - -

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

- {error && ( - saveError(undefined)} /> - )} - ); } diff --git a/packages/demobank-ui/src/pages/OperationState/index.ts b/packages/demobank-ui/src/pages/OperationState/index.ts new file mode 100644 index 000000000..b347fd942 --- /dev/null +++ b/packages/demobank-ui/src/pages/OperationState/index.ts @@ -0,0 +1,122 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { AbsoluteTime, AmountJson, WithdrawUriResult } from "@gnu-taler/taler-util"; +import { HttpError, utils } from "@gnu-taler/web-util/browser"; +import { ErrorLoading } from "../../components/ErrorLoading.js"; +import { Loading } from "../../components/Loading.js"; +import { useComponentState } from "./state.js"; +import { AbortedView, ConfirmedView, InvalidPaytoView, InvalidReserveView, InvalidWithdrawalView, NeedConfirmationView, ReadyView } from "./views.js"; + +export interface Props { + currency: string; + onClose: () => void; +} + +export type State = State.Loading | + State.LoadingError | + State.Ready | + State.Aborted | + State.Confirmed | + State.InvalidPayto | + State.InvalidWithdrawal | + State.InvalidReserve | + State.NeedConfirmation; + +export namespace State { + export interface Loading { + status: "loading"; + error: undefined; + } + + export interface LoadingError { + status: "loading-error"; + error: HttpError; + } + + /** + * Need to open the wallet + */ + export interface Ready { + status: "ready"; + error: undefined; + uri: WithdrawUriResult, + onClose: () => void; + onAbort: () => void; + } + + export interface InvalidPayto { + status: "invalid-payto", + error: undefined; + payto: string | null; + onClose: () => void; + } + export interface InvalidWithdrawal { + status: "invalid-withdrawal", + error: undefined; + onClose: () => void; + uri: string, + } + export interface InvalidReserve { + status: "invalid-reserve", + error: undefined; + onClose: () => void; + reserve: string | null; + } + export interface NeedConfirmation { + status: "need-confirmation", + onAbort: () => void; + onConfirm: () => void; + error: undefined; + busy: boolean, + } + export interface Aborted { + status: "aborted", + error: undefined; + onClose: () => void; + } + export interface Confirmed { + status: "confirmed", + error: undefined; + onClose: () => void; + } + +} + +export interface Transaction { + negative: boolean; + counterpart: string; + when: AbsoluteTime; + amount: AmountJson | undefined; + subject: string; +} + +const viewMapping: utils.StateViewMap = { + loading: Loading, + "invalid-payto": InvalidPaytoView, + "invalid-withdrawal": InvalidWithdrawalView, + "invalid-reserve": InvalidReserveView, + "need-confirmation": NeedConfirmationView, + "aborted": AbortedView, + "confirmed": ConfirmedView, + "loading-error": ErrorLoading, + ready: ReadyView, +}; + +export const OperationState = utils.compose( + (p: Props) => useComponentState(p), + viewMapping, +); diff --git a/packages/demobank-ui/src/pages/OperationState/state.ts b/packages/demobank-ui/src/pages/OperationState/state.ts new file mode 100644 index 000000000..4be680377 --- /dev/null +++ b/packages/demobank-ui/src/pages/OperationState/state.ts @@ -0,0 +1,265 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { Amounts, HttpStatusCode, TranslatedString, parsePaytoUri, parseWithdrawUri, stringifyWithdrawUri } from "@gnu-taler/taler-util"; +import { RequestError, notify, notifyError, notifyInfo, useTranslationContext, utils } from "@gnu-taler/web-util/browser"; +import { useEffect, useState } from "preact/hooks"; +import { useAccessAPI, useAccessAnonAPI, useWithdrawalDetails } from "../../hooks/access.js"; +import { getInitialBackendBaseURL } from "../../hooks/backend.js"; +import { useSettings } from "../../hooks/settings.js"; +import { buildRequestErrorMessage } from "../../utils.js"; +import { Props, State } from "./index.js"; + +export function useComponentState({ currency, onClose }: Props): utils.RecursiveState { + const { i18n } = useTranslationContext(); + const [settings, updateSettings] = useSettings() + const { createWithdrawal } = useAccessAPI(); + const { abortWithdrawal, confirmWithdrawal } = useAccessAnonAPI(); + const [busy, setBusy] = useState>() + + const amount = settings.maxWithdrawalAmount + + async function doSilentStart() { + //FIXME: if amount is not enough use balance + const parsedAmount = Amounts.parseOrThrow(`${currency}:${amount}`) + + try { + const result = await createWithdrawal({ + amount: Amounts.stringify(parsedAmount), + }); + const uri = parseWithdrawUri(result.data.taler_withdraw_uri); + if (!uri) { + return notifyError( + i18n.str`Server responded with an invalid withdraw URI`, + i18n.str`Withdraw URI: ${result.data.taler_withdraw_uri}`); + } else { + updateSettings("currentWithdrawalOperationId", uri.withdrawalOperationId) + } + } catch (error) { + if (error instanceof RequestError) { + notify( + buildRequestErrorMessage(i18n, error.cause, { + onClientError: (status) => + status === HttpStatusCode.Forbidden + ? i18n.str`The operation was rejected due to insufficient funds` + : undefined, + }), + ); + } else { + notifyError( + i18n.str`Operation failed, please report`, + (error instanceof Error + ? error.message + : JSON.stringify(error)) as TranslatedString + ) + } + } + } + + const withdrawalOperationId = settings.currentWithdrawalOperationId + useEffect(() => { + if (withdrawalOperationId === undefined) { + doSilentStart() + } + }, [settings.fastWithdrawal, amount]) + + const baseUrl = getInitialBackendBaseURL() + + if (!withdrawalOperationId) { + return { + status: "loading", + error: undefined + } + } + + const wid = withdrawalOperationId + + async function doAbort() { + try { + setBusy({}) + await abortWithdrawal(wid); + onClose(); + } catch (error) { + if (error instanceof RequestError) { + notify( + buildRequestErrorMessage(i18n, error.cause, { + onClientError: (status) => + status === HttpStatusCode.Conflict + ? i18n.str`The reserve operation has been confirmed previously and can't be aborted` + : undefined, + }), + ); + } else { + notifyError( + i18n.str`Operation failed, please report`, + (error instanceof Error + ? error.message + : JSON.stringify(error)) as TranslatedString + ) + } + } + setBusy(undefined) + } + + async function doConfirm() { + try { + setBusy({}) + await confirmWithdrawal(wid); + if (!settings.showWithdrawalSuccess) { + notifyInfo(i18n.str`Wire transfer completed!`) + } + } catch (error) { + if (error instanceof RequestError) { + notify( + buildRequestErrorMessage(i18n, error.cause, { + onClientError: (status) => + status === HttpStatusCode.Conflict + ? i18n.str`The withdrawal has been aborted previously and can't be confirmed` + : status === HttpStatusCode.UnprocessableEntity + ? i18n.str`The withdraw operation cannot be confirmed because no exchange and reserve public key selection happened before` + : undefined, + }), + ); + } else { + notifyError( + i18n.str`Operation failed, please report`, + (error instanceof Error + ? error.message + : JSON.stringify(error)) as TranslatedString + ) + } + } + setBusy(undefined) + } + const bankIntegrationApiBaseUrl = `${baseUrl}/taler-integration` + const uri = stringifyWithdrawUri({ + bankIntegrationApiBaseUrl, + withdrawalOperationId, + }); + const parsedUri = parseWithdrawUri(uri); + if (!parsedUri) { + return { + status: "invalid-withdrawal", + error: undefined, + uri, + onClose, + } + } + + return (): utils.RecursiveState => { + const result = useWithdrawalDetails(withdrawalOperationId); + const shouldCreateNewOperation = !result.ok && !result.loading && result.info.status === HttpStatusCode.NotFound + + useEffect(() => { + if (shouldCreateNewOperation) { + doSilentStart() + } + }, []) + if (!result.ok) { + if (result.loading) { + return { + status: "loading", + error: undefined + } + } + if (result.info.status === HttpStatusCode.NotFound) { + return { + status: "loading", + error: undefined, + } + } + return { + status: "loading-error", + error: result + } + } + const { data } = result; + if (data.aborted) { + return { + status: "aborted", + error: undefined, + onClose: async () => { + updateSettings("currentWithdrawalOperationId", undefined) + onClose() + }, + } + } + + if (data.confirmation_done) { + if (!settings.showWithdrawalSuccess) { + updateSettings("currentWithdrawalOperationId", undefined) + onClose() + } + return { + status: "confirmed", + error: undefined, + onClose: async () => { + updateSettings("currentWithdrawalOperationId", undefined) + onClose() + }, + } + } + + if (!data.selection_done) { + return { + status: "ready", + error: undefined, + uri: parsedUri, + onClose: async () => { + await doAbort() + updateSettings("currentWithdrawalOperationId", undefined) + onClose() + }, + onAbort: doAbort, + } + } + + if (!data.selected_reserve_pub) { + return { + status: "invalid-reserve", + error: undefined, + reserve: data.selected_reserve_pub, + onClose, + } + } + + const account = !data.selected_exchange_account ? undefined : parsePaytoUri(data.selected_exchange_account) + + if (!account) { + return { + status: "invalid-payto", + error: undefined, + payto: data.selected_exchange_account, + onClose, + } + } + + + // goToConfirmOperation(withdrawalOperationId) + return { + status: "need-confirmation", + error: undefined, + onAbort: async () => { + await doAbort() + updateSettings("currentWithdrawalOperationId", undefined) + onClose() + }, + busy: !!busy, + onConfirm: doConfirm + } + } + +} diff --git a/packages/demobank-ui/src/scss/_tiles.scss b/packages/demobank-ui/src/pages/OperationState/stories.tsx similarity index 75% rename from packages/demobank-ui/src/scss/_tiles.scss rename to packages/demobank-ui/src/pages/OperationState/stories.tsx index e69d995f0..03917a8fb 100644 --- a/packages/demobank-ui/src/scss/_tiles.scss +++ b/packages/demobank-ui/src/pages/OperationState/stories.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + (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 @@ -19,6 +19,11 @@ * @author Sebastian Javier Marchano (sebasjm) */ -.is-tiles-wrapper { - margin-bottom: $default-padding; -} +import * as tests from "@gnu-taler/web-util/testing"; +import { ReadyView } from "./views.js"; + +export default { + title: "operation status page", +}; + +export const Ready = tests.createExample(ReadyView, {}); diff --git a/packages/demobank-ui/src/scss/_modal.scss b/packages/demobank-ui/src/pages/OperationState/test.ts similarity index 62% rename from packages/demobank-ui/src/scss/_modal.scss rename to packages/demobank-ui/src/pages/OperationState/test.ts index b3a31ebf1..f4d6cf4b2 100644 --- a/packages/demobank-ui/src/scss/_modal.scss +++ b/packages/demobank-ui/src/pages/OperationState/test.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + (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 @@ -19,17 +19,14 @@ * @author Sebastian Javier Marchano (sebasjm) */ -.modal-card { - width: $modal-card-width; -} +import * as tests from "@gnu-taler/web-util/testing"; +import { SwrMockEnvironment } from "@gnu-taler/web-util/testing"; +import { expect } from "chai"; +import { CASHOUT_API_EXAMPLE } from "../../endpoints.js"; +import { Props } from "./index.js"; +import { useComponentState } from "./state.js"; -.modal-card-foot { - background-color: $modal-card-foot-background-color; -} - -@include mobile { - .modal .animation-content .modal-card { - width: $modal-card-width-mobile; - margin: 0 auto; - } -} +describe("Withdrawal operation states", () => { + it("should do some tests", async () => { + }); +}); diff --git a/packages/demobank-ui/src/pages/OperationState/views.tsx b/packages/demobank-ui/src/pages/OperationState/views.tsx new file mode 100644 index 000000000..2cb7385db --- /dev/null +++ b/packages/demobank-ui/src/pages/OperationState/views.tsx @@ -0,0 +1,376 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { stringifyWithdrawUri } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useEffect, useMemo, useState } from "preact/hooks"; +import { QR } from "../../components/QR.js"; +import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js"; +import { useSettings } from "../../hooks/settings.js"; +import { undefinedIfEmpty } from "../../utils.js"; +import { State } from "./index.js"; + +export function InvalidPaytoView({ payto, onClose }: State.InvalidPayto) { + return ( +
Payto from server is not valid "{payto}"
+ ); +} +export function InvalidWithdrawalView({ uri, onClose }: State.InvalidWithdrawal) { + return ( +
Withdrawal uri from server is not valid "{uri}"
+ ); +} +export function InvalidReserveView({ reserve, onClose }: State.InvalidReserve) { + return ( +
Reserve from server is not valid "{reserve}"
+ ); +} + +export function NeedConfirmationView({ error, onAbort, onConfirm, busy }: State.NeedConfirmation) { + const { i18n } = useTranslationContext() + + const captchaNumbers = useMemo(() => { + return { + a: Math.floor(Math.random() * 10), + b: Math.floor(Math.random() * 10), + }; + }, []); + const [captchaAnswer, setCaptchaAnswer] = useState(); + const answer = parseInt(captchaAnswer ?? "", 10); + const errors = undefinedIfEmpty({ + answer: !captchaAnswer + ? i18n.str`Answer the question before continue` + : Number.isNaN(answer) + ? i18n.str`The answer should be a number` + : answer !== captchaNumbers.a + captchaNumbers.b + ? i18n.str`The answer "${answer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.` + : undefined, + }) ?? (busy ? {} as Record : undefined); + + return ( +
+
+

+ Confirm the withdrawal operation +

+
+
+ + + + + + + +
+
+
+ +
{ + e.preventDefault() + }} + > +
+ +
+
+ { + setCaptchaAnswer(e.currentTarget.value) + }} + /> +
+ +
+
+
+ + +
+ +
+
+
+ {/*
+
+

Wire transfer details

+
+
+
+ {((): VNode => { + switch (details.account.targetType) { + case "iban": { + const p = details.account as PaytoUriIBAN + const name = p.params["receiver-name"] + return +
+
Exchange account
+
{p.iban}
+
+ {name && +
+
Exchange name
+
{p.params["receiver-name"]}
+
+ } +
+ } + case "x-taler-bank": { + const p = details.account as PaytoUriTalerBank + const name = p.params["receiver-name"] + return +
+
Exchange account
+
{p.account}
+
+ {name && +
+
Exchange name
+
{p.params["receiver-name"]}
+
+ } +
+ } + default: + return
+
Exchange account
+
{details.account.targetPath}
+
+ + } + })()} +
+
Withdrawal identification
+
{details.reserve}
+
+
+
Amount
+
To be added
+ // {/* Amounts.stringifyValue(details.amount) +
+
+
+
*/} + +
+
+
+ + ); +} +export function AbortedView({ error, onClose }: State.Aborted) { + return ( +
aborted
+ ); +} + +export function ConfirmedView({ error, onClose }: State.Confirmed) { + const { i18n } = useTranslationContext(); + const [settings, updateSettings] = useSettings() + return ( + + +
+ +
+ +
+
+ +
+

+ + The wire transfer to the Taler operator has been initiated. You will soon receive the requested amount in your Taler wallet. + +

+
+
+
+
+
+ + + Do not show this again + + + +
+
+
+ +
+
+ + ); +} + +export function ReadyView({ uri, onClose }: State.Ready): VNode<{}> { + const { i18n } = useTranslationContext(); + + useEffect(() => { + //Taler Wallet WebExtension is listening to headers response and tab updates. + //In the SPA there is no header response with the Taler URI so + //this hack manually triggers the tab update after the QR is in the DOM. + // WebExtension will be using + // https://developer.chrome.com/docs/extensions/reference/tabs/#event-onUpdated + document.title = `${document.title} ${uri.withdrawalOperationId}`; + }, []); + const talerWithdrawUri = stringifyWithdrawUri(uri); + return +
+ +
+ +
+
+

+ On this device +

+
+
+

+ If you are using a desktop browser you can open the popup now or click the link if you have the "Inject Taler support" option enabled. +

+
+ +
+
+
+
+
+

+ On a mobile phone +

+
+
+

+ Scan the QR code with your mobile device. +

+
+
+
+ +
+
+
+ +
+ +} diff --git a/packages/demobank-ui/src/pages/PaymentOptions.tsx b/packages/demobank-ui/src/pages/PaymentOptions.tsx index 3552da7b4..f60ba3270 100644 --- a/packages/demobank-ui/src/pages/PaymentOptions.tsx +++ b/packages/demobank-ui/src/pages/PaymentOptions.tsx @@ -15,10 +15,9 @@ */ import { AmountJson } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { notifyInfo } from "../hooks/notification.js"; import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; import { WalletWithdrawForm } from "./WalletWithdrawForm.js"; import { useSettings } from "../hooks/settings.js"; @@ -27,60 +26,97 @@ import { useSettings } from "../hooks/settings.js"; * Let the user choose a payment option, * then specify the details trigger the action. */ -export function PaymentOptions({ limit }: { limit: AmountJson }): VNode { +export function PaymentOptions({ limit, goToConfirmOperation }: { limit: AmountJson, goToConfirmOperation: (id: string) => void }): VNode { const { i18n } = useTranslationContext(); - const [settings, updateSettings] = useSettings(); + const [settings] = useSettings(); - const [tab, setTab] = useState<"charge-wallet" | "wire-transfer">( - "charge-wallet", - ); + const [tab, setTab] = useState<"charge-wallet" | "wire-transfer" | undefined>(); return ( -
-
-
- - +
+ +
+ + Send money to + + +
+ {/* */} + + + +
{tab === "charge-wallet" && ( -
-

{i18n.str`Obtain digital cash`}

- { - updateSettings("currentWithdrawalOperationId", id); - }} - /> -
+ { + setTab(undefined) + }} + /> )} {tab === "wire-transfer" && ( -
-

{i18n.str`Transfer to bank account`}

- { - notifyInfo(i18n.str`Wire transfer created!`); - }} - /> -
+ { + notifyInfo(i18n.str`Wire transfer created!`); + setTab(undefined) + }} + onCancel={() => { + setTab(undefined) + }} + /> )} -
-
- ); + + +
+ ) } diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx index d8c1644b1..52dbd4ff6 100644 --- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx +++ b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx @@ -17,42 +17,51 @@ import { AmountJson, Amounts, - buildPayto, HttpStatusCode, Logger, + TranslatedString, + buildPayto, parsePaytoUri, - stringifyPaytoUri, + stringifyPaytoUri } from "@gnu-taler/taler-util"; import { RequestError, + notify, + notifyError, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; +import { h, VNode, Fragment, Ref } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; -import { notifyError } from "../hooks/notification.js"; +import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js"; import { useAccessAPI } from "../hooks/access.js"; import { buildRequestErrorMessage, undefinedIfEmpty, validateIBAN, } from "../utils.js"; -import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; +import { useConfigState } from "../hooks/config.js"; +import { useConfigContext } from "../context/config.js"; const logger = new Logger("PaytoWireTransferForm"); export function PaytoWireTransferForm({ focus, + title, onSuccess, + onCancel, limit, }: { + title: TranslatedString, focus?: boolean; onSuccess: () => void; + onCancel: (() => void) | undefined; limit: AmountJson; }): VNode { const [isRawPayto, setIsRawPayto] = useState(false); - const [iban, setIban] = useState(undefined); - const [subject, setSubject] = useState(undefined); - const [amount, setAmount] = useState(undefined); + // FIXME: remove this + const [iban, setIban] = useState(); + const [subject, setSubject] = useState(); + const [amount, setAmount] = useState(); const [rawPaytoInput, rawPaytoInputSetter] = useState( undefined, @@ -70,295 +79,372 @@ export function PaytoWireTransferForm({ const errorsWire = undefinedIfEmpty({ iban: !iban - ? i18n.str`Missing IBAN` + ? i18n.str`required` : !IBAN_REGEX.test(iban) - ? i18n.str`IBAN should have just uppercased letters and numbers` - : validateIBAN(iban, i18n), - subject: !subject ? i18n.str`Missing subject` : undefined, + ? i18n.str`IBAN should have just uppercased letters and numbers` + : validateIBAN(iban, i18n), + subject: !subject ? i18n.str`required` : undefined, amount: !trimmedAmountStr - ? i18n.str`Missing amount` + ? i18n.str`required` : !parsedAmount - ? i18n.str`Amount is not valid` - : Amounts.isZero(parsedAmount) - ? i18n.str`Should be greater than 0` - : Amounts.cmp(limit, parsedAmount) === -1 - ? i18n.str`balance is not enough` - : undefined, + ? i18n.str`not valid` + : Amounts.isZero(parsedAmount) + ? i18n.str`should be greater than 0` + : Amounts.cmp(limit, parsedAmount) === -1 + ? i18n.str`balance is not enough` + : undefined, }); const { createTransaction } = useAccessAPI(); - if (!isRawPayto) - return ( -
-
{ - e.preventDefault(); - }} - autoCapitalize="none" - autoCorrect="off" - > -   - { - setIban(e.currentTarget.value); - }} - /> - -   - { - setSubject(e.currentTarget.value); - }} - /> - -   -
- - { - setAmount(e.currentTarget.value); - }} - /> -
- -

- { - e.preventDefault(); - if (!(iban && subject && amount)) { - return; - } - const ibanPayto = buildPayto("iban", iban, undefined); - ibanPayto.params.message = encodeURIComponent(subject); - const paytoUri = stringifyPaytoUri(ibanPayto); - - try { - await createTransaction({ - paytoUri, - amount: `${limit.currency}:${amount}`, - }); - onSuccess(); - setAmount(undefined); - setIban(undefined); - setSubject(undefined); - } catch (error) { - if (error instanceof RequestError) { - notifyError( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.BadRequest - ? i18n.str`The request was invalid or the payto://-URI used unacceptable features.` - : undefined, - }), - ); - } else { - notifyError({ - title: i18n.str`Operation failed, please report`, - description: - error instanceof Error - ? error.message - : JSON.stringify(error), - }); - } - } - }} - /> - { - e.preventDefault(); - setAmount(undefined); - setIban(undefined); - setSubject(undefined); - }} - /> -

- -

- { - setIsRawPayto(true); - e.preventDefault(); - }} - > - {i18n.str`Want to try the raw payto://-format?`} - -

-
- ); - const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput); const errorsPayto = undefinedIfEmpty({ rawPaytoInput: !rawPaytoInput ? 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` - : validateIBAN(parsed.iban, i18n), + ? i18n.str`does not follow the pattern` + : !parsed.isKnown || parsed.targetType !== "iban" + ? i18n.str`only "IBAN" target are supported` + : !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` + : !IBAN_REGEX.test(parsed.iban) + ? i18n.str`IBAN should have just uppercased letters and numbers` + : validateIBAN(parsed.iban, i18n), }); - return ( -
-

{i18n.str`Transfer money to account identified by payto:// URI:`}

-
{ - e.preventDefault(); - }} - autoCapitalize="none" - autoCorrect="off" - > -

-   - { - rawPaytoInputSetter(e.currentTarget.value); - }} - /> - -
-

- Hint: - - payto://iban/[receiver-iban]?message=[subject]&amount=[ - {limit.currency} - :X.Y] - -
-

-

- { - if (!rawPaytoInput) { - logger.error("Didn't get any raw Payto string!"); - return; - } + async function doSend() { + let payto_uri: string | undefined; - try { - await createTransaction({ - paytoUri: rawPaytoInput, - }); - onSuccess(); - rawPaytoInputSetter(undefined); - } catch (error) { - if (error instanceof RequestError) { - notifyError( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.BadRequest - ? i18n.str`The request was invalid or the payto://-URI used unacceptable features.` - : undefined, - }), - ); - } else { - notifyError({ - title: i18n.str`Operation failed, please report`, - description: - error instanceof Error - ? error.message - : JSON.stringify(error), - }); + if (rawPaytoInput) { + payto_uri = rawPaytoInput + } else { + if (!iban || !subject) return; + const ibanPayto = buildPayto("iban", iban, undefined); + ibanPayto.params.message = encodeURIComponent(subject); + payto_uri = stringifyPaytoUri(ibanPayto); + } + + try { + await createTransaction({ + payto_uri, + amount: `${limit.currency}:${amount}`, + }); + onSuccess(); + setAmount(undefined); + setIban(undefined); + setSubject(undefined); + rawPaytoInputSetter(undefined) + } catch (error) { + if (error instanceof RequestError) { + notify( + buildRequestErrorMessage(i18n, error.cause, { + onClientError: (status) => + status === HttpStatusCode.BadRequest + ? i18n.str`The request was invalid or the payto://-URI used unacceptable features.` + : undefined, + }), + ); + } else { + notifyError( + i18n.str`Operation failed, please report`, + (error instanceof Error + ? error.message + : JSON.stringify(error)) as TranslatedString + ) + } + } + + } + + return (

+ {/** + * FIXME: Scan a qr code + */} +
+

+ {title} +

+
+
+
+
+
+ + { + e.preventDefault() + }} + > +
+ {!isRawPayto ? +
+ +
+ +
+ { + setIban(e.currentTarget.value.toUpperCase()); + }} + /> + +
+

+ IBAN of the recipient's account +

+
+ +
+ +
+ { + setSubject(e.currentTarget.value); + }} + /> + +
+

some text to identify the transfer

+
+ +
+ + { + setAmount(d) + }} + /> + +

amount to transfer

+
+ +
: +
+
+ +
+