Compare commits

...

68 Commits

Author SHA1 Message Date
ffcb40b464
Merge branch 'master' into age-withdraw 2023-10-06 16:33:23 +02:00
fe7b51ef27
Merge branch 'master' into age-withdraw 2023-10-06 16:33:05 +02:00
Sebastian
101f62123a
fix 2023-10-06 11:05:54 -03:00
Sebastian
98013322db
backoffice ui 2023-10-06 10:38:23 -03:00
97d7be7503
rename to corebank API 2023-10-06 14:42:32 +02:00
Sebastian
851b2da39c
fixing issues reported by Christian, wip 2023-10-04 14:36:03 -03:00
Sebastian
535b990215
currency name up to 11 fractions up to 8 2023-10-04 13:41:40 -03:00
Sebastian
26e77181d8
icon link 2023-10-03 13:51:58 -03:00
Sebastian
e84d2b6175
more ui, removed some testing values 2023-10-03 10:11:33 -03:00
8e70b89593
wallet-core: currency hint for preset exchanges 2023-10-02 23:24:06 +02:00
671bbf2954
wallet-core: implement explicit updateExchangeEntry request 2023-10-02 22:48:44 +02:00
Sebastian
e54df1f167
match the exchange spec 2023-10-02 13:53:32 -03:00
Sebastian
372ddff917
render amount and limit input 2023-10-01 12:50:43 -03:00
Sebastian
1708d49a2d
more ui 2023-09-29 16:02:15 -03:00
Sebastian
c10f3f3ade
notifications exposed 2023-09-29 16:01:59 -03:00
Sebastian
779ddae8b8
iban country code should be always uppercased 2023-09-29 14:46:29 -03:00
552155c826
-symlinks 2023-09-28 18:42:14 +02:00
467f968695
build system: support DESTDIR 2023-09-28 17:50:53 +02:00
61424e2cb5
bump version 2023-09-28 12:30:47 +02:00
256e86fdc0
add empty migration from previous database version 2023-09-28 12:30:47 +02:00
Sebastian
cdb7d78f22
missing multiplier 2023-09-27 11:31:58 -03:00
Sebastian
649d704693
login time 2023-09-27 11:15:36 -03:00
Sebastian
1e4f21cc76
lang selector and fix logout 2023-09-26 15:18:43 -03:00
Sebastian
dcdf8fb6a0
logout 2023-09-26 07:45:45 -03:00
Sebastian
6024d0125e
uri snake case 2023-09-25 15:01:39 -03:00
Sebastian
ea0738ccd5
better /config error 2023-09-25 14:50:46 -03:00
Sebastian
820f953b96
check config number 2023-09-25 14:50:46 -03:00
Sebastian
4041a76a58
more ui: pagination 2023-09-25 14:50:45 -03:00
Sebastian
0b2c03dc5e
new libeufin api 2023-09-25 14:50:45 -03:00
Sebastian
fd9ed97fdc
do not reuse the same map instance 2023-09-25 14:50:45 -03:00
Sebastian
ae49194d42
more ui 2023-09-25 14:50:44 -03:00
Sebastian
15af6c619d
towards new core bank api 2023-09-25 14:50:44 -03:00
Sebastian
5640f0a67d
default to content type json 2023-09-25 14:50:43 -03:00
Sebastian
a59df74fb2
more ui 2023-09-25 14:50:43 -03:00
Sebastian
dfd23f63ba
more ui 2023-09-25 14:50:43 -03:00
Sebastian
56a6f47c7d
more ui 2023-09-25 14:50:42 -03:00
Sebastian
4faa037c20
tx group by date 2023-09-25 14:50:42 -03:00
Sebastian
af623f5096
preparing for the new token api 2023-09-25 14:50:41 -03:00
Sebastian
0b7bbed99d
more ui: business and admin 2023-09-25 14:50:41 -03:00
Sebastian
062939d9cc
admin refactor 2023-09-25 14:50:41 -03:00
Sebastian
b3c747151b
more ui 2023-09-25 14:50:40 -03:00
Sebastian
7d4c5a71aa
more ui 2023-09-25 14:50:40 -03:00
Sebastian
e39d5c488e
more ui 2023-09-25 14:50:39 -03:00
Sebastian
fdbe623e10
more ui stuff, moved forms to util 2023-09-25 14:50:39 -03:00
Sebastian
a5406c5a5d
some ui 2023-09-25 14:50:38 -03:00
e628ca1af8
change demo/test bank URL 2023-09-25 19:46:47 +02:00
361d92fe31
-libeufin config 2023-09-24 22:46:46 +02:00
7b93938e71
harness: add libeufin-bank integration test 2023-09-24 21:03:22 +02:00
bdd906c887
adapt to corebank API change, minor refactoring 2023-09-24 13:01:42 +02:00
6b63ecc49e
-fix botched Balance->WalletBalance rename 2023-09-21 19:43:59 +02:00
a99156ed22
wallet-core,harness: remove separate fakebank withdrawal API 2023-09-21 18:02:36 +02:00
58debefbe0
wallet-core,harness: towards corebank API instead of fakebank/nexus API 2023-09-21 17:56:29 +02:00
Sebastian
0388d31d36
account page 2023-09-19 00:39:00 -03:00
40d2aa0c11
cli: allow DB stats tracking via environment variable 2023-09-15 17:14:37 +02:00
0ff189d229
wallet-core: fix tipping 2023-09-15 17:04:44 +02:00
a15eec55d3
wallet-core: correctly consider deposit fee in p2p coin selection 2023-09-15 16:45:12 +02:00
de117e375a
wallet-core: make planchets.byGroupAndIndex unique 2023-09-15 13:35:47 +02:00
5de329e653
wallet-core: fix type error in purse_expiration 2023-09-15 12:51:57 +02:00
1d9d63b341
taler-util: fix time conversion 2023-09-15 12:02:11 +02:00
c919c30ef3
-formatting, don't use deprecated method 2023-09-14 20:58:40 +02:00
93e0f26b43
-remove unused record 2023-09-14 19:18:01 +02:00
1ce53e1c21
wallet-core: consistently use usec timestamps in DB 2023-09-14 19:18:01 +02:00
f4587c44fd
wallet-core: use typed microsecond timestamps in DB 2023-09-14 19:18:01 +02:00
Sebastian
59ef010b0e
update testing 2023-09-14 13:10:21 -03:00
Sebastian
b7afefb715
do, instead of show a message 2023-09-14 12:16:48 -03:00
Sebastian
1653130de8
update how access token management is handled 2023-09-14 12:14:21 -03:00
Sebastian
dd25740c91
update to the new tos state 2023-09-14 11:10:37 -03:00
Sebastian
ee48a39eb3
merchant payment 2023-09-14 10:56:34 -03:00
292 changed files with 12699 additions and 26961 deletions

View File

@ -9,6 +9,11 @@ git-archive-all = ./build-system/taler-build-scripts/archive-with-submodules/git
include .config.mk 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 .PHONY: compile
compile: compile:
pnpm install -r --frozen-lockfile pnpm install -r --frozen-lockfile
@ -121,18 +126,18 @@ lint:
install: install:
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm run compile pnpm run compile
make -C packages/taler-wallet-cli TOPLEVEL=yes install-nodeps $(MAKE) -C packages/taler-wallet-cli install-nodeps
make -C packages/anastasis-cli TOPLEVEL=yes install-nodeps $(MAKE) -C packages/anastasis-cli install-nodeps
make -C packages/taler-harness TOPLEVEL=yes install-nodeps $(MAKE) -C packages/taler-harness install-nodeps
make -C packages/demobank-ui TOPLEVEL=yes install-nodeps $(MAKE) -C packages/demobank-ui install-nodeps
make -C packages/merchant-backoffice-ui TOPLEVEL=yes install-nodeps $(MAKE) -C packages/merchant-backoffice-ui install-nodeps
make -C packages/aml-backoffice-ui TOPLEVEL=yes install-nodeps $(MAKE) -C packages/aml-backoffice-ui install-nodeps
.PHONY: install-tools .PHONY: install-tools
# Install taler-wallet-cli, anastasis-cli and taler-harness # Install taler-wallet-cli, anastasis-cli and taler-harness
install-tools: install-tools:
pnpm install --frozen-lockfile --filter @gnu-taler/taler-wallet-cli... --filter @gnu-taler/anastasis-cli... --filter @gnu-taler/taler-harness... 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 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/taler-wallet-cli install-nodeps
make -C packages/anastasis-cli TOPLEVEL=yes install-nodeps $(MAKE) -C packages/anastasis-cli install-nodeps
make -C packages/taler-harness TOPLEVEL=yes install-nodeps $(MAKE) -C packages/taler-harness install-nodeps

View File

@ -1,6 +1,6 @@
#!/bin/bash #!/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 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/ cp packages/merchant-backend-ui/dist/pages/$file prebuilt/backend/

View File

@ -1,6 +1,6 @@
#!/bin/bash #!/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 find packages/merchant-backoffice-ui/dist/prod/ -type f -printf '%P\n' | sort > prebuilt/backoffice/bof

View File

@ -1,6 +1,6 @@
#!/bin/bash #!/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 find packages/demobank-ui/dist/prod/ -type f -printf '%P\n' | sort > prebuilt/demobank/bof

View File

@ -3,6 +3,7 @@
ifeq ($(TOPLEVEL), yes) ifeq ($(TOPLEVEL), yes)
$(info top-level build) $(info top-level build)
-include ../../.config.mk -include ../../.config.mk
override DESTDIR := $(TOP_DESTDIR)
else else
$(info package-level build) $(info package-level build)
-include ../../.config.mk -include ../../.config.mk
@ -15,7 +16,7 @@ $(info prefix is $(prefix))
all: all:
@echo run \'make install\' to install @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 .PHONY: install-nodeps
install-nodeps: install-nodeps:

View File

@ -3,6 +3,7 @@
ifeq ($(TOPLEVEL), yes) ifeq ($(TOPLEVEL), yes)
$(info top-level build) $(info top-level build)
-include ../../.config.mk -include ../../.config.mk
override DESTDIR := $(TOP_DESTDIR)
else else
$(info package-level build) $(info package-level build)
-include ../../.config.mk -include ../../.config.mk
@ -20,19 +21,19 @@ warn-noprefix:
@echo "no prefix configured, did you run ./configure?" @echo "no prefix configured, did you run ./configure?"
install: warn-noprefix install: warn-noprefix
else 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 .PHONY: install install-nodeps deps
install-nodeps: install-nodeps:
./build-node.mjs ./build-node.mjs
install -d $(prefix)/bin install -d $(DESTDIR)$(bindir)
install -d $(install_target)/bin install -d $(DESTDIR)$(nodedir)/bin
install -d $(install_target)/node_modules/anastasis-cli install -d $(DESTDIR)$(nodedir)/dist
install -d $(install_target)/node_modules/anastasis-cli/bin install ./dist/anastasis-cli-bundled.cjs $(DESTDIR)$(nodedir)/dist/
install -d $(install_target)/node_modules/anastasis-cli/dist install ./dist/anastasis-cli-bundled.cjs.map $(DESTDIR)$(nodedir)/dist/
install ./dist/anastasis-cli-bundled.cjs $(install_target)/node_modules/anastasis-cli/dist/ install ./bin/anastasis-cli.mjs $(DESTDIR)$(nodedir)/bin/
install ./dist/anastasis-cli-bundled.cjs.map $(install_target)/node_modules/anastasis-cli/dist/ ln -sf ../lib/anastasis-cli/node_modules/anastasis-cli/bin/anastasis-cli.mjs $(DESTDIR)$(bindir)/anastasis-cli
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
deps: deps:
pnpm install --frozen-lockfile --filter @gnu-taler/anastasis-cli... pnpm install --frozen-lockfile --filter @gnu-taler/anastasis-cli...
install: install:

View File

@ -3,6 +3,7 @@
ifeq ($(TOPLEVEL), yes) ifeq ($(TOPLEVEL), yes)
$(info top-level build) $(info top-level build)
-include ../../.config.mk -include ../../.config.mk
override DESTDIR := $(TOP_DESTDIR)
else else
$(info package-level build) $(info package-level build)
-include ../../.config.mk -include ../../.config.mk
@ -15,7 +16,7 @@ $(info prefix is $(prefix))
all: all:
@echo run \'make install\' to install @echo run \'make install\' to install
spa_dir=$(prefix)/share/taler/demobank-ui spa_dir=$(DESTDIR)$(prefix)/share/taler/demobank-ui
.PHONY: deps .PHONY: deps
deps: deps:

View File

@ -36,11 +36,18 @@ to the default settings:
``` ```
globalThis.talerDemobankSettings = { globalThis.talerDemobankSettings = {
backendBaseURL: "https://bank.demo.taler.net/demobanks/default/", // location of libeufin server
backendBaseURL: "https://bank.demo.taler.net/",
allowRegistrations: true, allowRegistrations: true,
bankName: "Taler Bank", bankName: "Taler Bank",
// Show explainer text and navbar to other demo sites // Show explainer text and navbar to other demo sites
showDemoNav: true, 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 // Names and links for other demo sites to show in the navbar
demoSites: [ demoSites: [
["Landing", "https://demo.taler.net/"], ["Landing", "https://demo.taler.net/"],

View File

@ -24,5 +24,5 @@ await build({
assets: [{ base: "src", files: ["src/index.html"] }], assets: [{ base: "src", files: ["src/index.html"] }],
}, },
destination: "./dist/prod", destination: "./dist/prod",
css: "sass", css: "postcss",
}); });

View File

@ -18,7 +18,7 @@
import { serve } from "@gnu-taler/web-util/node"; import { serve } from "@gnu-taler/web-util/node";
import { initializeDev } from "@gnu-taler/web-util/build"; 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({ const build = initializeDev({
type: "development", type: "development",
@ -28,7 +28,7 @@ const build = initializeDev({
}, },
destination: "./dist/dev", destination: "./dist/dev",
public: "/app", public: "/app",
css: "sass", css: "postcss",
}); });
await build(); await build();

View File

@ -1,7 +1,7 @@
{ {
"private": true, "private": true,
"name": "@gnu-taler/demobank-ui", "name": "@gnu-taler/demobank-ui",
"version": "0.1.0", "version": "0.9.3-dev.27",
"license": "AGPL-3.0-OR-LATER", "license": "AGPL-3.0-OR-LATER",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -46,6 +46,9 @@
"devDependencies": { "devDependencies": {
"@creativebulma/bulma-tooltip": "^1.2.0", "@creativebulma/bulma-tooltip": "^1.2.0",
"@gnu-taler/pogen": "^0.0.5", "@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/chai": "^4.3.0",
"@types/history": "^4.7.8", "@types/history": "^4.7.8",
"@types/mocha": "^10.0.1", "@types/mocha": "^10.0.1",
@ -62,6 +65,7 @@
"po2json": "^0.4.5", "po2json": "^0.4.5",
"preact-render-to-string": "^5.2.6", "preact-render-to-string": "^5.2.6",
"sass": "1.56.1", "sass": "1.56.1",
"tailwindcss": "^3.3.2",
"typescript": "5.2.2" "typescript": "5.2.2"
}, },
"pogen": { "pogen": {

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 201 90">
<g fill="#0042b3" fill-rule="evenodd" stroke-width=".3">
<path d="M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z" />
<path d="M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z" />
<path d="M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z" />
</g>
<path d="M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -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 <div class={`group attention-${type} mt-2`}>
<div class="rounded-md group-[.attention-info]:bg-blue-50 group-[.attention-warning]:bg-yellow-50 group-[.attention-danger]:bg-red-50 group-[.attention-success]:bg-green-50 p-4 shadow">
<div class="flex">
<div >
<svg xmlns="http://www.w3.org/2000/svg" stroke="none" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 group-[.attention-info]:text-blue-400 group-[.attention-warning]:text-yellow-400 group-[.attention-danger]:text-red-400 group-[.attention-success]:text-green-400">
{(() => {
switch (type) {
case "info":
return <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" />
case "warning":
return <path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" />
case "danger":
return <path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" />
case "success":
return <path fill-rule="evenodd" d="M7.493 18.75c-.425 0-.82-.236-.975-.632A7.48 7.48 0 016 15.375c0-1.75.599-3.358 1.602-4.634.151-.192.373-.309.6-.397.473-.183.89-.514 1.212-.924a9.042 9.042 0 012.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 00.322-1.672V3a.75.75 0 01.75-.75 2.25 2.25 0 012.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 01-2.649 7.521c-.388.482-.987.729-1.605.729H14.23c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 00-1.423-.23h-.777zM2.331 10.977a11.969 11.969 0 00-.831 4.398 12 12 0 00.52 3.507c.26.85 1.084 1.368 1.973 1.368H4.9c.445 0 .72-.498.523-.898a8.963 8.963 0 01-.924-3.977c0-1.708.476-3.305 1.302-4.666.245-.403-.028-.959-.5-.959H4.25c-.832 0-1.612.453-1.918 1.227z" />
default:
assertUnreachable(type)
}
})()}
</svg>
</div>
<div class="ml-3 w-full">
<h3 class="text-sm group-hover:text-white font-bold group-[.attention-info]:text-blue-800 group-[.attention-success]:text-green-800 group-[.attention-warning]:text-yellow-800 group-[.attention-danger]:text-red-800">
{title}
</h3>
<div class="mt-2 text-sm group-[.attention-info]:text-blue-700 group-[.attention-warning]:text-yellow-700 group-[.attention-danger]:text-red-700 group-[.attention-success]:text-green-700">
{children}
</div>
</div>
{onClose &&
<div>
<button type="button" class="font-semibold items-center rounded bg-transparent px-2 py-1 text-xs text-gray-900 hover:bg-gray-50"
onClick={(e) => {
e.preventDefault();
onClose();
}}
>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
}
</div>
</div>
</div>
}

View File

@ -19,6 +19,7 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { State } from "./index.js"; import { State } from "./index.js";
import { format } from "date-fns"; import { format } from "date-fns";
import { Amounts } from "@gnu-taler/taler-util"; import { Amounts } from "@gnu-taler/taler-util";
import { RenderAmount } from "../../pages/PaytoWireTransferForm.js";
export function LoadingUriView({ error }: State.LoadingUriError): VNode { export function LoadingUriView({ error }: State.LoadingUriError): VNode {
const { i18n } = useTranslationContext(); 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") ? format(item.confirmation_time, "dd/MM/yyyy HH:mm:ss")
: "-"} : "-"}
</td> </td>
<td>{Amounts.stringifyValue(item.amount_debit)}</td> <td><RenderAmount value={Amounts.parseOrThrow(item.amount_debit)} /></td>
<td>{Amounts.stringifyValue(item.amount_credit)}</td> <td><RenderAmount value={Amounts.parseOrThrow(item.amount_credit)} /></td>
<td>{item.status}</td> <td>{item.status}</td>
<td> <td>
<a <a

View File

@ -0,0 +1,60 @@
import { h, VNode } from "preact";
import { useEffect, useState } from "preact/hooks";
export function CopyIcon(): VNode {
return (
<svg height="16" viewBox="0 0 16 16" width="16" stroke="currentColor" strokeWidth="1.5">
<path
fill-rule="evenodd"
d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"
/>
<path
fill-rule="evenodd"
d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"
/>
</svg>
)
};
export function CopiedIcon(): VNode {
return (
<svg height="16" viewBox="0 0 16 16" width="16" stroke="currentColor" strokeWidth="1.5">
<path
fill-rule="evenodd"
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
/>
</svg>
)
};
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 (
<button class="text-white" onClick={copyText} style={{ width: 16, height: 16, fontSize: "initial" }}>
<CopyIcon />
</button>
);
}
return (
<div class="text-white" content="Copied" style={{ display: "inline-block" }}>
<button disabled style={{ width: 16, height: 16, fontSize: "initial" }}>
<CopiedIcon />
</button>
</div>
);
}

View File

@ -1,6 +1,7 @@
/*
/* /*
This file is part of GNU Taler 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 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 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 <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
/** import { HttpError, useTranslationContext } from "@gnu-taler/web-util/browser";
* import { h, VNode } from "preact";
* @author Sebastian Javier Marchano (sebasjm) import { Attention } from "./Attention.js";
*/ import { TranslatedString } from "@gnu-taler/taler-util";
.is-user-avatar { export function ErrorLoading({ error }: { error: HttpError<SandboxBackend.SandboxError> }): VNode {
&.has-max-width { const { i18n } = useTranslationContext()
max-width: $size-base * 7; return (<Attention type="danger" title={error.message as TranslatedString}>
} <p class="text-sm font-medium text-red-800">Got status "{error.info.status}" on {error.info.url}</p>
</Attention>
&.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;
}
} }

View File

@ -42,11 +42,11 @@ function getLangName(s: keyof LangsNames | string): string {
return String(s); return String(s);
} }
// FIXME: explain "like py". export function LangSelector(): VNode {
export function LangSelectorLikePy(): VNode {
const [updatingLang, setUpdatingLang] = useState(false); const [updatingLang, setUpdatingLang] = useState(false);
const { lang, changeLanguage } = useTranslationContext(); const { lang, changeLanguage } = useTranslationContext();
const [hidden, setHidden] = useState(true); const [hidden, setHidden] = useState(true);
useEffect(() => { useEffect(() => {
function bodyKeyPress(event: KeyboardEvent) { function bodyKeyPress(event: KeyboardEvent) {
if (event.code === "Escape") setHidden(true); if (event.code === "Escape") setHidden(true);
@ -62,51 +62,49 @@ export function LangSelectorLikePy(): VNode {
}; };
}, []); }, []);
return ( return (
<Fragment> <div>
<a <div class="relative mt-2">
href="#" <button type="button" class="relative w-full cursor-default rounded-md bg-white py-1.5 pl-3 pr-10 text-left text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label"
class="pure-button" onClick={() => {
name="language"
onClick={(ev) => {
ev.preventDefault();
setHidden((h) => !h); setHidden((h) => !h);
ev.stopPropagation(); }}>
}} <span class="flex items-center">
> <img src="https://taler.net/images/languageicon.svg" alt="" class="h-5 w-5 flex-shrink-0 rounded-full" />
{getLangName(lang)} <span class="ml-3 block truncate">{getLangName(lang)}</span>
</a> </span>
<div <span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
id="lang" <svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
class={hidden ? "hide" : ""} <path fill-rule="evenodd" d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" clip-rule="evenodd" />
style={{ </svg>
display: "inline-block", </span>
}} </button>
>
<div style="position: relative; overflow: visible;"> {!hidden &&
<div <ul class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" tabIndex={-1} role="listbox" aria-labelledby="listbox-label" aria-activedescendant="listbox-option-3">
class="nav"
style="position: absolute; max-height: 60vh; overflow-y: auto; margin-left: -120px; margin-top: 20px"
>
{Object.keys(messages) {Object.keys(messages)
.filter((l) => l !== lang) .filter((l) => l !== lang)
.map((l) => ( .map((lang) => (
<a <li class="text-gray-900 hover:bg-indigo-600 hover:text-white cursor-pointer relative select-none py-2 pl-3 pr-9" role="option"
key={l}
href="#"
class="navbtn langbtn"
value={l}
onClick={() => { onClick={() => {
changeLanguage(l); changeLanguage(lang);
setUpdatingLang(false); setUpdatingLang(false);
setHidden(true)
}} }}
> >
{getLangName(l)} <span class="font-normal block truncate">{getLangName(lang)}</span>
</a>
<span class="text-indigo-600 absolute inset-y-0 right-0 flex items-center pr-4">
{/* <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
</svg> */}
</span>
</li>
))} ))}
<br />
</ul>
}
</div> </div>
</div> </div>
</div>
</Fragment>
); );
} }

View File

@ -33,7 +33,6 @@ export function QR({ text }: { text: string }): VNode {
return ( return (
<div <div
style={{ style={{
width: "100%",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "left", alignItems: "left",
@ -41,9 +40,7 @@ export function QR({ text }: { text: string }): VNode {
> >
<div <div
style={{ style={{
width: "50%", width: "100%",
minWidth: 200,
maxWidth: 300,
marginRight: "auto", marginRight: "auto",
marginLeft: "auto", marginLeft: "auto",
}} }}

View File

@ -0,0 +1,167 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { 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 <BankFrame >
<Router history={history}>
<Route
path="/login"
component={() => (
<Fragment>
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h2>
</div>
<LoginForm
onRegister={() => {
route("/register");
}}
/>
</Fragment>
)}
/>
<Route
path="/public-accounts"
component={() => <PublicHistoriesPage />}
/>
<Route
path="/operation/:wopid"
component={({ wopid }: { wopid: string }) => (
<WithdrawalOperationPage
operationId={wopid}
onContinue={() => {
route("/account");
}}
/>
)}
/>
{bankUiSettings.allowRegistrations &&
<Route
path="/register"
component={() => (
<RegistrationPage
onComplete={() => {
route("/account");
}}
onCancel={() => {
route("/account");
}}
/>
)}
/>
}
<Route default component={Redirect} to="/login" />
</Router>
</BankFrame>
}
const { isUserAdministrator, username } = backend.state
return (
<BankFrame account={backend.state.username}>
<Router history={history}>
<Route
path="/operation/:wopid"
component={({ wopid }: { wopid: string }) => (
<WithdrawalOperationPage
operationId={wopid}
onContinue={() => {
route("/account");
}}
/>
)}
/>
<Route
path="/public-accounts"
component={() => <PublicHistoriesPage />}
/>
<Route
path="/account"
component={() => {
if (isUserAdministrator) {
return <AdminHome
onRegister={() => {
route("/register");
}}
/>;
} else {
return <HomePage
account={username}
goToConfirmOperation={(wopid) => {
route(`/operation/${wopid}`);
}}
goToBusinessAccount={() => {
route("/business");
}}
onRegister={() => {
route("/register");
}}
/>
}
}}
/>
<Route
path="/business"
component={() => (
<BusinessAccount
account={username}
onClose={() => {
route("/account");
}}
onRegister={() => {
route("/register");
}}
onLoadNotOk={() => {
route("/account");
}}
/>
)}
/>
<Route default component={Redirect} to="/account" />
</Router>
</BankFrame>
);
}
function Redirect({ to }: { to: string }): VNode {
useEffect(() => {
route(to, true);
}, []);
return <div>being redirected to {to}</div>;
}
export function assertUnreachable(x: never): never {
throw new Error("Didn't expect to get here");
}

View File

@ -24,6 +24,6 @@ export function ShowInputErrorLabel({
isDirty: boolean; isDirty: boolean;
}): VNode { }): VNode {
if (message && isDirty) if (message && isDirty)
return <div style={{ marginTop: 8, color: "red" }}>{message}</div>; return <div class="text-base" style={{ color: "red" }}>{message}</div>;
return <Fragment />; return <div class="text-base" style={{ }}> </div>;
} }

View File

@ -46,6 +46,8 @@ export namespace State {
status: "ready"; status: "ready";
error: undefined; error: undefined;
transactions: Transaction[]; transactions: Transaction[];
onPrev?: () => void;
onNext?: () => void;
} }
} }

View File

@ -14,7 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { AbsoluteTime, Amounts } from "@gnu-taler/taler-util"; import { AbsoluteTime, Amounts, parsePaytoUri } from "@gnu-taler/taler-util";
import { useTransactions } from "../../hooks/access.js"; import { useTransactions } from "../../hooks/access.js";
import { Props, State, Transaction } from "./index.js"; import { Props, State, Transaction } from "./index.js";
@ -34,45 +34,19 @@ export function useComponentState({ account }: Props): State {
} }
const transactions = result.data.transactions const transactions = result.data.transactions
.map((item: unknown) => { .map((tx) => {
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;
}
const negative = anyItem.direction === "DBIT"; const negative = tx.direction === "debit";
const counterpart = negative ? anyItem.creditorIban : anyItem.debtorIban; 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; const when = AbsoluteTime.fromProtocolTimestamp(tx.date);
if (isNaN(date) || !isFinite(date)) { const amount = Amounts.parse(tx.amount);
date = 0; const subject = tx.subject;
}
const when: AbsoluteTime = !date
? AbsoluteTime.never()
: AbsoluteTime.fromMilliseconds(date);
const amount = Amounts.parse(`${anyItem.currency}:${anyItem.amount}`);
const subject = anyItem.subject;
return { return {
negative, negative,
counterpart, counterpart,
@ -87,5 +61,7 @@ export function useComponentState({ account }: Props): State {
status: "ready", status: "ready",
error: undefined, error: undefined,
transactions, transactions,
onNext: result.isReachingEnd ? undefined : result.loadMore,
onPrev: result.isReachingStart ? undefined : result.loadMorePrev,
}; };
} }

View File

@ -14,11 +14,13 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { State } from "./index.js"; import { State } from "./index.js";
import { format } from "date-fns"; import { format, isToday } from "date-fns";
import { Amounts } from "@gnu-taler/taler-util"; 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 { export function LoadingUriView({ error }: State.LoadingUriError): VNode {
const { i18n } = useTranslationContext(); 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(); const { i18n } = useTranslationContext();
if (!transactions.length) return <div />
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<string, typeof transactions>)
return ( return (
<div class="results"> <div class="px-4 mt-4">
<table class="pure-table pure-table-striped"> <div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold leading-6 text-gray-900"><i18n.Translate>Latest transactions</i18n.Translate></h1>
</div>
</div>
<div class="-mx-4 mt-5 ring-1 ring-gray-300 sm:mx-0 rounded-lg min-w-fit bg-white">
<table class="min-w-full divide-y divide-gray-300">
<thead> <thead>
<tr> <tr>
<th>{i18n.str`Date`}</th> <th scope="col" class="pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 ">{i18n.str`Date`}</th>
<th>{i18n.str`Amount`}</th> <th scope="col" class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 ">{i18n.str`Amount`}</th>
<th>{i18n.str`Counterpart`}</th> <th scope="col" class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 ">{i18n.str`Counterpart`}</th>
<th>{i18n.str`Subject`}</th> <th scope="col" class="pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 ">{i18n.str`Subject`}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{transactions.map((item, idx) => { {Object.entries(txByDate).map(([date, txs], idx) => {
return ( return <Fragment>
<tr key={idx}> <tr class="border-t border-gray-200">
<td> <th colSpan={4} scope="colgroup" class="bg-gray-50 py-2 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-3">
{item.when.t_ms === "never" {date}
? "" </th>
: format(item.when.t_ms, "dd/MM/yyyy HH:mm:ss")} </tr>
</td> {txs.map(item => {
<td> const time = item.when.t_ms === "never" ? "" : format(item.when.t_ms, "HH:mm:ss")
{item.negative ? "-" : ""} const amount = <Fragment>
{item.amount ? ( { }
`${Amounts.stringifyValue(item.amount)} ${ </Fragment>
item.amount.currency return (<tr key={idx}>
}` <td class="relative py-2 pl-2 pr-2 text-sm ">
<div class="font-medium text-gray-900">{time}</div>
<dl class="font-normal sm:hidden">
<dt class="sr-only sm:hidden"><i18n.Translate>Amount</i18n.Translate></dt>
<dd class="mt-1 truncate text-gray-700">
{item.negative ? i18n.str`sent` : i18n.str`received`} {item.amount ? (
<RenderAmount value={item.amount} />
) : ( ) : (
<span style={{ color: "grey" }}>&lt;invalid value&gt;</span> <span style={{ color: "grey" }}>&lt;{i18n.str`invalid value`}&gt;</span>
)}</dd>
<dt class="sr-only sm:hidden"><i18n.Translate>Counterpart</i18n.Translate></dt>
<dd class="mt-1 truncate text-gray-500 sm:hidden">
{item.negative ? i18n.str`to` : i18n.str`from`} {item.counterpart}
</dd>
</dl>
</td>
<td data-negative={item.negative ? "true" : "false"}
class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 data-[negative=false]:text-green-600 data-[negative=true]:text-red-600">
{item.amount ? (<RenderAmount value={item.amount} negative={item.negative} />
) : (
<span style={{ color: "grey" }}>&lt;{i18n.str`invalid value`}&gt;</span>
)} )}
</td> </td>
<td>{item.counterpart}</td> <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500">{item.counterpart}</td>
<td>{item.subject}</td> <td class="px-3 py-3.5 text-sm text-gray-500 break-all min-w-md">{item.subject}</td>
</tr> </tr>)
); })}
</Fragment>
})} })}
</tbody> </tbody>
</table> </table>
<nav class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" aria-label="Pagination">
<div class="flex flex-1 justify-between sm:justify-end">
<button
class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0"
disabled={!onPrev}
onClick={onPrev}
>
<i18n.Translate>First page</i18n.Translate>
</button>
<button
class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0"
disabled={!onNext}
onClick={onNext}
>
<i18n.Translate>Next</i18n.Translate>
</button>
</div>
</nav>
</div>
</div> </div>
); );
} }

View File

@ -15,16 +15,23 @@
*/ */
import { import {
LibtoolVersion,
getGlobalLogLevel, getGlobalLogLevel,
setGlobalLogLevelFromString, setGlobalLogLevelFromString,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { TranslationProvider } from "@gnu-taler/web-util/browser"; import { TranslationProvider, useApiContext } from "@gnu-taler/web-util/browser";
import { FunctionalComponent, h } from "preact"; import { ComponentChildren, Fragment, FunctionalComponent, VNode, h } from "preact";
import { SWRConfig } from "swr"; import { SWRConfig } from "swr";
import { BackendStateProvider } from "../context/backend.js"; import { BackendStateProvider, useBackendContext } from "../context/backend.js";
import { strings } from "../i18n/strings.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; const WITH_LOCAL_STORAGE_CACHE = false;
/** /**
@ -48,6 +55,7 @@ const App: FunctionalComponent = () => {
return ( return (
<TranslationProvider source={strings}> <TranslationProvider source={strings}>
<BackendStateProvider> <BackendStateProvider>
<VersionCheck>
<SWRConfig <SWRConfig
value={{ value={{
provider: WITH_LOCAL_STORAGE_CACHE provider: WITH_LOCAL_STORAGE_CACHE
@ -57,6 +65,7 @@ const App: FunctionalComponent = () => {
> >
<Routing /> <Routing />
</SWRConfig> </SWRConfig>
</VersionCheck>
</BackendStateProvider> </BackendStateProvider>
</TranslationProvider > </TranslationProvider >
); );
@ -64,6 +73,26 @@ const App: FunctionalComponent = () => {
(window as any).setGlobalLogLevelFromString = setGlobalLogLevelFromString; (window as any).setGlobalLogLevelFromString = setGlobalLogLevelFromString;
(window as any).getGlobalLevel = getGlobalLogLevel; (window as any).getGlobalLevel = getGlobalLogLevel;
function VersionCheck({ children }: { children: ComponentChildren }): VNode {
const checked = useConfigState()
if (checked === undefined) {
return <Loading />
}
if (checked.type === "wrong") {
return <BankFrame>
the bank backend is not supported. supported version "{BANK_INTEGRATION_PROTOCOL_VERSION}", server version "{checked}"
</BankFrame>
}
if (checked.type === "ok") {
return <ConfigStateProvider value={checked.result}>{children}</ConfigStateProvider>
}
return <BankFrame>
<ErrorLoading error={checked.result} />
</BankFrame>
}
function localStorageProvider(): Map<unknown, unknown> { function localStorageProvider(): Map<unknown, unknown> {
const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]")); const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]"));

View File

@ -34,6 +34,9 @@ const initial: Type = {
logOut() { logOut() {
null; null;
}, },
expired() {
null;
},
logIn(info) { logIn(info) {
null; null;
}, },
@ -65,6 +68,7 @@ export const BackendStateProviderTesting = ({
const value: BackendStateHandler = { const value: BackendStateHandler = {
state, state,
logIn: () => {}, logIn: () => {},
expired: () => {},
logOut: () => {}, logOut: () => {},
}; };

View File

@ -1,6 +1,6 @@
/* /*
This file is part of GNU Taler 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 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 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 <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { ComponentChildren, createContext, h, VNode } from "preact";
import { useContext } from "preact/hooks";
/** /**
* *
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
section.section.is-title-bar { export type Type = Required<SandboxBackend.Config>;
padding: $default-padding;
border-bottom: $light-border;
ul { const initial: Type = {
li { name: "",
display: inline-block; version: "0:0:0",
padding: 0 $default-padding * 0.5 0 0; currency_fraction_digits: 2,
font-size: $default-padding; currency_fraction_limit: 2,
color: $title-bar-color; fiat_currency: "",
have_cashout: false,
};
const Context = createContext<Type>(initial);
&:after { export const useConfigContext = (): Type => useContext(Context);
display: inline-block;
content: "/";
padding-left: $default-padding * 0.5;
}
&:last-child { export const ConfigStateProvider = ({
padding-right: 0; value,
font-weight: 900; children,
color: $title-bar-active-color; }: {
value: Type,
children: ComponentChildren;
}): VNode => {
return h(Context.Provider, {
value,
children,
});
};
&:after {
display: none;
}
}
}
}
}

View File

@ -74,7 +74,9 @@ type HashCode = string;
type EddsaPublicKey = string; type EddsaPublicKey = string;
type EddsaSignature = string; type EddsaSignature = string;
type WireTransferIdentifierRawP = string; type WireTransferIdentifierRawP = string;
type RelativeTime = Duration; type RelativeTime = {
d_us: number | "forever"
};
type ImageDataUrl = string; type ImageDataUrl = string;
interface WithId { interface WithId {
@ -99,20 +101,33 @@ type Amount = string;
type UUID = string; type UUID = string;
type Integer = number; type Integer = number;
interface Balance {
amount: Amount;
credit_debit_indicator: "credit" | "debit";
}
namespace SandboxBackend { namespace SandboxBackend {
export interface Config { export interface Config {
// Name of this API, always "circuit". // Name of this API, always "circuit".
name: string; name: string;
// API version in the form $n:$n:$n // API version in the form $n:$n:$n
version: string; version: string;
// Contains ratios and fees related to buying // If 'true', the server provides local currency
// and selling the circuit currency. // conversion support.
ratios_and_fees: RatiosAndFees; // 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 { interface RatiosAndFees {
// Exchange rate to buy the circuit currency from fiat. // Exchange rate to buy the circuit currency from fiat.
@ -126,7 +141,7 @@ namespace SandboxBackend {
} }
export interface SandboxError { export interface SandboxError {
error: SandboxErrorDetail; error?: SandboxErrorDetail;
} }
interface SandboxErrorDetail { interface SandboxErrorDetail {
// String enum classifying the error. // String enum classifying the error.
@ -152,26 +167,12 @@ namespace SandboxBackend {
UtilError = "util-error", 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 { type EmailAddress = string;
// Available balance on the account. type PhoneNumber = string;
balance: Balance;
// payto://-URI of the account. (New) namespace CoreBank {
paytoUri: string;
// Number indicating the max debit allowed for the requesting user.
debitThreshold: Amount;
}
interface BankAccountCreateWithdrawalRequest { interface BankAccountCreateWithdrawalRequest {
// Amount to withdraw. // Amount to withdraw.
amount: Amount; amount: Amount;
@ -213,28 +214,24 @@ namespace SandboxBackend {
} }
interface BankAccountTransactionInfo { interface BankAccountTransactionInfo {
creditorIban: string; creditor_payto_uri: string;
creditorBic: string; // Optional debtor_payto_uri: string;
creditorName: string;
debtorIban: string; amount: Amount;
debtorBic: string; direction: "debit" | "credit";
debtorName: string;
amount: number;
currency: string;
subject: string; subject: string;
// Transaction unique ID. Matches // Transaction unique ID. Matches
// $transaction_id from the URI. // $transaction_id from the URI.
uid: string; row_id: number;
direction: "DBIT" | "CRDT"; date: Timestamp;
date: string; // milliseconds since the Unix epoch
} }
interface CreateBankAccountTransactionCreate { interface CreateBankAccountTransactionCreate {
// Address in the Payto format of the wire transfer receiver. // Address in the Payto format of the wire transfer receiver.
// It needs at least the 'message' query string parameter. // It needs at least the 'message' query string parameter.
paytoUri: string; payto_uri: string;
// Transaction amount (in the $currency:x.y format), optional. // Transaction amount (in the $currency:x.y format), optional.
// However, when not given, its value must occupy the 'amount' // However, when not given, its value must occupy the 'amount'
@ -243,11 +240,143 @@ namespace SandboxBackend {
amount?: string; amount?: string;
} }
interface BankRegistrationRequest { interface RegisterAccountRequest {
// Username
username: string; username: string;
// Password.
password: string; 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 { namespace Circuit {

View File

@ -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/"],
],
};

View File

@ -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<Data.Form>): FormState<Data.Form> {
return {
when: {
disabled: true,
},
threshold: {
// disabled: v.state === AmlExchangeBackend.AmlState.frozen,
},
};
}

View File

@ -44,13 +44,13 @@ export function useAccessAPI(): AccessAPI {
const account = state.username; const account = state.username;
const createWithdrawal = async ( const createWithdrawal = async (
data: SandboxBackend.Access.BankAccountCreateWithdrawalRequest, data: SandboxBackend.CoreBank.BankAccountCreateWithdrawalRequest,
): Promise< ): Promise<
HttpResponseOk<SandboxBackend.Access.BankAccountCreateWithdrawalResponse> HttpResponseOk<SandboxBackend.CoreBank.BankAccountCreateWithdrawalResponse>
> => { > => {
const res = const res =
await request<SandboxBackend.Access.BankAccountCreateWithdrawalResponse>( await request<SandboxBackend.CoreBank.BankAccountCreateWithdrawalResponse>(
`access-api/accounts/${account}/withdrawals`, `accounts/${account}/withdrawals`,
{ {
method: "POST", method: "POST",
data, data,
@ -60,21 +60,21 @@ export function useAccessAPI(): AccessAPI {
return res; return res;
}; };
const createTransaction = async ( const createTransaction = async (
data: SandboxBackend.Access.CreateBankAccountTransactionCreate, data: SandboxBackend.CoreBank.CreateBankAccountTransactionCreate,
): Promise<HttpResponseOk<void>> => { ): Promise<HttpResponseOk<void>> => {
const res = await request<void>( const res = await request<void>(
`access-api/accounts/${account}/transactions`, `accounts/${account}/transactions`,
{ {
method: "POST", method: "POST",
data, data,
contentType: "json", contentType: "json",
}, },
); );
await mutateAll(/.*accounts\/.*\/transactions.*/); await mutateAll(/.*accounts\/.*/);
return res; return res;
}; };
const deleteAccount = async (): Promise<HttpResponseOk<void>> => { const deleteAccount = async (): Promise<HttpResponseOk<void>> => {
const res = await request<void>(`access-api/accounts/${account}`, { const res = await request<void>(`accounts/${account}`, {
method: "DELETE", method: "DELETE",
contentType: "json", contentType: "json",
}); });
@ -94,7 +94,7 @@ export function useAccessAnonAPI(): AccessAnonAPI {
const { request } = useAuthenticatedBackend(); const { request } = useAuthenticatedBackend();
const abortWithdrawal = async (id: string): Promise<HttpResponseOk<void>> => { const abortWithdrawal = async (id: string): Promise<HttpResponseOk<void>> => {
const res = await request<void>(`access-api/withdrawals/${id}/abort`, { const res = await request<void>(`withdrawals/${id}/abort`, {
method: "POST", method: "POST",
contentType: "json", contentType: "json",
}); });
@ -104,7 +104,7 @@ export function useAccessAnonAPI(): AccessAnonAPI {
const confirmWithdrawal = async ( const confirmWithdrawal = async (
id: string, id: string,
): Promise<HttpResponseOk<void>> => { ): Promise<HttpResponseOk<void>> => {
const res = await request<void>(`access-api/withdrawals/${id}/confirm`, { const res = await request<void>(`withdrawals/${id}/confirm`, {
method: "POST", method: "POST",
contentType: "json", contentType: "json",
}); });
@ -122,9 +122,10 @@ export function useTestingAPI(): TestingAPI {
const mutateAll = useMatchMutate(); const mutateAll = useMatchMutate();
const { request: noAuthRequest } = usePublicBackend(); const { request: noAuthRequest } = usePublicBackend();
const register = async ( const register = async (
data: SandboxBackend.Access.BankRegistrationRequest, data: SandboxBackend.CoreBank.RegisterAccountRequest,
): Promise<HttpResponseOk<void>> => { ): Promise<HttpResponseOk<void>> => {
const res = await noAuthRequest<void>(`access-api/testing/register`, { // FIXME: This API is deprecated. The normal account registration API should be used instead.
const res = await noAuthRequest<void>(`accounts`, {
method: "POST", method: "POST",
data, data,
contentType: "json", contentType: "json",
@ -138,18 +139,18 @@ export function useTestingAPI(): TestingAPI {
export interface TestingAPI { export interface TestingAPI {
register: ( register: (
data: SandboxBackend.Access.BankRegistrationRequest, data: SandboxBackend.CoreBank.RegisterAccountRequest,
) => Promise<HttpResponseOk<void>>; ) => Promise<HttpResponseOk<void>>;
} }
export interface AccessAPI { export interface AccessAPI {
createWithdrawal: ( createWithdrawal: (
data: SandboxBackend.Access.BankAccountCreateWithdrawalRequest, data: SandboxBackend.CoreBank.BankAccountCreateWithdrawalRequest,
) => Promise< ) => Promise<
HttpResponseOk<SandboxBackend.Access.BankAccountCreateWithdrawalResponse> HttpResponseOk<SandboxBackend.CoreBank.BankAccountCreateWithdrawalResponse>
>; >;
createTransaction: ( createTransaction: (
data: SandboxBackend.Access.CreateBankAccountTransactionCreate, data: SandboxBackend.CoreBank.CreateBankAccountTransactionCreate,
) => Promise<HttpResponseOk<void>>; ) => Promise<HttpResponseOk<void>>;
deleteAccount: () => Promise<HttpResponseOk<void>>; deleteAccount: () => Promise<HttpResponseOk<void>>;
} }
@ -166,15 +167,15 @@ export interface InstanceTemplateFilter {
export function useAccountDetails( export function useAccountDetails(
account: string, account: string,
): HttpResponse< ): HttpResponse<
SandboxBackend.Access.BankAccountBalanceResponse, SandboxBackend.CoreBank.AccountData,
SandboxBackend.SandboxError SandboxBackend.SandboxError
> { > {
const { fetcher } = useAuthenticatedBackend(); const { fetcher } = useAuthenticatedBackend();
const { data, error } = useSWR< const { data, error } = useSWR<
HttpResponseOk<SandboxBackend.Access.BankAccountBalanceResponse>, HttpResponseOk<SandboxBackend.CoreBank.AccountData>,
RequestError<SandboxBackend.SandboxError> RequestError<SandboxBackend.SandboxError>
>([`access-api/accounts/${account}`], fetcher, { >([`accounts/${account}`], fetcher, {
refreshInterval: 0, refreshInterval: 0,
refreshWhenHidden: false, refreshWhenHidden: false,
revalidateOnFocus: false, revalidateOnFocus: false,
@ -186,29 +187,9 @@ export function useAccountDetails(
keepPreviousData: true, 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) { if (data) {
const isAmount = Amounts.parse(data.data.debitThreshold);
if (isAmount) {
//server response with correct format
return data; 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;
}
if (error) return error.cause; if (error) return error.cause;
return { loading: true }; return { loading: true };
} }
@ -217,15 +198,15 @@ export function useAccountDetails(
export function useWithdrawalDetails( export function useWithdrawalDetails(
wid: string, wid: string,
): HttpResponse< ): HttpResponse<
SandboxBackend.Access.BankAccountGetWithdrawalResponse, SandboxBackend.CoreBank.BankAccountGetWithdrawalResponse,
SandboxBackend.SandboxError SandboxBackend.SandboxError
> { > {
const { fetcher } = useAuthenticatedBackend(); const { fetcher } = useAuthenticatedBackend();
const { data, error } = useSWR< const { data, error } = useSWR<
HttpResponseOk<SandboxBackend.Access.BankAccountGetWithdrawalResponse>, HttpResponseOk<SandboxBackend.CoreBank.BankAccountGetWithdrawalResponse>,
RequestError<SandboxBackend.SandboxError> RequestError<SandboxBackend.SandboxError>
>([`access-api/withdrawals/${wid}`], fetcher, { >([`withdrawals/${wid}`], fetcher, {
refreshInterval: 1000, refreshInterval: 1000,
refreshWhenHidden: false, refreshWhenHidden: false,
revalidateOnFocus: false, revalidateOnFocus: false,
@ -247,15 +228,15 @@ export function useTransactionDetails(
account: string, account: string,
tid: string, tid: string,
): HttpResponse< ): HttpResponse<
SandboxBackend.Access.BankAccountTransactionInfo, SandboxBackend.CoreBank.BankAccountTransactionInfo,
SandboxBackend.SandboxError SandboxBackend.SandboxError
> { > {
const { fetcher } = useAuthenticatedBackend(); const { paginatedFetcher } = useAuthenticatedBackend();
const { data, error } = useSWR< const { data, error } = useSWR<
HttpResponseOk<SandboxBackend.Access.BankAccountTransactionInfo>, HttpResponseOk<SandboxBackend.CoreBank.BankAccountTransactionInfo>,
RequestError<SandboxBackend.SandboxError> RequestError<SandboxBackend.SandboxError>
>([`access-api/accounts/${account}/transactions/${tid}`], fetcher, { >([`accounts/${account}/transactions/${tid}`], paginatedFetcher, {
refreshInterval: 0, refreshInterval: 0,
refreshWhenHidden: false, refreshWhenHidden: false,
revalidateOnFocus: false, revalidateOnFocus: false,
@ -274,13 +255,13 @@ export function useTransactionDetails(
} }
interface PaginationFilter { interface PaginationFilter {
page: number; // page: number;
} }
export function usePublicAccounts( export function usePublicAccounts(
args?: PaginationFilter, args?: PaginationFilter,
): HttpResponsePaginated< ): HttpResponsePaginated<
SandboxBackend.Access.PublicAccountsResponse, SandboxBackend.CoreBank.PublicAccountsResponse,
SandboxBackend.SandboxError SandboxBackend.SandboxError
> { > {
const { paginatedFetcher } = usePublicBackend(); const { paginatedFetcher } = usePublicBackend();
@ -292,13 +273,13 @@ export function usePublicAccounts(
error: afterError, error: afterError,
isValidating: loadingAfter, isValidating: loadingAfter,
} = useSWR< } = useSWR<
HttpResponseOk<SandboxBackend.Access.PublicAccountsResponse>, HttpResponseOk<SandboxBackend.CoreBank.PublicAccountsResponse>,
RequestError<SandboxBackend.SandboxError> RequestError<SandboxBackend.SandboxError>
>([`access-api/public-accounts`, args?.page, PAGE_SIZE], paginatedFetcher); >([`public-accounts`, page, PAGE_SIZE], paginatedFetcher);
const [lastAfter, setLastAfter] = useState< const [lastAfter, setLastAfter] = useState<
HttpResponse< HttpResponse<
SandboxBackend.Access.PublicAccountsResponse, SandboxBackend.CoreBank.PublicAccountsResponse,
SandboxBackend.SandboxError SandboxBackend.SandboxError
> >
>({ loading: true }); >({ 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 // if the query returns less that we ask, then we have reach the end or beginning
const isReachingEnd = const isReachingEnd =
afterData && afterData.data.publicAccounts.length < PAGE_SIZE; afterData && afterData.data.public_accounts.length < PAGE_SIZE;
const isReachingStart = false; const isReachingStart = false;
const pagination = { const pagination = {
@ -319,7 +300,7 @@ export function usePublicAccounts(
isReachingStart, isReachingStart,
loadMore: () => { loadMore: () => {
if (!afterData || isReachingEnd) return; if (!afterData || isReachingEnd) return;
if (afterData.data.publicAccounts.length < MAX_RESULT_SIZE) { if (afterData.data.public_accounts.length < MAX_RESULT_SIZE) {
setPage(page + 1); setPage(page + 1);
} }
}, },
@ -328,12 +309,12 @@ export function usePublicAccounts(
}, },
}; };
const publicAccounts = !afterData const public_accounts = !afterData
? [] ? []
: (afterData || lastAfter).data.publicAccounts; : (afterData || lastAfter).data.public_accounts;
if (loadingAfter) return { loading: true, data: { publicAccounts } }; if (loadingAfter) return { loading: true, data: { public_accounts } };
if (afterData) { if (afterData) {
return { ok: true, data: { publicAccounts }, ...pagination }; return { ok: true, data: { public_accounts }, ...pagination };
} }
return { loading: true }; return { loading: true };
} }
@ -348,28 +329,36 @@ export function useTransactions(
account: string, account: string,
args?: PaginationFilter, args?: PaginationFilter,
): HttpResponsePaginated< ): HttpResponsePaginated<
SandboxBackend.Access.BankAccountTransactionsResponse, SandboxBackend.CoreBank.BankAccountTransactionsResponse,
SandboxBackend.SandboxError SandboxBackend.SandboxError
> { > {
const { paginatedFetcher } = useAuthenticatedBackend(); const { paginatedFetcher } = useAuthenticatedBackend();
const [page, setPage] = useState(1); const [start, setStart] = useState<string>();
const { const {
data: afterData, data: afterData,
error: afterError, error: afterError,
isValidating: loadingAfter, isValidating: loadingAfter,
} = useSWR< } = useSWR<
HttpResponseOk<SandboxBackend.Access.BankAccountTransactionsResponse>, HttpResponseOk<SandboxBackend.CoreBank.BankAccountTransactionsResponse>,
RequestError<SandboxBackend.SandboxError> RequestError<SandboxBackend.SandboxError>
>( >(
[`access-api/accounts/${account}/transactions`, args?.page, PAGE_SIZE], [`accounts/${account}/transactions`, start, PAGE_SIZE],
paginatedFetcher, paginatedFetcher, {
refreshInterval: 0,
refreshWhenHidden: false,
refreshWhenOffline: false,
// revalidateOnMount: false,
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
); );
const [lastAfter, setLastAfter] = useState< const [lastAfter, setLastAfter] = useState<
HttpResponse< HttpResponse<
SandboxBackend.Access.BankAccountTransactionsResponse, SandboxBackend.CoreBank.BankAccountTransactionsResponse,
SandboxBackend.SandboxError SandboxBackend.SandboxError
> >
>({ loading: true }); >({ 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 // if the query returns less that we ask, then we have reach the end or beginning
const isReachingEnd = const isReachingEnd =
afterData && afterData.data.transactions.length < PAGE_SIZE; afterData && afterData.data.transactions.length < PAGE_SIZE;
const isReachingStart = false; const isReachingStart = start == undefined;
const pagination = { const pagination = {
isReachingEnd, isReachingEnd,
isReachingStart, isReachingStart,
loadMore: () => { loadMore: () => {
if (!afterData || isReachingEnd) return; if (!afterData || isReachingEnd) return;
if (afterData.data.transactions.length < MAX_RESULT_SIZE) { // if (afterData.data.transactions.length < MAX_RESULT_SIZE) {
setPage(page + 1); const l = afterData.data.transactions[afterData.data.transactions.length-1]
} setStart(String(l.row_id));
// }
}, },
loadMorePrev: () => { loadMorePrev: () => {
null; if (!afterData || isReachingStart) return;
// if (afterData.data.transactions.length < MAX_RESULT_SIZE) {
setStart(undefined)
// }
}, },
}; };

View File

@ -40,21 +40,24 @@ import { useCallback, useEffect, useState } from "preact/hooks";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
import { useBackendContext } from "../context/backend.js"; import { useBackendContext } from "../context/backend.js";
import { bankUiSettings } from "../settings.js"; import { bankUiSettings } from "../settings.js";
import { AccessToken } from "./useCredentialsChecker.js";
/** /**
* Has the information to reach and * Has the information to reach and
* authenticate at the bank's backend. * authenticate at the bank's backend.
*/ */
export type BackendState = LoggedIn | LoggedOut; export type BackendState = LoggedIn | LoggedOut | Expired;
export interface BackendCredentials { interface LoggedIn {
username: string;
password: string;
}
interface LoggedIn extends BackendCredentials {
status: "loggedIn"; status: "loggedIn";
isUserAdministrator: boolean; isUserAdministrator: boolean;
username: string;
token: AccessToken;
}
interface Expired {
status: "expired";
isUserAdministrator: boolean;
username: string;
} }
interface LoggedOut { interface LoggedOut {
status: "loggedOut"; status: "loggedOut";
@ -64,10 +67,17 @@ export const codecForBackendStateLoggedIn = (): Codec<LoggedIn> =>
buildCodecForObject<LoggedIn>() buildCodecForObject<LoggedIn>()
.property("status", codecForConstString("loggedIn")) .property("status", codecForConstString("loggedIn"))
.property("username", codecForString()) .property("username", codecForString())
.property("password", codecForString()) .property("token", codecForString() as Codec<AccessToken>)
.property("isUserAdministrator", codecForBoolean()) .property("isUserAdministrator", codecForBoolean())
.build("BackendState.LoggedIn"); .build("BackendState.LoggedIn");
export const codecForBackendStateExpired = (): Codec<Expired> =>
buildCodecForObject<Expired>()
.property("status", codecForConstString("expired"))
.property("username", codecForString())
.property("isUserAdministrator", codecForBoolean())
.build("BackendState.Expired");
export const codecForBackendStateLoggedOut = (): Codec<LoggedOut> => export const codecForBackendStateLoggedOut = (): Codec<LoggedOut> =>
buildCodecForObject<LoggedOut>() buildCodecForObject<LoggedOut>()
.property("status", codecForConstString("loggedOut")) .property("status", codecForConstString("loggedOut"))
@ -78,6 +88,7 @@ export const codecForBackendState = (): Codec<BackendState> =>
.discriminateOn("status") .discriminateOn("status")
.alternative("loggedIn", codecForBackendStateLoggedIn()) .alternative("loggedIn", codecForBackendStateLoggedIn())
.alternative("loggedOut", codecForBackendStateLoggedOut()) .alternative("loggedOut", codecForBackendStateLoggedOut())
.alternative("expired", codecForBackendStateExpired())
.build("BackendState"); .build("BackendState");
export function getInitialBackendBaseURL(): string { export function getInitialBackendBaseURL(): string {
@ -85,18 +96,27 @@ export function getInitialBackendBaseURL(): string {
typeof localStorage !== "undefined" typeof localStorage !== "undefined"
? localStorage.getItem("bank-base-url") ? localStorage.getItem("bank-base-url")
: undefined; : undefined;
let result: string;
if (!overrideUrl) { if (!overrideUrl) {
//normal path //normal path
if (!bankUiSettings.backendBaseURL) { if (!bankUiSettings.backendBaseURL) {
console.error( console.error(
"ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'", "ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'",
); );
return canonicalizeBaseUrl(window.origin); result = window.origin
} } else {
return canonicalizeBaseUrl(bankUiSettings.backendBaseURL); result = bankUiSettings.backendBaseURL;
} }
} else {
// testing/development path // testing/development path
return canonicalizeBaseUrl(overrideUrl); result = overrideUrl
}
try {
return canonicalizeBaseUrl(result)
} catch (e) {
//fall back
return canonicalizeBaseUrl(window.origin)
}
} }
export const defaultState: BackendState = { export const defaultState: BackendState = {
@ -106,7 +126,8 @@ export const defaultState: BackendState = {
export interface BackendStateHandler { export interface BackendStateHandler {
state: BackendState; state: BackendState;
logOut(): void; logOut(): void;
logIn(info: BackendCredentials): void; expired(): void;
logIn(info: {username: string, token: AccessToken}): void;
} }
const BACKEND_STATE_KEY = buildStorageKey( const BACKEND_STATE_KEY = buildStorageKey(
@ -124,12 +145,22 @@ export function useBackendState(): BackendStateHandler {
BACKEND_STATE_KEY, BACKEND_STATE_KEY,
defaultState, defaultState,
); );
const mutateAll = useMatchMutate();
return { return {
state, state,
logOut() { logOut() {
update(defaultState); update(defaultState);
}, },
expired() {
if (state.status === "loggedOut") return;
const nextState: BackendState = {
status: "expired",
username: state.username,
isUserAdministrator: state.username === "admin",
};
update(nextState);
},
logIn(info) { logIn(info) {
//admin is defined by the username //admin is defined by the username
const nextState: BackendState = { const nextState: BackendState = {
@ -138,6 +169,7 @@ export function useBackendState(): BackendStateHandler {
isUserAdministrator: info.username === "admin", isUserAdministrator: info.username === "admin",
}; };
update(nextState); update(nextState);
mutateAll(/.*/)
}, },
}; };
} }
@ -150,7 +182,7 @@ interface useBackendType {
fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>;
multiFetcher: <T>(endpoint: string[][]) => Promise<HttpResponseOk<T>[]>; multiFetcher: <T>(endpoint: string[][]) => Promise<HttpResponseOk<T>[]>;
paginatedFetcher: <T>( paginatedFetcher: <T>(
args: [string, number, number], args: [string, string | undefined, number],
) => Promise<HttpResponseOk<T>>; ) => Promise<HttpResponseOk<T>>;
sandboxAccountsFetcher: <T>( sandboxAccountsFetcher: <T>(
args: [string, number, number, string], args: [string, number, number, string],
@ -179,13 +211,15 @@ export function usePublicBackend(): useBackendType {
[baseUrl], [baseUrl],
); );
const paginatedFetcher = useCallback( const paginatedFetcher = useCallback(
function fetcherImpl<T>([endpoint, page, size]: [ function fetcherImpl<T>([endpoint, start, size]: [
string, string,
number, string | undefined,
number, number,
]): Promise<HttpResponseOk<T>> { ]): Promise<HttpResponseOk<T>> {
const delta = -1 * size //descending order
const params = start ? { delta, start } : { delta }
return requestHandler<T>(baseUrl, endpoint, { return requestHandler<T>(baseUrl, endpoint, {
params: { page: page || 1, size }, params,
}); });
}, },
[baseUrl], [baseUrl],
@ -247,35 +281,12 @@ interface InvalidationResult {
error: unknown; 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<CheckResult> {
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 { export function useAuthenticatedBackend(): useBackendType {
const { state } = useBackendContext(); const { state } = useBackendContext();
const { request: requestHandler } = useApiContext(); 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 baseUrl = getInitialBackendBaseURL();
const request = useCallback( const request = useCallback(
@ -283,26 +294,28 @@ export function useAuthenticatedBackend(): useBackendType {
path: string, path: string,
options: RequestOptions = {}, options: RequestOptions = {},
): Promise<HttpResponseOk<T>> { ): Promise<HttpResponseOk<T>> {
return requestHandler<T>(baseUrl, path, { basicAuth: creds, ...options }); return requestHandler<T>(baseUrl, path, { token: creds, ...options });
}, },
[baseUrl, creds], [baseUrl, creds],
); );
const fetcher = useCallback( const fetcher = useCallback(
function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> { function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> {
return requestHandler<T>(baseUrl, endpoint, { basicAuth: creds }); return requestHandler<T>(baseUrl, endpoint, { token: creds });
}, },
[baseUrl, creds], [baseUrl, creds],
); );
const paginatedFetcher = useCallback( const paginatedFetcher = useCallback(
function fetcherImpl<T>([endpoint, page = 1, size]: [ function fetcherImpl<T>([endpoint, start, size]: [
string, string,
number, string | undefined,
number, number,
]): Promise<HttpResponseOk<T>> { ]): Promise<HttpResponseOk<T>> {
const delta = -1 * size //descending order
const params = start ? { delta, start } : { delta }
return requestHandler<T>(baseUrl, endpoint, { return requestHandler<T>(baseUrl, endpoint, {
basicAuth: creds, token: creds,
params: { page, size }, params,
}); });
}, },
[baseUrl, creds], [baseUrl, creds],
@ -313,7 +326,7 @@ export function useAuthenticatedBackend(): useBackendType {
> { > {
return Promise.all( return Promise.all(
endpoints.map((endpoint) => endpoints.map((endpoint) =>
requestHandler<T>(baseUrl, endpoint, { basicAuth: creds }), requestHandler<T>(baseUrl, endpoint, { token: creds }),
), ),
); );
}, },
@ -327,7 +340,7 @@ export function useAuthenticatedBackend(): useBackendType {
string, string,
]): Promise<HttpResponseOk<T>> { ]): Promise<HttpResponseOk<T>> {
return requestHandler<T>(baseUrl, endpoint, { return requestHandler<T>(baseUrl, endpoint, {
basicAuth: creds, token: creds,
params: { page: page || 1, size }, params: { page: page || 1, size },
}); });
}, },
@ -339,7 +352,7 @@ export function useAuthenticatedBackend(): useBackendType {
HttpResponseOk<T> HttpResponseOk<T>
> { > {
return requestHandler<T>(baseUrl, endpoint, { return requestHandler<T>(baseUrl, endpoint, {
basicAuth: creds, token: creds,
params: { account }, params: { account },
}); });
}, },

View File

@ -33,6 +33,7 @@ import {
// FIX default import https://github.com/microsoft/TypeScript/issues/49189 // FIX default import https://github.com/microsoft/TypeScript/issues/49189
import _useSWR, { SWRHook } from "swr"; import _useSWR, { SWRHook } from "swr";
import { AmountJson, Amounts } from "@gnu-taler/taler-util"; import { AmountJson, Amounts } from "@gnu-taler/taler-util";
import { AccessToken } from "./useCredentialsChecker.js";
const useSWR = _useSWR as unknown as SWRHook; const useSWR = _useSWR as unknown as SWRHook;
export function useAdminAccountAPI(): AdminAccountAPI { export function useAdminAccountAPI(): AdminAccountAPI {
@ -90,7 +91,8 @@ export function useAdminAccountAPI(): AdminAccountAPI {
await mutateAll(/.*/); await mutateAll(/.*/);
logIn({ logIn({
username: account, username: account,
password: data.new_password, //FIXME: change password api
token: data.new_password as AccessToken,
}); });
} }
return res; return res;
@ -215,14 +217,15 @@ export interface CircuitAccountAPI {
async function getBusinessStatus( async function getBusinessStatus(
request: ReturnType<typeof useApiContext>["request"], request: ReturnType<typeof useApiContext>["request"],
basicAuth: { username: string; password: string }, username: string,
token: AccessToken,
): Promise<boolean> { ): Promise<boolean> {
try { try {
const url = getInitialBackendBaseURL(); const url = getInitialBackendBaseURL();
const result = await request<SandboxBackend.Circuit.CircuitAccountData>( const result = await request<SandboxBackend.Circuit.CircuitAccountData>(
url, url,
`circuit-api/accounts/${basicAuth.username}`, `circuit-api/accounts/${username}`,
{ basicAuth }, { token },
); );
return result.ok; return result.ok;
} catch (error) { } catch (error) {
@ -264,10 +267,10 @@ type CashoutEstimators = {
export function useEstimator(): CashoutEstimators { export function useEstimator(): CashoutEstimators {
const { state } = useBackendContext(); const { state } = useBackendContext();
const { request } = useApiContext(); const { request } = useApiContext();
const basicAuth = const creds =
state.status === "loggedOut" state.status !== "loggedIn"
? undefined ? undefined
: { username: state.username, password: state.password }; : state.token;
return { return {
estimateByCredit: async (amount, fee, rate) => { estimateByCredit: async (amount, fee, rate) => {
const zeroBalance = Amounts.zeroOfCurrency(fee.currency); const zeroBalance = Amounts.zeroOfCurrency(fee.currency);
@ -282,7 +285,7 @@ export function useEstimator(): CashoutEstimators {
url, url,
`circuit-api/cashouts/estimates`, `circuit-api/cashouts/estimates`,
{ {
basicAuth, token: creds,
params: { params: {
amount_credit: Amounts.stringify(amount), amount_credit: Amounts.stringify(amount),
}, },
@ -313,7 +316,7 @@ export function useEstimator(): CashoutEstimators {
url, url,
`circuit-api/cashouts/estimates`, `circuit-api/cashouts/estimates`,
{ {
basicAuth, token: creds,
params: { params: {
amount_debit: Amounts.stringify(amount), amount_debit: Amounts.stringify(amount),
}, },
@ -337,13 +340,13 @@ export function useBusinessAccountFlag(): boolean | undefined {
const { state } = useBackendContext(); const { state } = useBackendContext();
const { request } = useApiContext(); const { request } = useApiContext();
const creds = const creds =
state.status === "loggedOut" state.status !== "loggedIn"
? undefined ? undefined
: { username: state.username, password: state.password }; : {user: state.username, token: state.token};
useEffect(() => { useEffect(() => {
if (!creds) return; if (!creds) return;
getBusinessStatus(request, creds) getBusinessStatus(request, creds.user, creds.token)
.then((result) => { .then((result) => {
setIsBusiness(result); setIsBusiness(result);
}) })
@ -432,7 +435,7 @@ export function useBusinessAccounts(
HttpResponseOk<SandboxBackend.Circuit.CircuitAccounts>, HttpResponseOk<SandboxBackend.Circuit.CircuitAccounts>,
RequestError<SandboxBackend.SandboxError> RequestError<SandboxBackend.SandboxError>
>( >(
[`circuit-api/accounts`, args?.page, PAGE_SIZE, args?.account], [`accounts`, args?.page, PAGE_SIZE, args?.account],
sandboxAccountsFetcher, sandboxAccountsFetcher,
{ {
refreshInterval: 0, refreshInterval: 0,

View File

@ -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<typeof useApiContext>["request"],
): Promise<SandboxBackend.Config> {
const url = getInitialBackendBaseURL();
const result = await request<SandboxBackend.Config>(url, `config`);
return result.data;
}
export type ConfigResult = undefined
| { type: "ok", result: Required<SandboxBackend.Config> }
| { type: "wrong", result: SandboxBackend.Config }
| { type: "error", result: HttpError<SandboxBackend.SandboxError> }
export function useConfigState(): ConfigResult {
const [checked, setChecked] = useState<ConfigResult>()
const { request } = useApiContext();
useEffect(() => {
getConfigState(request)
.then((result) => {
const r = LibtoolVersion.compare(BANK_INTEGRATION_PROTOCOL_VERSION, result.version)
if (r?.compatible) {
const complete: Required<SandboxBackend.Config> = {
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;
}

View File

@ -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<NotificationMessage>();
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<NotificationMessage | undefined>,
] {
const [value, setter] = useState<NotificationMessage | undefined>();
useEffect(() => {
return storage.onUpdate(NOTIFICATION_KEY, () => {
setter(storage.get(NOTIFICATION_KEY));
});
});
return [value, setter];
}

View File

@ -15,8 +15,12 @@
*/ */
import { import {
AmountString,
Codec, Codec,
buildCodecForObject, buildCodecForObject,
codecForAmountString,
codecForBoolean,
codecForNumber,
codecForString, codecForString,
codecOptional, codecOptional,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
@ -24,15 +28,33 @@ import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser";
interface Settings { interface Settings {
currentWithdrawalOperationId: string | undefined; currentWithdrawalOperationId: string | undefined;
showWithdrawalSuccess: boolean;
showDemoDescription: boolean;
showInstallWallet: boolean;
maxWithdrawalAmount: number;
fastWithdrawal: boolean;
showDebugInfo: boolean;
} }
export const codecForSettings = (): Codec<Settings> => export const codecForSettings = (): Codec<Settings> =>
buildCodecForObject<Settings>() buildCodecForObject<Settings>()
.property("currentWithdrawalOperationId", codecOptional(codecForString())) .property("currentWithdrawalOperationId", codecOptional(codecForString()))
.property("showWithdrawalSuccess", (codecForBoolean()))
.property("showDemoDescription", (codecForBoolean()))
.property("showInstallWallet", (codecForBoolean()))
.property("fastWithdrawal", (codecForBoolean()))
.property("showDebugInfo", (codecForBoolean()))
.property("maxWithdrawalAmount", codecForNumber())
.build("Settings"); .build("Settings");
const defaultSettings: Settings = { const defaultSettings: Settings = {
currentWithdrawalOperationId: undefined, currentWithdrawalOperationId: undefined,
showWithdrawalSuccess: true,
showDemoDescription: true,
showInstallWallet: true,
maxWithdrawalAmount: 25,
fastWithdrawal: false,
showDebugInfo: false,
}; };
const DEMOBANK_SETTINGS_KEY = buildStorageKey( const DEMOBANK_SETTINGS_KEY = buildStorageKey(

View File

@ -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<LoginResult> {
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<LoginTokenSuccessResponse>(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<LoginResult> {
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;
}

View File

@ -16,7 +16,8 @@
@author Sebastian Javier Marchano @author Sebastian Javier Marchano
--> -->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="h-full bg-gray-100">
<head> <head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" /> <meta http-equiv="content-type" content="text/html; charset=utf-8" />
<meta charset="utf-8" /> <meta charset="utf-8" />
@ -24,10 +25,8 @@
<meta name="taler-support" content="uri"> <meta name="taler-support" content="uri">
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<link <link rel="icon"
rel="icon" href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" />
href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
/>
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" /> <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
<title>Demobank</title> <title>Demobank</title>
<!-- Optional customization script. --> <!-- Optional customization script. -->
@ -36,7 +35,9 @@
<script type="module" src="index.js"></script> <script type="module" src="index.js"></script>
<link rel="stylesheet" href="index.css" /> <link rel="stylesheet" href="index.css" />
</head> </head>
<body>
<body class="h-full">
<div id="app"></div> <div id="app"></div>
</body> </body>
</html> </html>

View File

@ -16,7 +16,7 @@
import App from "./components/app.js"; import App from "./components/app.js";
import { h, render } from "preact"; import { h, render } from "preact";
import "./scss/main.scss"; import "./scss/main.css"
const app = document.getElementById("app"); const app = document.getElementById("app");

View File

@ -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 <http://www.gnu.org/licenses/>
*/
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: <T>(
error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
) => VNode;
}
export const CopyIcon = (): VNode => (
<svg height="16" viewBox="0 0 16 16" width="16">
<path
fill-rule="evenodd"
d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"
/>
<path
fill-rule="evenodd"
d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"
/>
</svg>
);
export const CopiedIcon = (): VNode => (
<svg height="16" viewBox="0 0 16 16" width="16">
<path
fill-rule="evenodd"
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
/>
</svg>
);
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 (
<button onClick={copyText} style={{width:32, height:32, fontSize: "initial"}}>
<CopyIcon />
</button>
);
}
return (
<div content="Copied" style={{display:"inline-block"}}>
<button disabled style={{width:32, height:32 , fontSize: "initial"}}>
<CopiedIcon />
</button>
</div>
);
}
/**
* 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 <LoginForm />;
}
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 (
<div>Payto from server is not valid &quot;{data.paytoUri}&quot;</div>
);
}
const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
const limit = balanceIsDebit
? Amounts.sub(debitThreshold, balance).amount
: Amounts.add(balance, debitThreshold).amount;
return (
<Fragment>
<div>
<h1 class="nav welcome-text">
<i18n.Translate>
Welcome, {account} (<a href={stringifyPaytoUri(payto)}>{payto.iban}</a>)! <CopyButton getContent={() => stringifyPaytoUri(payto)} />
</i18n.Translate>
</h1>
</div>
<section id="assets">
<div class="asset-summary">
<h2>{i18n.str`Bank account balance`}</h2>
{!balance ? (
<div class="large-amount" style={{ color: "gray" }}>
Waiting server response...
</div>
) : (
<div class="large-amount amount">
{balanceIsDebit ? <b>-</b> : null}
<span class="value">{`${Amounts.stringifyValue(balance)}`}</span>
&nbsp;
<span class="currency">{`${balance.currency}`}</span>
</div>
)}
</div>
</section>
<section id="payments">
<div class="payments">
<h2>{i18n.str`Payments`}</h2>
<PaymentOptions limit={limit} />
</div>
</section>
<section style={{ marginTop: "2em" }}>
<div class="active">
<h3>{i18n.str`Latest transactions`}</h3>
<Transactions account={account} />
</div>
</section>
</Fragment>
);
}

View File

@ -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 <http://www.gnu.org/licenses/>
*/
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: <T>(
error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
) => 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<SandboxBackend.SandboxError>;
}
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<SandboxBackend.CoreBank.AccountData>;
}
export interface UserNotFound {
status: "error-user-not-found",
error: HttpError<any>;
onRegister?: () => void;
}
}
export interface Transaction {
negative: boolean;
counterpart: string;
when: AbsoluteTime;
amount: AmountJson | undefined;
subject: string;
}
const viewMapping: utils.StateViewMap<State> = {
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,
);

View File

@ -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 <http://www.gnu.org/licenses/>
*/
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,
};
}

View File

@ -1,6 +1,6 @@
/* /*
This file is part of GNU Taler 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 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 terms of the GNU General Public License as published by the Free Software
@ -19,17 +19,11 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
footer.footer { import * as tests from "@gnu-taler/web-util/testing";
.logo { import { ReadyView } from "./views.js";
img {
width: auto;
height: $footer-logo-height;
}
}
}
@include mobile { export default {
.footer-copyright { title: "account page",
text-align: center; };
}
} export const Ready = tests.createExample(ReadyView, {});

View File

@ -1,6 +1,6 @@
/* /*
This file is part of GNU Taler 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 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 terms of the GNU General Public License as published by the Free Software
@ -19,16 +19,14 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
@mixin transition($t) { import * as tests from "@gnu-taler/web-util/testing";
transition: $t 250ms ease-in-out 50ms; 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) { describe("Account states", () => {
.icon { it("should do some tests", async () => {
width: $icon-base-width; });
});
&.has-update-mark:after {
right: ($icon-base-width / 2) - 0.85;
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>
*/
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 (
<div>Payto from server is not valid &quot;{error.data.payto_uri}&quot;</div>
);
}
const IS_PUBLIC_ACCOUNT_ENABLED = false
function ShowDemoInfo(): VNode {
const { i18n } = useTranslationContext();
const [settings, updateSettings] = useSettings();
if (!settings.showDemoDescription) return <Fragment />
return <Attention title={i18n.str`This is a demo bank`} onClose={() => {
updateSettings("showDemoDescription", false);
}}>
{IS_PUBLIC_ACCOUNT_ENABLED ? (
<i18n.Translate>
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{" "}
<a href="/public-accounts">Public Accounts</a>.
</i18n.Translate>
) : (
<i18n.Translate>
This part of the demo shows how a bank that supports Taler
directly would work.
</i18n.Translate>
)}
</Attention>
}
export function ReadyView({ account, limit, goToBusinessAccount, goToConfirmOperation }: State.Ready): VNode<{}> {
const { i18n } = useTranslationContext();
return <Fragment>
<MaybeBusinessButton account={account} onClick={goToBusinessAccount} />
<ShowDemoInfo />
<PaymentOptions limit={limit} goToConfirmOperation={goToConfirmOperation} />
<Transactions account={account} />
</Fragment>;
}
function MaybeBusinessButton({
account,
onClick,
}: {
account: string;
onClick: () => void;
}): VNode {
const { i18n } = useTranslationContext();
const result = useBusinessAccountDetails(account);
if (!result.ok) return <Fragment />;
return (
<div class="w-full flex justify-end">
<button
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
onClick={(e) => {
e.preventDefault()
onClick()
}}
>
<i18n.Translate>Business Profile</i18n.Translate>
</button>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -14,283 +14,362 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { Logger, TranslatedString } from "@gnu-taler/taler-util"; import { Amounts, Logger, TranslatedString, parsePaytoUri } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { notifyError, notifyException, useNotifications, useTranslationContext } from "@gnu-taler/web-util/browser";
import { ComponentChildren, Fragment, h, VNode } from "preact"; import { ComponentChildren, Fragment, VNode, h } from "preact";
import { StateUpdater, useEffect, useState } from "preact/hooks"; import { useEffect, useErrorBoundary, useState } from "preact/hooks";
import talerLogo from "../assets/logo-white.svg"; import logo from "../assets/logo-2021.svg";
import { LangSelectorLikePy as LangSelector } from "../components/LangSelector.js"; 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 { useBackendContext } from "../context/backend.js";
import { useBusinessAccountDetails } from "../hooks/circuit.js"; import { useAccountDetails } from "../hooks/access.js";
import { bankUiSettings } from "../settings.js";
import { useSettings } from "../hooks/settings.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 GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
const versionText = VERSION const versionText = VERSION
? GIT_HASH ? GIT_HASH
? `Version ${VERSION} (${GIT_HASH.substring(0, 8)})` ? <a href={`https://git.taler.net/wallet-core.git/tree/?id=${GIT_HASH}`} target="_blank" rel="noreferrer noopener">
Version {VERSION} ({GIT_HASH.substring(0, 8)})
</a>
: VERSION : 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 <Fragment />;
return (
<a
href="#"
class="pure-button pure-button-primary"
onClick={(e) => {
e.preventDefault();
onClick();
}}
>{i18n.str`Business Profile`}</a>
);
}
export function BankFrame({ export function BankFrame({
children, children,
goToBusinessAccount, account,
}: { }: {
account?: string,
children: ComponentChildren; children: ComponentChildren;
goToBusinessAccount?: () => void;
}): VNode { }): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext(); const backend = useBackendContext();
const [settings, updateSettings] = useSettings(); 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 = []; const demo_sites = [];
if (bankUiSettings.demoSites) {
for (const i in bankUiSettings.demoSites) for (const i in bankUiSettings.demoSites)
demo_sites.push( demo_sites.push(
<a href={bankUiSettings.demoSites[i][1]}> <a href={bankUiSettings.demoSites[i][1]}>
{bankUiSettings.demoSites[i][0]} {bankUiSettings.demoSites[i][0]}
</a>, </a>,
); );
}
return ( return (<div class="min-h-full flex flex-col m-0" style="min-height: 100vh;">
<Fragment> <div class="bg-indigo-600 pb-32">
<header <nav class="">
class="demobar" <div class="mx-auto max-w-7xl px-2 sm:px-4 lg:px-8">
style="display: flex; flex-direction: row; justify-content: space-between;" <div class="relative flex h-16 items-center justify-between ">
> <div class="flex items-center px-2 lg:px-0">
<a href="#main" class="skip">{i18n.str`Skip to main content`}</a> <div class="flex-shrink-0 bg-white rounded-lg">
<div style="max-width: 50em; margin-left: 2em; margin-right: 2em;"> <a href={bankUiSettings.iconLinkURL ?? "#"}>
<h1> <img
<span class="it"> class="h-8 w-auto"
<a href="/">{bankUiSettings.bankName}</a> src={logo}
</span> alt="Taler"
</h1> style={{ height: "1.5rem", margin: ".5rem" }}
{maybeDemoContent(
<p>
{IS_PUBLIC_ACCOUNT_ENABLED ? (
<i18n.Translate>
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{" "}
<a href="/public-accounts">Public Accounts</a>.
</i18n.Translate>
) : (
<i18n.Translate>
This part of the demo shows how a bank that supports Taler
directly would work.
</i18n.Translate>
)}
</p>,
)}
</div>
</header>
<div style="display:flex; flex-direction: column;" class="navcontainer">
<nav class="demolist">
{maybeDemoContent(<Fragment>{demo_sites}</Fragment>)}
{backend.state.status === "loggedIn" ? (
<Fragment>
{goToBusinessAccount && !backend.state.isUserAdministrator ? (
<MaybeBusinessButton
account={backend.state.username}
onClick={goToBusinessAccount}
/> />
) : undefined} </a>
</div>
{bankUiSettings.demoSites &&
<div class="hidden sm:block lg:ml-10 ">
<div class="flex space-x-4">
{/* <!-- Current: "bg-indigo-700 text-white", Default: "text-white hover:bg-indigo-500 hover:bg-opacity-75" --> */}
{bankUiSettings.demoSites.map(([name, url]) => {
return <a href={url} class="text-white hover:bg-indigo-500 hover:bg-opacity-75 rounded-md py-2 px-3 text-sm font-medium">{name}</a>
})}
</div>
</div>
}
</div>
<LangSelector /> <div class="flex">
<button type="button" class="relative inline-flex items-center justify-center rounded-md bg-indigo-600 p-1 text-indigo-200 hover:bg-indigo-500 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" aria-controls="mobile-menu" aria-expanded="false"
onClick={(e) => {
setOpen(!open)
}}>
<span class="absolute -inset-0.5"></span>
<span class="sr-only">Open settings</span>
<svg class="block h-10 w-10" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
</div>
</div>
</div>
<a {open &&
href="#" <div class="relative z-10" aria-labelledby="slide-over-title" role="dialog" aria-modal="true"
class="pure-button logout-button" onClick={() => {
setOpen(false)
}}>
<div class="fixed inset-0"></div>
<div class="fixed inset-0 overflow-hidden">
<div class="absolute inset-0 overflow-hidden">
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
<div class="pointer-events-auto w-screen max-w-md" >
<div class="flex h-full flex-col overflow-y-scroll bg-white py-6 shadow-xl" onClick={(e) => {
//do not trigger close if clicking inside the sidebar
e.stopPropagation();
}}>
<div class="px-4 sm:px-6" >
<div class="flex items-start justify-between" >
<h2 class="text-base font-semibold leading-6 text-gray-900" id="slide-over-title">
<i18n.Translate>Preferences</i18n.Translate>
</h2>
<div class="ml-3 flex h-7 items-center">
<button type="button" class="relative rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
onClick={(e) => {
setOpen(false)
}}
>
<span class="absolute -inset-2.5"></span>
<span class="sr-only">
<i18n.Translate>Close panel</i18n.Translate>
</span>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<div class="relative mt-6 flex-1 px-4 sm:px-6">
<nav class="flex flex-1 flex-col" aria-label="Sidebar">
<ul role="list" class="flex flex-1 flex-col gap-y-7">
<li>
<a href="#"
class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"
onClick={() => { onClick={() => {
backend.logOut(); backend.logOut();
setOpen(false)
updateSettings("currentWithdrawalOperationId", undefined); updateSettings("currentWithdrawalOperationId", undefined);
}} }}
>{i18n.str`Logout`}</a> >
</Fragment> <svg class="h-6 w-6 shrink-0 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
) : undefined} <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
<i18n.Translate>Log out</i18n.Translate>
</a>
</li>
<li>
<LangSelector />
</li>
{bankUiSettings.demoSites &&
<li class="sm:hidden">
<div class="text-xs font-semibold leading-6 text-gray-400">
<i18n.Translate>Sites</i18n.Translate>
</div>
<ul role="list" class="-mx-2 mt-2 space-y-1">
{bankUiSettings.demoSites.map(([name, url]) => {
return <li>
<a href={url} target="_blank" rel="noopener noreferrer" class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border text-[0.625rem] font-medium bg-white text-gray-400 border-gray-200 group-hover:border-indigo-600 group-hover:text-indigo-600">&gt;</span>
<span class="truncate">{name}</span>
</a>
</li>
})}
</ul>
</li>
}
<li>
<ul role="list" class="space-y-1">
<li class="mt-2">
<div class="flex items-center justify-between">
<span class="flex flex-grow flex-col">
<span class="text-sm text-black font-medium leading-6 " id="availability-label">
<i18n.Translate>Show withdrawal confirmation</i18n.Translate>
</span>
</span>
<button type="button" data-enabled={settings.showWithdrawalSuccess} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description"
onClick={() => {
updateSettings("showWithdrawalSuccess", !settings.showWithdrawalSuccess);
}}>
<span aria-hidden="true" data-enabled={settings.showWithdrawalSuccess} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
</button>
</div>
</li>
<li class="mt-2">
<div class="flex items-center justify-between">
<span class="flex flex-grow flex-col">
<span class="text-sm text-black font-medium leading-6 " id="availability-label">
<i18n.Translate>Show demo description</i18n.Translate>
</span>
</span>
<button type="button" data-enabled={settings.showDemoDescription} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description"
onClick={() => {
updateSettings("showDemoDescription", !settings.showDemoDescription);
}}>
<span aria-hidden="true" data-enabled={settings.showDemoDescription} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
</button>
</div>
</li>
<li class="mt-2">
<div class="flex items-center justify-between">
<span class="flex flex-grow flex-col">
<span class="text-sm text-black font-medium leading-6 " id="availability-label">
<i18n.Translate>Show debug info</i18n.Translate>
</span>
</span>
<button type="button" data-enabled={settings.showDebugInfo} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description"
onClick={() => {
updateSettings("showDebugInfo", !settings.showDebugInfo);
}}>
<span aria-hidden="true" data-enabled={settings.showDebugInfo} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
</button>
</div>
</li>
<li class="mt-2">
<div class="flex items-center justify-between">
<span class="flex flex-grow flex-col">
<span class="text-sm text-black font-medium leading-6 " id="availability-label">
<i18n.Translate>Show install wallet first</i18n.Translate>
</span>
</span>
<button type="button" data-enabled={settings.showInstallWallet} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description"
onClick={() => {
updateSettings("showInstallWallet", !settings.showInstallWallet);
}}>
<span aria-hidden="true" data-enabled={settings.showInstallWallet} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
</button>
</div>
</li>
<li class="mt-2">
<div class="flex items-center justify-between">
<span class="flex flex-grow flex-col">
<span class="text-sm text-black font-medium leading-6 " id="availability-label">
<i18n.Translate>Use fast withdrawal</i18n.Translate>
</span>
</span>
<button type="button" data-enabled={settings.fastWithdrawal} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description"
onClick={() => {
updateSettings("fastWithdrawal", !settings.fastWithdrawal);
}}>
<span aria-hidden="true" data-enabled={settings.fastWithdrawal} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
</button>
</div>
</li>
</ul>
</li>
</ul>
</nav> </nav>
</div> </div>
<section id="main" class="content"> </div>
</div>
</div>
</div>
</div>
</div>
}
</nav >
{account &&
<header class="py-5 border-t border-indigo-300 border-opacity-25 bg-indigo-600 lg:border-t lg:border-indigo-400 lg:border-opacity-25">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class=" flex flex-wrap items-center justify-between sm:flex-nowrap">
<h3 class="text-2xl font-bold tracking-tight text-white"><WelcomeAccount account={account} /></h3>
<div>
<h3 class="text-2xl font-bold tracking-tight text-white"><AccountBalance account={account} /></h3>
</div>
</div>
</div>
</header>
}
</div >
<StatusBanner /> <StatusBanner />
<main class="-mt-32 flex-1">
<div class="mx-auto max-w-7xl px-4 pb-12 sm:px-6 lg:px-8">
<div class="rounded-lg bg-white px-5 py-6 shadow sm:px-6">
{children} {children}
</section>
<section id="footer" class="footer">
<hr />
<div>
<p>
You can learn more about GNU Taler on our{" "}
<a href="https://taler.net">main website</a>.
</p>
</div> </div>
<div style="flex-grow:1" /> </div>
<p> </main>
Copyright &copy; 2014&mdash;2022 Taler Systems SA. {versionText}{" "}
<TestingTag /> <Footer />
</p> </div >
</section>
</Fragment>
); );
} }
function maybeDemoContent(content: VNode): VNode { function MaybeShowDebugInfo({ info }: { info: any }): VNode {
if (bankUiSettings.showDemoNav) { const [settings] = useSettings()
return content; if (settings.showDebugInfo) {
return <pre class="whitespace-break-spaces ">
{info}
</pre>
} }
return <Fragment />; return <Fragment />
} }
export function ErrorBannerFloat({
error,
onClear,
}: {
error: ErrorMessage;
onClear?: () => void;
}): VNode {
return (
<div
style={{
position: "fixed",
top: 10,
zIndex: 200,
width: "90%",
}}
>
<ErrorBanner error={error} onClear={onClear} />
</div>
);
}
function ErrorBanner({ function StatusBanner(): VNode {
error, const notifs = useNotifications()
onClear, if (notifs.length === 0) return <Fragment />
}: { return <div class="fixed z-20 w-full p-4"> {
error: ErrorMessage; notifs.map(n => {
onClear?: () => void; switch (n.message.type) {
}): VNode { case "error":
return ( return <Attention type="danger" title={n.message.title} onClose={() => {
<div n.remove()
class="informational informational-fail" }}>
style={{ {n.message.description &&
marginTop: 8, <div class="mt-2 text-sm text-red-700">
paddingLeft: 16, {n.message.description}
paddingRight: 16,
}}
>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<p>
<b>{error.title}</b>
</p>
<div style={{ marginTop: "auto", marginBottom: "auto" }}>
{onClear && (
<input
type="button"
class="pure-button"
value="Clear"
onClick={(e) => {
e.preventDefault();
onClear();
}}
/>
)}
</div> </div>
</div>
<p>{error.description}</p>
</div>
);
} }
<MaybeShowDebugInfo info={n.message.debug} />
{/* <a href="#" class="text-gray-500">
show debug info
</a>
{n.message.debug &&
<div class="mt-2 text-sm text-red-700 font-mono break-all">
{n.message.debug}
</div>
} */}
</Attention>
case "info":
return <Attention type="success" title={n.message.title} onClose={() => {
n.remove();
}} />
}
})}
</div>
function StatusBanner(): VNode | null {
const [info, setInfo] = useState<TranslatedString>();
const [error, setError] = useState<ErrorMessage>();
useEffect(() => {
return onNotificationUpdate((newValue) => {
if (newValue === undefined) {
setInfo(undefined);
setError(undefined);
} else {
if (newValue.type === "error") {
setError(newValue.error);
} else {
setInfo(newValue.info);
}
}
});
}, []);
return (
<div
style={{
position: "fixed",
top: 10,
zIndex: 200,
width: "90%",
}}
>
{!info ? undefined : (
<div
class="informational informational-ok"
style={{ marginTop: 8, paddingLeft: 16, paddingRight: 16 }}
>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<p>
<b>{info}</b>
</p>
<div>
<input
type="button"
class="pure-button"
value="Clear"
onClick={async () => {
setInfo(undefined);
}}
/>
</div>
</div>
</div>
)}
{!error ? undefined : (
<ErrorBanner
error={error}
onClear={() => {
setError(undefined);
}}
/>
)}
</div>
);
} }
function TestingTag(): VNode { function TestingTag(): VNode {
const testingUrl = localStorage.getItem("bank-base-url"); const testingUrl = localStorage.getItem("bank-base-url");
if (!testingUrl) return <Fragment />; if (!testingUrl) return <Fragment />;
return ( return (
<span style={{ color: "gray" }}> <p class="text-xs leading-5 text-gray-300">
Testing with {testingUrl}{" "} Testing with {testingUrl}{" "}
<a <a
href="" href=""
@ -302,6 +381,58 @@ function TestingTag(): VNode {
> >
stop testing stop testing
</a> </a>
</span> </p>
); );
} }
function Footer() {
const { i18n } = useTranslationContext()
return (
<footer class="bottom-4 mb-4">
<div class="mt-8 mx-8 md:order-1 md:mt-0">
<div>
<p class="text-xs leading-5 text-gray-400">
<i18n.Translate>
Learn more about <a target="_blank" rel="noreferrer noopener" class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net">GNU Taler</a>
</i18n.Translate>
</p>
</div>
<div style="flex-grow:1" />
<p class="text-xs leading-5 text-gray-400">
Copyright &copy; 2014&mdash;2023 Taler Systems SA. {versionText}{" "}
<TestingTag />
</p>
</div>
</footer>
);
}
function WelcomeAccount({ account }: { account: string }): VNode {
const { i18n } = useTranslationContext();
const result = useAccountDetails(account);
if (!result.ok) return <div />
const payto = parsePaytoUri(result.data.payto_uri)
if (!payto) return <div />
const accountNumber = !payto.isKnown ? undefined : payto.targetType === "iban" ? payto.iban : payto.targetType === "x-taler-bank" ? payto.account : undefined;
return <i18n.Translate>
Welcome, {account} {accountNumber !== undefined ?
<span>
(<a href={result.data.payto_uri}>{accountNumber}</a> <CopyButton getContent={() => result.data.payto_uri} />)
</span>
: <Fragment />}!
</i18n.Translate>
}
function AccountBalance({ account }: { account: string }): VNode {
const result = useAccountDetails(account);
if (!result.ok) return <div />
return <RenderAmount
value={Amounts.parseOrThrow(result.data.balance.amount)}
negative={result.data.balance.credit_debit_indicator === "debit"}
/>
}

View File

@ -17,6 +17,7 @@
import { import {
HttpStatusCode, HttpStatusCode,
Logger, Logger,
TranslatedString,
parseWithdrawUri, parseWithdrawUri,
stringifyWithdrawUri, stringifyWithdrawUri,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
@ -24,18 +25,18 @@ import {
ErrorType, ErrorType,
HttpResponse, HttpResponse,
HttpResponsePaginated, HttpResponsePaginated,
notify,
notifyError,
useTranslationContext, useTranslationContext,
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact"; import { Fragment, VNode, h } from "preact";
import { Loading } from "../components/Loading.js"; import { Loading } from "../components/Loading.js";
import { useBackendContext } from "../context/backend.js";
import { getInitialBackendBaseURL } from "../hooks/backend.js"; import { getInitialBackendBaseURL } from "../hooks/backend.js";
import { notifyError, notifyInfo } from "../hooks/notification.js";
import { useSettings } from "../hooks/settings.js"; import { useSettings } from "../hooks/settings.js";
import { AccountPage } from "./AccountPage.js"; import { AccountPage } from "./AccountPage/index.js";
import { AdminPage } from "./AdminPage.js";
import { LoginForm } from "./LoginForm.js"; import { LoginForm } from "./LoginForm.js";
import { WithdrawalQRCode } from "./WithdrawalQRCode.js"; import { WithdrawalQRCode } from "./WithdrawalQRCode.js";
import { route } from "preact-router";
const logger = new Logger("AccountPage"); const logger = new Logger("AccountPage");
@ -51,73 +52,66 @@ const logger = new Logger("AccountPage");
*/ */
export function HomePage({ export function HomePage({
onRegister, onRegister,
onPendingOperationFound, account,
goToConfirmOperation,
goToBusinessAccount,
}: { }: {
onPendingOperationFound: (id: string) => void; account: string,
onRegister: () => void; onRegister: () => void;
goToBusinessAccount: () => void;
goToConfirmOperation: (id: string) => void;
}): VNode { }): VNode {
const backend = useBackendContext();
const [settings] = useSettings();
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
if (backend.state.status === "loggedOut") {
return <LoginForm onRegister={onRegister} />;
}
if (settings.currentWithdrawalOperationId) {
onPendingOperationFound(settings.currentWithdrawalOperationId);
return <Loading />;
}
if (backend.state.isUserAdministrator) {
return <AdminPage onRegister={onRegister} />;
}
return ( return (
<AccountPage <AccountPage
account={backend.state.username} account={account}
onLoadNotOk={handleNotOkResult(i18n, onRegister)} goToConfirmOperation={goToConfirmOperation}
goToBusinessAccount={goToBusinessAccount}
onLoadNotOk={handleNotOkResult(i18n)}
/> />
); );
} }
export function WithdrawalOperationPage({ export function WithdrawalOperationPage({
operationId, operationId,
onLoadNotOk,
onContinue, onContinue,
}: { }: {
operationId: string; operationId: string;
onLoadNotOk: () => void;
onContinue: () => void; onContinue: () => void;
}): VNode { }): VNode {
//FIXME: libeufin sandbox should return show to create the integration api endpoint //FIXME: libeufin sandbox should return show to create the integration api endpoint
//or return withdrawal uri from response //or return withdrawal uri from response
const baseUrl = getInitialBackendBaseURL()
const uri = stringifyWithdrawUri({ const uri = stringifyWithdrawUri({
bankIntegrationApiBaseUrl: `${getInitialBackendBaseURL()}/integration-api`, bankIntegrationApiBaseUrl: `${baseUrl}/taler-integration`,
withdrawalOperationId: operationId, withdrawalOperationId: operationId,
}); });
const parsedUri = parseWithdrawUri(uri); const parsedUri = parseWithdrawUri(uri);
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const [settings, updateSettings] = useSettings();
if (!parsedUri) { if (!parsedUri) {
notifyError({ notifyError(
title: i18n.str`The Withdrawal URI is not valid: "${uri}"`, i18n.str`The Withdrawal URI is not valid`,
}); uri as TranslatedString
);
return <Loading />; return <Loading />;
} }
return ( return (
<WithdrawalQRCode <WithdrawalQRCode
withdrawUri={parsedUri} withdrawUri={parsedUri}
onContinue={onContinue} onClose={() => {
onLoadNotOk={onLoadNotOk} updateSettings("currentWithdrawalOperationId", undefined)
onContinue()
}}
/> />
); );
} }
export function handleNotOkResult( export function handleNotOkResult(
i18n: ReturnType<typeof useTranslationContext>["i18n"], i18n: ReturnType<typeof useTranslationContext>["i18n"],
onRegister?: () => void,
): <T>( ): <T>(
result: result:
| HttpResponsePaginated<T, SandboxBackend.SandboxError> | HttpResponsePaginated<T, SandboxBackend.SandboxError>
@ -125,53 +119,53 @@ export function handleNotOkResult(
) => VNode { ) => VNode {
return function handleNotOkResult2<T>( return function handleNotOkResult2<T>(
result: result:
| HttpResponsePaginated<T, SandboxBackend.SandboxError> | HttpResponsePaginated<T, SandboxBackend.SandboxError | undefined>
| HttpResponse<T, SandboxBackend.SandboxError>, | HttpResponse<T, SandboxBackend.SandboxError | undefined>,
): VNode { ): VNode {
if (result.loading) return <Loading />; if (result.loading) return <Loading />;
if (!result.ok) { if (!result.ok) {
switch (result.type) { switch (result.type) {
case ErrorType.TIMEOUT: { case ErrorType.TIMEOUT: {
notifyError({ notifyError(i18n.str`Request timeout, try again later.`, undefined);
title: i18n.str`Request timeout, try again later.`,
});
break; break;
} }
case ErrorType.CLIENT: { case ErrorType.CLIENT: {
if (result.status === HttpStatusCode.Unauthorized) { if (result.status === HttpStatusCode.Unauthorized) {
notifyError({ notifyError(i18n.str`Wrong credentials`, undefined);
title: i18n.str`Wrong credentials`, return <LoginForm />;
});
return <LoginForm onRegister={onRegister} />;
} }
const errorData = result.payload; const errorData = result.payload;
notifyError({ notify({
title: i18n.str`Could not load due to a client error`, type: "error",
description: errorData.error.description, 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), debug: JSON.stringify(result),
}); });
break; break;
} }
case ErrorType.SERVER: { case ErrorType.SERVER: {
notifyError({ notify({
type: "error",
title: i18n.str`Server returned with 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), debug: JSON.stringify(result.payload),
}); });
break; break;
} }
case ErrorType.UNREADABLE: { case ErrorType.UNREADABLE: {
notifyError({ notify({
type: "error",
title: i18n.str`Unexpected 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), debug: JSON.stringify(result),
}); });
break; break;
} }
case ErrorType.UNEXPECTED: { case ErrorType.UNEXPECTED: {
notifyError({ notify({
type: "error",
title: i18n.str`Unexpected 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), debug: JSON.stringify(result),
}); });
break; break;
@ -180,7 +174,7 @@ export function handleNotOkResult(
assertUnreachable(result); assertUnreachable(result);
} }
} }
// route("/")
return <div>error</div>; return <div>error</div>;
} }
return <div />; return <div />;

View File

@ -14,132 +14,93 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { HttpStatusCode } from "@gnu-taler/taler-util"; import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser"; import { ErrorType, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact"; import { Fragment, VNode, h } from "preact";
import { useEffect, useRef, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";
import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js";
import { useBackendContext } from "../context/backend.js"; import { useBackendContext } from "../context/backend.js";
import { useCredentialsChecker } from "../hooks/backend.js"; import { useCredentialsChecker } from "../hooks/useCredentialsChecker.js";
import { ErrorMessage } from "../hooks/notification.js";
import { bankUiSettings } from "../settings.js"; import { bankUiSettings } from "../settings.js";
import { undefinedIfEmpty } from "../utils.js"; import { undefinedIfEmpty } from "../utils.js";
import { ErrorBannerFloat } from "./BankFrame.js"; import { doAutoFocus } from "./PaytoWireTransferForm.js";
import { USERNAME_REGEX } from "./RegistrationPage.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
/** /**
* Collect and submit login data. * Collect and submit login data.
*/ */
export function LoginForm({ onRegister }: { onRegister?: () => void }): VNode { export function LoginForm({ onRegister }: { onRegister?: () => void }): VNode {
const backend = useBackendContext(); const backend = useBackendContext();
const [username, setUsername] = useState<string | undefined>(); const currentUser = backend.state.status !== "loggedOut" ? backend.state.username : undefined
const [username, setUsername] = useState<string | undefined>(currentUser);
const [password, setPassword] = useState<string | undefined>(); const [password, setPassword] = useState<string | undefined>();
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const testLogin = useCredentialsChecker(); const { requestNewLoginToken, refreshLoginToken } = useCredentialsChecker();
const [error, saveError] = useState<ErrorMessage | undefined>();
/**
* 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<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
useEffect(function focusInput() { useEffect(function focusInput() {
//FIXME: show invalidate session and allow relogin
if (isSessionExpired) {
localStorage.removeItem("backend-state");
window.location.reload()
}
ref.current?.focus(); ref.current?.focus();
}, []); }, []);
const [busy, setBusy] = useState<Record<string, undefined>>()
const errors = undefinedIfEmpty({ const errors = undefinedIfEmpty({
username: !username username: !username
? i18n.str`Missing username` ? i18n.str`Missing username`
: !USERNAME_REGEX.test(username) // : !USERNAME_REGEX.test(username)
? i18n.str`Use letters and numbers only, and start with a lowercase letter` // ? i18n.str`Use letters and numbers only, and start with a lowercase letter`
: undefined, : undefined,
password: !password ? i18n.str`Missing password` : undefined, password: !password ? i18n.str`Missing password` : undefined,
}); }) ?? busy;
return ( function saveError({ title, description, debug }: { title: TranslatedString, description?: TranslatedString, debug?: any }) {
<Fragment> notifyError(title, description, debug)
<h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1> }
{error && (
<ErrorBannerFloat error={error} onClear={() => saveError(undefined)} /> async function doLogout() {
)} backend.logOut()
<div class="login-div"> }
<form
class="login-form" async function doLogin() {
noValidate
onSubmit={(e) => {
e.preventDefault();
}}
autoCapitalize="none"
autoCorrect="off"
>
<div class="pure-form">
<h2>{i18n.str`Please login!`}</h2>
<p class="unameFieldLabel loginFieldLabel formFieldLabel">
<label for="username">{i18n.str`Username:`}</label>
</p>
<input
ref={ref}
autoFocus
type="text"
name="username"
id="username"
value={username ?? ""}
enterkeyhint="next"
placeholder="Username"
autocomplete="username"
required
onInput={(e): void => {
setUsername(e.currentTarget.value);
}}
/>
<ShowInputErrorLabel
message={errors?.username}
isDirty={username !== undefined}
/>
<p class="passFieldLabel loginFieldLabel formFieldLabel">
<label for="password">{i18n.str`Password:`}</label>
</p>
<input
type="password"
name="password"
id="password"
autocomplete="current-password"
enterkeyhint="send"
value={password ?? ""}
placeholder="Password"
required
onInput={(e): void => {
setPassword(e.currentTarget.value);
}}
/>
<ShowInputErrorLabel
message={errors?.password}
isDirty={password !== undefined}
/>
<br />
<button
type="submit"
class="pure-button pure-button-primary"
disabled={!!errors}
onClick={async (e) => {
e.preventDefault();
if (!username || !password) return; if (!username || !password) return;
const testResult = await testLogin(username, password); setBusy({})
if (testResult.valid) { const result = await requestNewLoginToken(username, password);
backend.logIn({ username, password }); if (result.valid) {
backend.logIn({ username, token: result.token });
} else { } else {
if (testResult.requestError) { const { cause } = result;
const { cause } = testResult;
switch (cause.type) { switch (cause.type) {
case ErrorType.CLIENT: { case ErrorType.CLIENT: {
if (cause.status === HttpStatusCode.Unauthorized) { if (cause.status === HttpStatusCode.Unauthorized) {
saveError({ saveError({
title: i18n.str`Wrong credentials for "${username}"`, title: i18n.str`Wrong credentials for "${username}"`,
}); });
} } else
if (cause.status === HttpStatusCode.NotFound) { if (cause.status === HttpStatusCode.NotFound) {
saveError({ saveError({
title: i18n.str`Account not found`, title: i18n.str`Account not found`,
}); });
} else { } else {
saveError({ saveError({
title: i18n.str`Could not load due to a client error`, title: i18n.str`Could not load due to a request error`,
description: cause.payload.error.description, description: i18n.str`Request to url "${cause.info.url}" returned ${cause.info.status}`,
debug: JSON.stringify(cause.payload), debug: JSON.stringify(cause.payload),
}); });
} }
@ -148,7 +109,7 @@ export function LoginForm({ onRegister }: { onRegister?: () => void }): VNode {
case ErrorType.SERVER: { case ErrorType.SERVER: {
saveError({ saveError({
title: i18n.str`Server had a problem, try again later or report.`, title: i18n.str`Server had a problem, try again later or report.`,
description: cause.payload.error.description, // description: cause.payload.error.description,
debug: JSON.stringify(cause.payload), debug: JSON.stringify(cause.payload),
}); });
break; break;
@ -162,7 +123,7 @@ export function LoginForm({ onRegister }: { onRegister?: () => void }): VNode {
case ErrorType.UNREADABLE: { case ErrorType.UNREADABLE: {
saveError({ saveError({
title: i18n.str`Unexpected error.`, title: i18n.str`Unexpected error.`,
description: `Response from ${cause.info?.url} is unreadable, http status: ${cause.status}`, description: `Response from ${cause.info?.url} is unreadable, http status: ${cause.status}` as TranslatedString,
debug: JSON.stringify(cause), debug: JSON.stringify(cause),
}); });
break; break;
@ -170,43 +131,132 @@ export function LoginForm({ onRegister }: { onRegister?: () => void }): VNode {
default: { default: {
saveError({ saveError({
title: i18n.str`Unexpected error, please report.`, title: i18n.str`Unexpected error, please report.`,
description: `Diagnostic from ${cause.info?.url} is "${cause.message}"`, description: `Diagnostic from ${cause.info?.url} is "${cause.message}"` as TranslatedString,
debug: JSON.stringify(cause), debug: JSON.stringify(cause),
}); });
break; break;
} }
} }
} else { // backend.logOut();
saveError({
title: i18n.str`Unexpected error, please report.`,
debug: JSON.stringify(testResult.error),
});
} }
backend.logOut();
}
setUsername(undefined);
setPassword(undefined); setPassword(undefined);
setBusy(undefined)
}
return (
<div class="flex min-h-full flex-col justify-center">
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form class="space-y-6" noValidate
onSubmit={(e) => {
e.preventDefault();
}}
autoCapitalize="none"
autoCorrect="off"
>
<div>
<label for="username" class="block text-sm font-medium leading-6 text-gray-900">
<i18n.Translate>Username</i18n.Translate>
</label>
<div class="mt-2">
<input
ref={doAutoFocus}
type="text"
name="username"
id="username"
class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
value={username ?? ""}
disabled={isSessionExpired}
enterkeyhint="next"
placeholder="identification"
autocomplete="username"
required
onInput={(e): void => {
setUsername(e.currentTarget.value);
}}
/>
<ShowInputErrorLabel
message={errors?.username}
isDirty={username !== undefined}
/>
</div>
</div>
<div>
<div class="flex items-center justify-between">
<label for="password" class="block text-sm font-medium leading-6 text-gray-900">Password</label>
</div>
<div class="mt-2">
<input
type="password"
name="password"
id="password"
autocomplete="current-password"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
enterkeyhint="send"
value={password ?? ""}
placeholder="Password"
required
onInput={(e): void => {
setPassword(e.currentTarget.value);
}}
/>
<ShowInputErrorLabel
message={errors?.password}
isDirty={password !== undefined}
/>
</div>
</div>
{isSessionExpired ? <div class="flex justify-between">
<button type="submit"
class="rounded-md bg-white-600 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600"
onClick={(e) => {
e.preventDefault()
doLogout()
}} }}
> >
{i18n.str`Login`} <i18n.Translate>Cancel</i18n.Translate>
</button> </button>
{bankUiSettings.allowRegistrations && onRegister ? ( <button type="submit"
<button class="rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
class="pure-button pure-button-secondary btn-cancel" disabled={!!errors}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault()
onRegister(); doLogin()
}} }}
> >
{i18n.str`Register`} <i18n.Translate>Renew session</i18n.Translate>
</button> </button>
) : ( </div> : <div>
<div /> <button type="submit"
)} class="flex w-full justify-center rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
</div> disabled={!!errors}
onClick={(e) => {
e.preventDefault()
doLogin()
}}
>
<i18n.Translate>Log in</i18n.Translate>
</button>
</div>}
</form> </form>
{bankUiSettings.allowRegistrations && onRegister &&
<p class="mt-10 text-center text-sm text-gray-500 border-t">
<button type="submit"
class="flex mt-4 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
onClick={(e) => {
e.preventDefault()
onRegister()
}}
>
<i18n.Translate>Register</i18n.Translate>
</button>
</p>
}
</div>
</div> </div>
</Fragment>
); );
} }

View File

@ -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 <http://www.gnu.org/licenses/>
*/
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<SandboxBackend.SandboxError>;
}
/**
* 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<State> = {
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,
);

View File

@ -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 <http://www.gnu.org/licenses/>
*/
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<State> {
const { i18n } = useTranslationContext();
const [settings, updateSettings] = useSettings()
const { createWithdrawal } = useAccessAPI();
const { abortWithdrawal, confirmWithdrawal } = useAccessAnonAPI();
const [busy, setBusy] = useState<Record<string, undefined>>()
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<State> => {
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
}
}
}

View File

@ -1,6 +1,6 @@
/* /*
This file is part of GNU Taler 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 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 terms of the GNU General Public License as published by the Free Software
@ -19,6 +19,11 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
.is-tiles-wrapper { import * as tests from "@gnu-taler/web-util/testing";
margin-bottom: $default-padding; import { ReadyView } from "./views.js";
}
export default {
title: "operation status page",
};
export const Ready = tests.createExample(ReadyView, {});

View File

@ -1,6 +1,6 @@
/* /*
This file is part of GNU Taler 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 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 terms of the GNU General Public License as published by the Free Software
@ -19,17 +19,14 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
.modal-card { import * as tests from "@gnu-taler/web-util/testing";
width: $modal-card-width; 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 { describe("Withdrawal operation states", () => {
background-color: $modal-card-foot-background-color; it("should do some tests", async () => {
} });
});
@include mobile {
.modal .animation-content .modal-card {
width: $modal-card-width-mobile;
margin: 0 auto;
}
}

View File

@ -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 <http://www.gnu.org/licenses/>
*/
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 (
<div>Payto from server is not valid &quot;{payto}&quot;</div>
);
}
export function InvalidWithdrawalView({ uri, onClose }: State.InvalidWithdrawal) {
return (
<div>Withdrawal uri from server is not valid &quot;{uri}&quot;</div>
);
}
export function InvalidReserveView({ reserve, onClose }: State.InvalidReserve) {
return (
<div>Reserve from server is not valid &quot;{reserve}&quot;</div>
);
}
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<string | undefined>();
const answer = parseInt(captchaAnswer ?? "", 10);
const errors = undefinedIfEmpty({
answer: !captchaAnswer
? i18n.str`Answer the question before continue`
: Number.isNaN(answer)
? i18n.str`The answer should be a number`
: answer !== captchaNumbers.a + captchaNumbers.b
? i18n.str`The answer "${answer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.`
: undefined,
}) ?? (busy ? {} as Record<string, undefined> : undefined);
return (
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold text-gray-900">
<i18n.Translate>Confirm the withdrawal operation</i18n.Translate>
</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<div class="px-4 mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-4 sm:gap-x-3">
<label class={"relative sm:col-span-2 flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-noneborder-indigo-600 ring-2 ring-indigo-600"}>
<input type="radio" name="project-type" value="Newsletter" class="sr-only" aria-labelledby="project-type-0-label" aria-describedby="project-type-0-description-0 project-type-0-description-1" />
<span class="flex flex-1">
<span class="flex flex-col">
<span id="project-type-0-label" class="block text-sm font-medium text-gray-900 ">
<i18n.Translate>challenge response test</i18n.Translate>
</span>
</span>
</span>
<svg class="h-5 w-5 text-indigo-600" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</label>
<label class="relative flex cursor-pointer rounded-lg border bg-gray-100 p-4 shadow-sm focus:outline-none border-gray-300">
<input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" />
<span class="flex flex-1">
<span class="flex flex-col">
<span id="project-type-1-label" class="block text-sm font-medium text-gray-900">
<i18n.Translate>using SMS</i18n.Translate>
</span>
<span id="project-type-1-description-0" class="mt-1 flex items-center text-sm text-gray-500">
<i18n.Translate>not available</i18n.Translate>
</span>
</span>
</span>
<svg class="h-5 w-5 text-indigo-600 hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</label>
<label class="relative flex cursor-pointer rounded-lg border bg-gray-100 p-4 shadow-sm focus:outline-none border-gray-300">
<input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" />
<span class="flex flex-1">
<span class="flex flex-col">
<span id="project-type-1-label" class="block text-sm font-medium text-gray-900">
<i18n.Translate>one time password</i18n.Translate>
</span>
<span id="project-type-1-description-0" class="mt-1 flex items-center text-sm text-gray-500">
<i18n.Translate>not available</i18n.Translate>
</span>
</span>
</span>
<svg class="h-5 w-5 text-indigo-600 hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</label>
</div>
</div>
<div class="mt-3 text-sm leading-6">
<form
class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
autoCapitalize="none"
autoCorrect="off"
onSubmit={e => {
e.preventDefault()
}}
>
<div class="px-4 py-6 sm:p-8">
<label for="withdraw-amount">{i18n.str`What is`}&nbsp;
<em>
{captchaNumbers.a}&nbsp;+&nbsp;{captchaNumbers.b}
</em>
?
</label>
<div class="mt-2">
<div class="relative rounded-md shadow-sm">
<input
type="text"
// class="block w-full rounded-md border-0 py-1.5 pl-16 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
aria-describedby="answer"
autoFocus
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
value={captchaAnswer ?? ""}
required
name="answer"
id="answer"
autocomplete="off"
onChange={(e): void => {
setCaptchaAnswer(e.currentTarget.value)
}}
/>
</div>
<ShowInputErrorLabel message={errors?.answer} isDirty={captchaAnswer !== undefined} />
</div>
</div>
<div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
<button type="button" class="text-sm font-semibold leading-6 text-gray-900"
onClick={onAbort}
>
<i18n.Translate>Cancel</i18n.Translate></button>
<button type="submit"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
disabled={!!errors}
onClick={(e) => {
e.preventDefault()
onConfirm()
}}
>
<i18n.Translate>Transfer</i18n.Translate>
</button>
</div>
</form>
</div>
<div class="px-4 mt-4 ">
{/* <div class="w-full">
<div class="px-4 sm:px-0 text-sm">
<p><i18n.Translate>Wire transfer details</i18n.Translate></p>
</div>
<div class="mt-6 border-t border-gray-100">
<dl class="divide-y divide-gray-100">
{((): VNode => {
switch (details.account.targetType) {
case "iban": {
const p = details.account as PaytoUriIBAN
const name = p.params["receiver-name"]
return <Fragment>
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.iban}</dd>
</div>
{name &&
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Exchange name</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd>
</div>
}
</Fragment>
}
case "x-taler-bank": {
const p = details.account as PaytoUriTalerBank
const name = p.params["receiver-name"]
return <Fragment>
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.account}</dd>
</div>
{name &&
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Exchange name</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd>
</div>
}
</Fragment>
}
default:
return <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{details.account.targetPath}</dd>
</div>
}
})()}
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Withdrawal identification</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0 break-words">{details.reserve}</dd>
</div>
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">To be added</dd>
// {/* Amounts.stringifyValue(details.amount)
</div>
</dl>
</div>
</div> */}
</div>
</div>
</div>
);
}
export function AbortedView({ error, onClose }: State.Aborted) {
return (
<div>aborted</div>
);
}
export function ConfirmedView({ error, onClose }: State.Confirmed) {
const { i18n } = useTranslationContext();
const [settings, updateSettings] = useSettings()
return (
<Fragment>
<div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white p-4 text-left shadow-xl transition-all ">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<div class="mt-3 text-center sm:mt-5">
<h3 class="text-base font-semibold leading-6 text-gray-900" id="modal-title">
<i18n.Translate>Withdrawal confirmed</i18n.Translate>
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
<i18n.Translate>
The wire transfer to the Taler operator has been initiated. You will soon receive the requested amount in your Taler wallet.
</i18n.Translate>
</p>
</div>
</div>
</div>
<div class="mt-4">
<div class="flex items-center justify-between">
<span class="flex flex-grow flex-col">
<span class="text-sm text-black font-medium leading-6 " id="availability-label">
<i18n.Translate>Do not show this again</i18n.Translate>
</span>
</span>
<button type="button" data-enabled={!settings.showWithdrawalSuccess} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description"
onClick={() => {
updateSettings("showWithdrawalSuccess", !settings.showWithdrawalSuccess);
}}>
<span aria-hidden="true" data-enabled={!settings.showWithdrawalSuccess} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
</button>
</div>
</div>
<div class="mt-5 sm:mt-6">
<button type="button"
class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
onClick={async (e) => {
e.preventDefault();
onClose()
}}>
<i18n.Translate>Close</i18n.Translate>
</button>
</div>
</Fragment>
);
}
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 <Fragment>
<div class="flex justify-end mt-4">
<button type="button"
class="inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
onClick={() => {
onClose()
}}
>
Cancel
</button>
</div>
<div class="bg-white shadow sm:rounded-lg mt-4">
<div class="p-4">
<h3 class="text-base font-semibold leading-6 text-gray-900">
<i18n.Translate>On this device</i18n.Translate>
</h3>
<div class="mt-2 sm:flex sm:items-start sm:justify-between">
<div class="max-w-xl text-sm text-gray-500">
<p>
<i18n.Translate>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.</i18n.Translate>
</p>
</div>
<div class="mt-5 sm:ml-6 sm:mt-0 sm:flex sm:flex-shrink-0 sm:items-center">
<a href={talerWithdrawUri}
class="inline-flex items-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Start</i18n.Translate>
</a>
</div>
</div>
</div>
</div>
<div class="bg-white shadow sm:rounded-lg mt-2">
<div class="p-4">
<h3 class="text-base font-semibold leading-6 text-gray-900">
<i18n.Translate>On a mobile phone</i18n.Translate>
</h3>
<div class="mt-2 sm:flex sm:items-start sm:justify-between">
<div class="max-w-xl text-sm text-gray-500">
<p>
<i18n.Translate>Scan the QR code with your mobile device.</i18n.Translate>
</p>
</div>
</div>
<div class="mt-2 max-w-md ml-auto mr-auto">
<QR text={talerWithdrawUri} />
</div>
</div>
</div>
</Fragment>
}

View File

@ -15,10 +15,9 @@
*/ */
import { AmountJson } from "@gnu-taler/taler-util"; 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 { h, VNode } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { notifyInfo } from "../hooks/notification.js";
import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
import { WalletWithdrawForm } from "./WalletWithdrawForm.js"; import { WalletWithdrawForm } from "./WalletWithdrawForm.js";
import { useSettings } from "../hooks/settings.js"; import { useSettings } from "../hooks/settings.js";
@ -27,60 +26,97 @@ import { useSettings } from "../hooks/settings.js";
* Let the user choose a payment option, * Let the user choose a payment option,
* then specify the details trigger the action. * 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 { i18n } = useTranslationContext();
const [settings, updateSettings] = useSettings(); const [settings] = useSettings();
const [tab, setTab] = useState<"charge-wallet" | "wire-transfer">( const [tab, setTab] = useState<"charge-wallet" | "wire-transfer" | undefined>();
"charge-wallet",
);
return ( return (
<article> <div class="mt-2">
<div class="payments">
<div class="tab"> <fieldset>
<button <legend class="px-4 text-base font-semibold leading-6 text-gray-900">
class={tab === "charge-wallet" ? "tablinks active" : "tablinks"} <i18n.Translate>Send money to</i18n.Translate>
onClick={(): void => { </legend>
setTab("charge-wallet");
}} <div class="px-4 mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-2 sm:gap-x-4">
> {/* <!-- Active: "border-indigo-600 ring-2 ring-indigo-600", Not Active: "border-gray-300" --> */}
{i18n.str`Withdraw `} <label class={"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + (tab === "charge-wallet" ? "border-indigo-600 ring-2 ring-indigo-600" : "border-gray-300")}>
</button> <input type="radio" name="project-type" value="Newsletter" class="sr-only" aria-labelledby="project-type-0-label" aria-describedby="project-type-0-description-0 project-type-0-description-1" onClick={() => {
<button setTab("charge-wallet")
class={tab === "wire-transfer" ? "tablinks active" : "tablinks"} }} />
onClick={(): void => { <span class="flex flex-1">
setTab("wire-transfer"); <div class="text-4xl mr-4 my-auto">&#x1F4B5;</div>
}} <span class="flex flex-col">
> <span id="project-type-0-label" class="block text-sm font-medium text-gray-900">
{i18n.str`Wire transfer`} <i18n.Translate>a <b>Taler</b> wallet</i18n.Translate>
</button> </span>
<span id="project-type-0-description-0" class="mt-1 flex items-center text-sm text-gray-500">
<i18n.Translate>Withdraw digital money into your mobile wallet or browser extension</i18n.Translate>
</span>
{!!settings.currentWithdrawalOperationId &&
<span class="inline-flex items-center gap-x-1.5 w-fit rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">
<svg class="h-1.5 w-1.5 fill-green-500" viewBox="0 0 6 6" aria-hidden="true">
<circle cx="3" cy="3" r="3" />
</svg>
<i18n.Translate>operation ready</i18n.Translate>
</span>
}
</span>
</span>
<svg class="h-5 w-5 text-indigo-600" style={{ visibility: tab === "charge-wallet" ? "visible" : "hidden" }} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</label>
<label class={"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + (tab === "wire-transfer" ? "border-indigo-600 ring-2 ring-indigo-600" : "border-gray-300")}>
<input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" onClick={() => {
setTab("wire-transfer")
}} />
<span class="flex flex-1">
<div class="text-4xl mr-4 my-auto">&#x2194;</div>
<span class="flex flex-col">
<span id="project-type-1-label" class="block text-sm font-medium text-gray-900">
<i18n.Translate>another bank account</i18n.Translate>
</span>
<span id="project-type-1-description-0" class="mt-1 flex items-center text-sm text-gray-500">
<i18n.Translate>Make a wire transfer to an account which you know the bank account number</i18n.Translate>
</span>
</span>
</span>
<svg class="h-5 w-5 text-indigo-600" style={{ visibility: tab === "wire-transfer" ? "visible" : "hidden" }} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</label>
</div> </div>
{tab === "charge-wallet" && ( {tab === "charge-wallet" && (
<div id="charge-wallet" class="tabcontent active">
<h3>{i18n.str`Obtain digital cash`}</h3>
<WalletWithdrawForm <WalletWithdrawForm
focus focus
limit={limit} limit={limit}
onSuccess={(id) => { goToConfirmOperation={goToConfirmOperation}
updateSettings("currentWithdrawalOperationId", id); onCancel={() => {
setTab(undefined)
}} }}
/> />
</div>
)} )}
{tab === "wire-transfer" && ( {tab === "wire-transfer" && (
<div id="wire-transfer" class="tabcontent active">
<h3>{i18n.str`Transfer to bank account`}</h3>
<PaytoWireTransferForm <PaytoWireTransferForm
focus focus
title={i18n.str`Transfer details`}
limit={limit} limit={limit}
onSuccess={() => { onSuccess={() => {
notifyInfo(i18n.str`Wire transfer created!`); notifyInfo(i18n.str`Wire transfer created!`);
setTab(undefined)
}}
onCancel={() => {
setTab(undefined)
}} }}
/> />
</div>
)} )}
</fieldset>
</div> </div>
</article> )
);
} }

View File

@ -17,42 +17,51 @@
import { import {
AmountJson, AmountJson,
Amounts, Amounts,
buildPayto,
HttpStatusCode, HttpStatusCode,
Logger, Logger,
TranslatedString,
buildPayto,
parsePaytoUri, parsePaytoUri,
stringifyPaytoUri, stringifyPaytoUri
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
RequestError, RequestError,
notify,
notifyError,
useTranslationContext, useTranslationContext,
} from "@gnu-taler/web-util/browser"; } 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 { 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 { useAccessAPI } from "../hooks/access.js";
import { import {
buildRequestErrorMessage, buildRequestErrorMessage,
undefinedIfEmpty, undefinedIfEmpty,
validateIBAN, validateIBAN,
} from "../utils.js"; } 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"); const logger = new Logger("PaytoWireTransferForm");
export function PaytoWireTransferForm({ export function PaytoWireTransferForm({
focus, focus,
title,
onSuccess, onSuccess,
onCancel,
limit, limit,
}: { }: {
title: TranslatedString,
focus?: boolean; focus?: boolean;
onSuccess: () => void; onSuccess: () => void;
onCancel: (() => void) | undefined;
limit: AmountJson; limit: AmountJson;
}): VNode { }): VNode {
const [isRawPayto, setIsRawPayto] = useState(false); const [isRawPayto, setIsRawPayto] = useState(false);
const [iban, setIban] = useState<string | undefined>(undefined); // FIXME: remove this
const [subject, setSubject] = useState<string | undefined>(undefined); const [iban, setIban] = useState<string | undefined>();
const [amount, setAmount] = useState<string | undefined>(undefined); const [subject, setSubject] = useState<string | undefined>();
const [amount, setAmount] = useState<string | undefined>();
const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>( const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>(
undefined, undefined,
@ -70,17 +79,17 @@ export function PaytoWireTransferForm({
const errorsWire = undefinedIfEmpty({ const errorsWire = undefinedIfEmpty({
iban: !iban iban: !iban
? i18n.str`Missing IBAN` ? i18n.str`required`
: !IBAN_REGEX.test(iban) : !IBAN_REGEX.test(iban)
? i18n.str`IBAN should have just uppercased letters and numbers` ? i18n.str`IBAN should have just uppercased letters and numbers`
: validateIBAN(iban, i18n), : validateIBAN(iban, i18n),
subject: !subject ? i18n.str`Missing subject` : undefined, subject: !subject ? i18n.str`required` : undefined,
amount: !trimmedAmountStr amount: !trimmedAmountStr
? i18n.str`Missing amount` ? i18n.str`required`
: !parsedAmount : !parsedAmount
? i18n.str`Amount is not valid` ? i18n.str`not valid`
: Amounts.isZero(parsedAmount) : Amounts.isZero(parsedAmount)
? i18n.str`Should be greater than 0` ? i18n.str`should be greater than 0`
: Amounts.cmp(limit, parsedAmount) === -1 : Amounts.cmp(limit, parsedAmount) === -1
? i18n.str`balance is not enough` ? i18n.str`balance is not enough`
: undefined, : undefined,
@ -88,41 +97,179 @@ export function PaytoWireTransferForm({
const { createTransaction } = useAccessAPI(); const { createTransaction } = useAccessAPI();
if (!isRawPayto) const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput);
return (
const errorsPayto = undefinedIfEmpty({
rawPaytoInput: !rawPaytoInput
? i18n.str`required`
: !parsed
? 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),
});
async function doSend() {
let payto_uri: string | undefined;
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 (<div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
{/**
* FIXME: Scan a qr code
*/}
<div class="px-4 sm:px-0">
<h2 class="text-base font-semibold leading-7 text-gray-900">
{title}
</h2>
<div> <div>
<div class="px-4 mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-1 sm:gap-x-4">
<label class={"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + (!isRawPayto ? "border-indigo-600 ring-2 ring-indigo-600" : "border-gray-300")}>
<input type="radio" name="project-type" value="Newsletter" class="sr-only" aria-labelledby="project-type-0-label" aria-describedby="project-type-0-description-0 project-type-0-description-1" onChange={() => {
if (parsed && parsed.isKnown && parsed.targetType === "iban") {
setIban(parsed.iban)
const amount = Amounts.parse(parsed.params["amount"])
if (amount) {
setAmount(Amounts.stringifyValue(amount))
}
const subject = parsed.params["subject"]
if (subject) {
setSubject(subject)
}
}
setIsRawPayto(false)
}} />
<span class="flex flex-1">
<span class="flex flex-col">
<span class="block text-sm font-medium text-gray-900">
<i18n.Translate>Using a form</i18n.Translate>
</span>
</span>
</span>
</label>
<label class={"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + (isRawPayto ? "border-indigo-600 ring-2 ring-indigo-600" : "border-gray-300")}>
<input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" onChange={() => {
if (iban) {
const payto = buildPayto("iban", iban, undefined)
if (parsedAmount) {
payto.params["amount"] = Amounts.stringify(parsedAmount)
}
if (subject) {
payto.params["message"] = subject
}
rawPaytoInputSetter(stringifyPaytoUri(payto))
}
setIsRawPayto(true)
}} />
<span class="flex flex-1">
<span class="flex flex-col">
<span class="block text-sm font-medium text-gray-900">
<i18n.Translate>Import payto:// URI</i18n.Translate>
</span>
</span>
</span>
</label>
</div>
</div>
</div>
<form <form
class="pure-form" class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2 w-fit mx-auto"
name="wire-transfer-form"
onSubmit={(e) => {
e.preventDefault();
}}
autoCapitalize="none" autoCapitalize="none"
autoCorrect="off" autoCorrect="off"
onSubmit={e => {
e.preventDefault()
}}
> >
<label for="iban">{i18n.str`Receiver IBAN:`}</label>&nbsp; <div class="px-4 py-6 sm:p-8">
{!isRawPayto ?
<div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<div class="sm:col-span-5">
<label for="iban" class="block text-sm font-medium leading-6 text-gray-900">{i18n.str`Recipient`}</label>
<div class="mt-2">
<input <input
ref={ref} ref={focus ? doAutoFocus : undefined}
type="text" type="text"
id="iban" class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
name="iban" name="iban"
id="iban"
value={iban ?? ""} value={iban ?? ""}
placeholder="CC0123456789" placeholder="CC0123456789"
autocomplete="off"
required required
pattern={ibanRegex} pattern={ibanRegex}
onInput={(e): void => { onInput={(e): void => {
setIban(e.currentTarget.value); setIban(e.currentTarget.value.toUpperCase());
}} }}
/> />
<ShowInputErrorLabel <ShowInputErrorLabel
message={errorsWire?.iban} message={errorsWire?.iban}
isDirty={iban !== undefined} isDirty={iban !== undefined}
/> />
<label for="subject">{i18n.str`Transfer subject:`}</label>&nbsp; </div>
<p class="mt-2 text-sm text-gray-500" >
<i18n.Translate>IBAN of the recipient's account</i18n.Translate>
</p>
</div>
<div class="sm:col-span-5">
<label for="subject" class="block text-sm font-medium leading-6 text-gray-900">{i18n.str`Transfer subject`}</label>
<div class="mt-2">
<input <input
type="text" type="text"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
name="subject" name="subject"
id="subject" id="subject"
autocomplete="off"
placeholder="subject" placeholder="subject"
value={subject ?? ""} value={subject ?? ""}
required required
@ -134,161 +281,43 @@ export function PaytoWireTransferForm({
message={errorsWire?.subject} message={errorsWire?.subject}
isDirty={subject !== undefined} isDirty={subject !== undefined}
/> />
<label for="amount">{i18n.str`Amount:`}</label>&nbsp;
<div style={{ width: "max-content", display: "flex" }}>
<input
type="text"
readonly
class="currency-indicator"
size={limit.currency.length}
maxLength={limit.currency.length}
tabIndex={-1}
style={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
borderRight: 0,
}}
value={limit.currency}
/>
<input
type="number"
name="amount"
id="amount"
placeholder="amount"
required
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderLeft: 0,
width: 150,
}}
value={amount ?? ""}
onInput={(e): void => {
setAmount(e.currentTarget.value);
}}
/>
</div> </div>
<p class="mt-2 text-sm text-gray-500" >some text to identify the transfer</p>
</div>
<div class="sm:col-span-5">
<label for="amount" class="block text-sm font-medium leading-6 text-gray-900">{i18n.str`Amount`}</label>
<InputAmount
name="amount"
left
currency={limit.currency}
value={trimmedAmountStr}
onChange={(d) => {
setAmount(d)
}}
/>
<ShowInputErrorLabel <ShowInputErrorLabel
message={errorsWire?.amount} message={errorsWire?.amount}
isDirty={amount !== undefined} isDirty={subject !== undefined}
/> />
<p style={{ display: "flex", justifyContent: "space-between" }}> <p class="mt-2 text-sm text-gray-500" >amount to transfer</p>
<input
type="submit"
class="pure-button pure-button-primary"
disabled={!!errorsWire}
value="Send"
onClick={async (e) => {
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),
});
}
}
}}
/>
<input
type="button"
class="pure-button"
value="Clear"
onClick={async (e) => {
e.preventDefault();
setAmount(undefined);
setIban(undefined);
setSubject(undefined);
}}
/>
</p>
</form>
<p>
<a
href="#"
onClick={(e) => {
setIsRawPayto(true);
e.preventDefault();
}}
>
{i18n.str`Want to try the raw payto://-format?`}
</a>
</p>
</div> </div>
);
const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput); </div> :
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6 w-full">
const errorsPayto = undefinedIfEmpty({ <div class="sm:col-span-6">
rawPaytoInput: !rawPaytoInput <label for="address" class="block text-sm font-medium leading-6 text-gray-900">{i18n.str`payto URI:`}</label>
? i18n.str`required` <div class="mt-2">
: !parsed <textarea
? i18n.str`does not follow the pattern` ref={focus ? doAutoFocus : undefined}
: !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),
});
return (
<div>
<p>{i18n.str`Transfer money to account identified by payto:// URI:`}</p>
<form
class="pure-form"
name="payto-form"
onSubmit={(e) => {
e.preventDefault();
}}
autoCapitalize="none"
autoCorrect="off"
>
<p>
<label for="address">{i18n.str`payto URI:`}</label>&nbsp;
<input
name="address" name="address"
type="text"
size={50}
ref={ref}
id="address" id="address"
type="textarea"
rows={3}
class="block overflow-hidden w-64 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
value={rawPaytoInput ?? ""} value={rawPaytoInput ?? ""}
required required
placeholder={i18n.str`payto address`} placeholder={i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limit.currency}:X.Y]`}
// pattern={`payto://iban/[A-Z][A-Z][0-9]+?message=[a-zA-Z0-9 ]+&amount=${currency}:[0-9]+(.[0-9]+)?`}
onInput={(e): void => { onInput={(e): void => {
rawPaytoInputSetter(e.currentTarget.value); rawPaytoInputSetter(e.currentTarget.value);
}} }}
@ -297,68 +326,125 @@ export function PaytoWireTransferForm({
message={errorsPayto?.rawPaytoInput} message={errorsPayto?.rawPaytoInput}
isDirty={rawPaytoInput !== undefined} isDirty={rawPaytoInput !== undefined}
/> />
<br />
<div style={{ fontSize: "small", marginTop: 4 }}>
Hint:
<code>
payto://iban/[receiver-iban]?message=[subject]&amount=[
{limit.currency}
:X.Y]
</code>
</div> </div>
</p> </div>
<p> </div>
<input
class="pure-button pure-button-primary"
type="button"
disabled={!!errorsPayto}
value={i18n.str`Send`}
onClick={async () => {
if (!rawPaytoInput) {
logger.error("Didn't get any raw Payto string!");
return;
} }
</div>
try { <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
await createTransaction({ {onCancel ?
paytoUri: rawPaytoInput, <button type="button" class="text-sm font-semibold leading-6 text-gray-900"
}); onClick={onCancel}
onSuccess(); >
rawPaytoInputSetter(undefined); <i18n.Translate>Cancel</i18n.Translate>
} catch (error) { </button>
if (error instanceof RequestError) { : <div />
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),
});
} }
} <button type="submit"
}} class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
/> disabled={isRawPayto ? !!errorsPayto : !!errorsWire}
</p> onClick={(e) => {
<p> e.preventDefault()
<a doSend()
href="/account"
onClick={() => {
setIsRawPayto(false);
}} }}
> >
{i18n.str`Use wire-transfer form?`} <i18n.Translate>Send</i18n.Translate>
</a> </button>
</p> </div>
</form> </form>
</div > </div >
)
}
/**
* Show the element when the load ended
* @param element
*/
export function doAutoFocus(element: HTMLElement | null) {
if (element) {
setTimeout(() => {
element.focus()
element.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "center"
})
}, 100)
}
}
const FRAC_SEPARATOR = "."
export function InputAmount(
{
currency,
name,
value,
error,
left,
onChange,
}: {
error?: string;
currency: string;
name: string;
left?: boolean | undefined,
value: string | undefined;
onChange?: (s: string) => void;
},
ref: Ref<HTMLInputElement>,
): VNode {
const cfg = useConfigContext()
return (
<div class="mt-2">
<div class="flex rounded-md shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600">
<div
class="pointer-events-none inset-y-0 flex items-center px-3"
>
<span class="text-gray-500 sm:text-sm">{currency}</span>
</div>
<input
type="number"
data-left={left}
class="text-right rounded-md rounded-l-none data-[left=true]:text-left w-full py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6"
placeholder="0.00" aria-describedby="price-currency"
ref={ref}
name={name}
id={name}
autocomplete="off"
value={value ?? ""}
disabled={!onChange}
onInput={(e) => {
if (!onChange) return;
const l = e.currentTarget.value.length
const sep_pos = e.currentTarget.value.indexOf(FRAC_SEPARATOR)
if (sep_pos !== -1 && l - sep_pos - 1 > cfg.currency_fraction_limit) {
e.currentTarget.value = e.currentTarget.value.substring(0, sep_pos + cfg.currency_fraction_limit + 1)
}
onChange(e.currentTarget.value);
}}
/>
</div>
<ShowInputErrorLabel message={error} isDirty={value !== undefined} />
</div>
); );
} }
export function RenderAmount({ value, negative }: { value: AmountJson, negative?: boolean }): VNode {
const cfg = useConfigContext()
const str = Amounts.stringifyValue(value)
const sep_pos = str.indexOf(FRAC_SEPARATOR)
if (sep_pos !== -1 && str.length - sep_pos - 1 > cfg.currency_fraction_digits) {
const limit = sep_pos + cfg.currency_fraction_digits + 1
const normal = str.substring(0, limit)
const small = str.substring(limit)
return <span class="whitespace-nowrap">
{negative ? "-" : undefined}
{value.currency} {normal} <sup class="-ml-2">{small}</sup>
</span>
}
return <span class="whitespace-nowrap">
{negative ? "-" : undefined}
{value.currency} {str}
</span>
}

View File

@ -36,8 +36,8 @@ export function PublicHistoriesPage({}: Props): VNode {
const result = usePublicAccounts(); const result = usePublicAccounts();
const [showAccount, setShowAccount] = useState( const [showAccount, setShowAccount] = useState(
result.ok && result.data.publicAccounts.length > 0 result.ok && result.data.public_accounts.length > 0
? result.data.publicAccounts[0].accountLabel ? result.data.public_accounts[0].account_name
: undefined, : undefined,
); );
@ -51,9 +51,9 @@ export function PublicHistoriesPage({}: Props): VNode {
const accountsBar = []; const accountsBar = [];
// Ask story of all the public accounts. // Ask story of all the public accounts.
for (const account of data.publicAccounts) { for (const account of data.public_accounts) {
logger.trace("Asking transactions for", account.accountLabel); logger.trace("Asking transactions for", account.account_name);
const isSelected = account.accountLabel == showAccount; const isSelected = account.account_name == showAccount;
accountsBar.push( accountsBar.push(
<li <li
class={ class={
@ -65,13 +65,13 @@ export function PublicHistoriesPage({}: Props): VNode {
<a <a
href="#" href="#"
class="pure-menu-link" class="pure-menu-link"
onClick={() => setShowAccount(account.accountLabel)} onClick={() => setShowAccount(account.account_name)}
> >
{account.accountLabel} {account.account_name}
</a> </a>
</li>, </li>,
); );
txs[account.accountLabel] = <Transactions account={account.accountLabel} />; txs[account.account_name] = <Transactions account={account.account_name} />;
} }
return ( return (

View File

@ -17,17 +17,19 @@
import { import {
HttpStatusCode, HttpStatusCode,
stringifyWithdrawUri, stringifyWithdrawUri,
TranslatedString,
WithdrawUriResult, WithdrawUriResult,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
notify,
notifyError,
RequestError, RequestError,
useTranslationContext, useTranslationContext,
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { useEffect } from "preact/hooks"; import { useEffect } from "preact/hooks";
import { QR } from "../components/QR.js"; import { QR } from "../components/QR.js";
import { useAccessAnonAPI } from "../hooks/access.js"; import { useAccessAnonAPI } from "../hooks/access.js";
import { notifyError } from "../hooks/notification.js";
import { buildRequestErrorMessage } from "../utils.js"; import { buildRequestErrorMessage } from "../utils.js";
export function QrCodeSection({ export function QrCodeSection({
@ -49,26 +51,14 @@ export function QrCodeSection({
const talerWithdrawUri = stringifyWithdrawUri(withdrawUri); const talerWithdrawUri = stringifyWithdrawUri(withdrawUri);
const { abortWithdrawal } = useAccessAnonAPI(); const { abortWithdrawal } = useAccessAnonAPI();
return (
<section id="main" class="content"> async function doAbort() {
<h1 class="nav">{i18n.str`Charge your GNU Taler wallet`}</h1>
<article>
<div class="qr-div ">
<a href={talerWithdrawUri} class="pure-button pure-button-primary">
<i18n.Translate>Continue with GNU Taler</i18n.Translate>
</a>
<p>{i18n.str`Or scan this QR code with your mobile to receive the coin in another device:`}</p>
<QR text={talerWithdrawUri} />
<a
class="pure-button btn-cancel"
onClick={async (e) => {
e.preventDefault();
try { try {
await abortWithdrawal(withdrawUri.withdrawalOperationId); await abortWithdrawal(withdrawUri.withdrawalOperationId);
onAborted(); onAborted();
} catch (error) { } catch (error) {
if (error instanceof RequestError) { if (error instanceof RequestError) {
notifyError( notify(
buildRequestErrorMessage(i18n, error.cause, { buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) => onClientError: (status) =>
status === HttpStatusCode.Conflict status === HttpStatusCode.Conflict
@ -77,19 +67,71 @@ export function QrCodeSection({
}), }),
); );
} else { } else {
notifyError({ notifyError(
title: i18n.str`Operation failed, please report`, i18n.str`Operation failed, please report`,
description: (error instanceof Error
error instanceof Error
? error.message ? error.message
: JSON.stringify(error), : JSON.stringify(error)) as TranslatedString
}); )
} }
} }
}} }
>{i18n.str`Cancel`}</a>
return (
<Fragment>
<div class="bg-white shadow-xl sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900">
<i18n.Translate>If you have a Taler wallet installed in this device</i18n.Translate>
</h3>
<div class="mt-4 mb-4 text-sm text-gray-500">
<p><i18n.Translate>
You will see the details of the operation in your wallet including the fees (if applies).
If you still don't have one you can install it from <a class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net/en/wallet.html">here</a>.
</i18n.Translate></p>
</div> </div>
</article> <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 pt-2 mt-2 ">
</section> <button type="button"
// class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md px-3 py-2 text-sm font-semibold text-black shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
class="text-sm font-semibold leading-6 text-gray-900"
onClick={doAbort}
>
Cancel
</button>
<a href={talerWithdrawUri}
class="inline-flex items-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Withdraw</i18n.Translate>
</a>
</div>
</div>
</div>
<div class="bg-white shadow-xl sm:rounded-lg mt-8">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900">
<i18n.Translate>Or if you have the wallet in another device</i18n.Translate>
</h3>
<div class="mt-4 max-w-xl text-sm text-gray-500">
<i18n.Translate>Scan the QR below to start the withdrawal</i18n.Translate>
</div>
<div class="mt-2 max-w-md ml-auto mr-auto">
<QR text={talerWithdrawUri} />
</div>
</div>
<div class="flex items-center justify-center gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
<button type="button"
// class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md px-3 py-2 text-sm font-semibold text-black shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
class="text-sm font-semibold leading-6 text-gray-900"
onClick={doAbort}
>
Cancel
</button>
</div>
</div>
</Fragment>
); );
} }

View File

@ -13,26 +13,31 @@
You should have received a copy of the GNU General Public License along with You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { HttpStatusCode, Logger } from "@gnu-taler/taler-util"; import { HttpStatusCode, Logger, TranslatedString } from "@gnu-taler/taler-util";
import { import {
RequestError, RequestError,
notify,
notifyError,
useTranslationContext, useTranslationContext,
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact"; import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { useBackendContext } from "../context/backend.js"; import { useBackendContext } from "../context/backend.js";
import { useTestingAPI } from "../hooks/access.js"; import { useTestingAPI } from "../hooks/access.js";
import { notifyError } from "../hooks/notification.js";
import { bankUiSettings } from "../settings.js"; import { bankUiSettings } from "../settings.js";
import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js"; import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js";
import { getRandomPassword, getRandomUsername } from "./rnd.js";
import { useCredentialsChecker } from "../hooks/useCredentialsChecker.js";
const logger = new Logger("RegistrationPage"); const logger = new Logger("RegistrationPage");
export function RegistrationPage({ export function RegistrationPage({
onComplete, onComplete,
onCancel
}: { }: {
onComplete: () => void; onComplete: () => void;
onCancel: () => void;
}): VNode { }): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
if (!bankUiSettings.allowRegistrations) { if (!bankUiSettings.allowRegistrations) {
@ -40,29 +45,48 @@ export function RegistrationPage({
<p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p> <p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p>
); );
} }
return <RegistrationForm onComplete={onComplete} />; return <RegistrationForm onComplete={onComplete} onCancel={onCancel} />;
} }
export const USERNAME_REGEX = /^[a-z][a-zA-Z0-9]*$/; export const USERNAME_REGEX = /^[a-z][a-zA-Z0-9-]*$/;
export const PHONE_REGEX = /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/;
export const EMAIL_REGEX = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/;
/** /**
* Collect and submit registration data. * Collect and submit registration data.
*/ */
function RegistrationForm({ onComplete }: { onComplete: () => void }): VNode { function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, onCancel: () => void }): VNode {
const backend = useBackendContext(); const backend = useBackendContext();
const [username, setUsername] = useState<string | undefined>(); const [username, setUsername] = useState<string | undefined>();
const [name, setName] = useState<string | undefined>();
const [password, setPassword] = useState<string | undefined>(); const [password, setPassword] = useState<string | undefined>();
const [phone, setPhone] = useState<string | undefined>();
const [email, setEmail] = useState<string | undefined>();
const [repeatPassword, setRepeatPassword] = useState<string | undefined>(); const [repeatPassword, setRepeatPassword] = useState<string | undefined>();
const { requestNewLoginToken } = useCredentialsChecker()
const { register } = useTestingAPI(); const { register } = useTestingAPI();
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const errors = undefinedIfEmpty({ const errors = undefinedIfEmpty({
// name: !name
// ? i18n.str`Missing name`
// : undefined,
username: !username username: !username
? i18n.str`Missing username` ? i18n.str`Missing username`
: !USERNAME_REGEX.test(username) : !USERNAME_REGEX.test(username)
? i18n.str`Use letters and numbers only, and start with a lowercase letter` ? i18n.str`Use letters and numbers only, and start with a lowercase letter`
: undefined, : undefined,
phone: !phone
? undefined
: !PHONE_REGEX.test(phone)
? i18n.str`Use letters and numbers only, and start with a lowercase letter`
: undefined,
email: !email
? undefined
: !EMAIL_REGEX.test(email)
? i18n.str`Use letters and numbers only, and start with a lowercase letter`
: undefined,
password: !password ? i18n.str`Missing password` : undefined, password: !password ? i18n.str`Missing password` : undefined,
repeatPassword: !repeatPassword repeatPassword: !repeatPassword
? i18n.str`Missing password` ? i18n.str`Missing password`
@ -71,32 +95,120 @@ function RegistrationForm({ onComplete }: { onComplete: () => void }): VNode {
: undefined, : undefined,
}); });
async function doRegistrationStep() {
if (!username || !password) return;
try {
await register({ name: name ?? "", username, password });
const resp = await requestNewLoginToken(username, password)
setUsername(undefined);
if (resp.valid) {
backend.logIn({ username, token: resp.token });
}
onComplete();
} catch (error) {
if (error instanceof RequestError) {
notify(
buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) =>
status === HttpStatusCode.Conflict
? i18n.str`That username is already taken`
: undefined,
}),
);
} else {
notifyError(
i18n.str`Operation failed, please report`,
(error instanceof Error
? error.message
: JSON.stringify(error)) as TranslatedString
)
}
}
setPassword(undefined);
setRepeatPassword(undefined);
}
async function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(undefined);
}, ms)
})
}
async function doRandomRegistration(tries: number = 3) {
const user = getRandomUsername();
const pass = getRandomPassword();
try {
setUsername(undefined);
setPassword(undefined);
setRepeatPassword(undefined);
const username = `_${user.first}-${user.second}_`
await register({ username, name: `${user.first} ${user.second}`, password: pass });
const resp = await requestNewLoginToken(username, pass)
if (resp.valid) {
backend.logIn({ username, token: resp.token });
}
onComplete();
} catch (error) {
if (error instanceof RequestError) {
if (tries > 0) {
await delay(200)
await doRandomRegistration(tries - 1)
} else {
notify(
buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) =>
status === HttpStatusCode.Conflict
? i18n.str`Could not create a random user`
: undefined,
}),
);
}
} else {
notifyError(
i18n.str`Operation failed, please report`,
(error instanceof Error
? error.message
: JSON.stringify(error)) as TranslatedString
)
}
}
}
return ( return (
<Fragment> <Fragment>
<h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1> <h1 class="nav"></h1>
<article>
<div class="register-div"> <div class="flex min-h-full flex-col justify-center">
<form <div class="sm:mx-auto sm:w-full sm:max-w-sm">
class="register-form" <h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">{i18n.str`Account registration`}</h2>
noValidate </div>
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form class="space-y-6" noValidate
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
}} }}
autoCapitalize="none" autoCapitalize="none"
autoCorrect="off" autoCorrect="off"
> >
<div class="pure-form"> <div>
<h2>{i18n.str`Please register!`}</h2> <label for="username" class="block text-sm font-medium leading-6 text-gray-900">
<p class="unameFieldLabel registerFieldLabel formFieldLabel"> <i18n.Translate>Username</i18n.Translate>
<label for="register-un">{i18n.str`Username:`}</label> <b style={{ color: "red" }}> *</b>
</p> </label>
<div class="mt-2">
<input <input
id="register-un" autoFocus
name="register-un"
type="text" type="text"
placeholder="Username" name="username"
autocomplete="username" id="username"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
value={username ?? ""} value={username ?? ""}
enterkeyhint="next"
placeholder="identification"
autocomplete="username"
required
onInput={(e): void => { onInput={(e): void => {
setUsername(e.currentTarget.value); setUsername(e.currentTarget.value);
}} }}
@ -105,16 +217,26 @@ function RegistrationForm({ onComplete }: { onComplete: () => void }): VNode {
message={errors?.username} message={errors?.username}
isDirty={username !== undefined} isDirty={username !== undefined}
/> />
<p class="unameFieldLabel registerFieldLabel formFieldLabel"> </div>
<label for="register-pw">{i18n.str`Password:`}</label> </div>
</p>
<div>
<div class="flex items-center justify-between">
<label for="password" class="block text-sm font-medium leading-6 text-gray-900">
<i18n.Translate>Password</i18n.Translate>
<b style={{ color: "red" }}> *</b>
</label>
</div>
<div class="mt-2">
<input <input
type="password" type="password"
name="register-pw" name="password"
id="register-pw" id="password"
placeholder="Password" autocomplete="current-password"
autocomplete="new-password" class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
enterkeyhint="send"
value={password ?? ""} value={password ?? ""}
placeholder="Password"
required required
onInput={(e): void => { onInput={(e): void => {
setPassword(e.currentTarget.value); setPassword(e.currentTarget.value);
@ -124,17 +246,26 @@ function RegistrationForm({ onComplete }: { onComplete: () => void }): VNode {
message={errors?.password} message={errors?.password}
isDirty={password !== undefined} isDirty={password !== undefined}
/> />
<p class="unameFieldLabel registerFieldLabel formFieldLabel"> </div>
<label for="register-repeat">{i18n.str`Repeat Password:`}</label> </div>
</p>
<div>
<div class="flex items-center justify-between">
<label for="register-repeat" class="block text-sm font-medium leading-6 text-gray-900">
<i18n.Translate>Repeat password</i18n.Translate>
<b style={{ color: "red" }}> *</b>
</label>
</div>
<div class="mt-2">
<input <input
type="password" type="password"
style={{ marginBottom: 8 }}
name="register-repeat" name="register-repeat"
id="register-repeat" id="register-repeat"
autocomplete="new-password" autocomplete="current-password"
placeholder="Same password" class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
enterkeyhint="send"
value={repeatPassword ?? ""} value={repeatPassword ?? ""}
placeholder="Same password"
required required
onInput={(e): void => { onInput={(e): void => {
setRepeatPassword(e.currentTarget.value); setRepeatPassword(e.currentTarget.value);
@ -144,64 +275,127 @@ function RegistrationForm({ onComplete }: { onComplete: () => void }): VNode {
message={errors?.repeatPassword} message={errors?.repeatPassword}
isDirty={repeatPassword !== undefined} isDirty={repeatPassword !== undefined}
/> />
<br /> </div>
<button </div>
class="pure-button pure-button-primary btn-register"
type="submit"
disabled={!!errors}
onClick={async (e) => {
e.preventDefault();
if (!username || !password) return; <div>
try { <label for="name" class="block text-sm font-medium leading-6 text-gray-900">
const credentials = { username, password }; <i18n.Translate>Name</i18n.Translate>
await register(credentials); </label>
setUsername(undefined); <div class="mt-2">
setPassword(undefined); <input
setRepeatPassword(undefined); autoFocus
backend.logIn(credentials); type="text"
onComplete(); name="name"
} catch (error) { id="name"
if (error instanceof RequestError) { class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
notifyError( value={name ?? ""}
buildRequestErrorMessage(i18n, error.cause, { enterkeyhint="next"
onClientError: (status) => placeholder="your name"
status === HttpStatusCode.Conflict autocomplete="name"
? i18n.str`That username is already taken` required
: undefined, onInput={(e): void => {
}), setName(e.currentTarget.value);
);
} else {
notifyError({
title: i18n.str`Operation failed, please report`,
description:
error instanceof Error
? error.message
: JSON.stringify(error),
});
}
}
}} }}
> />
{i18n.str`Register`} {/* <ShowInputErrorLabel
</button> message={errors?.name}
{/* FIXME: should use a different color */} isDirty={name !== undefined}
<button /> */}
class="pure-button pure-button-secondary btn-cancel" </div>
</div>
{/* <div>
<label for="phone" class="block text-sm font-medium leading-6 text-gray-900">
<i18n.Translate>Phone</i18n.Translate>
</label>
<div class="mt-2">
<input
autoFocus
type="text"
name="phone"
id="phone"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
value={phone ?? ""}
enterkeyhint="next"
placeholder="your phone"
autocomplete="none"
onInput={(e): void => {
setPhone(e.currentTarget.value);
}}
/>
<ShowInputErrorLabel
message={errors?.phone}
isDirty={phone !== undefined}
/>
</div>
</div>
<div>
<label for="email" class="block text-sm font-medium leading-6 text-gray-900">
<i18n.Translate>Email</i18n.Translate>
</label>
<div class="mt-2">
<input
autoFocus
type="text"
name="email"
id="email"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
value={email ?? ""}
enterkeyhint="next"
placeholder="your email"
autocomplete="email"
onInput={(e): void => {
setEmail(e.currentTarget.value);
}}
/>
<ShowInputErrorLabel
message={errors?.email}
isDirty={email !== undefined}
/>
</div>
</div> */}
<div class="flex w-full justify-between">
<button type="submit"
class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault()
setUsername(undefined); onCancel()
setPassword(undefined);
setRepeatPassword(undefined);
onComplete();
}} }}
> >
{i18n.str`Cancel`} <i18n.Translate>Cancel</i18n.Translate>
</button>
<button type="submit"
class=" rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
disabled={!!errors}
onClick={(e) => {
e.preventDefault()
doRegistrationStep()
}}
>
<i18n.Translate>Register</i18n.Translate>
</button> </button>
</div> </div>
</form> </form>
{bankUiSettings.allowRandomAccountCreation &&
<p class="mt-10 text-center text-sm text-gray-500 border-t">
<button type="submit"
class="flex mt-4 w-full justify-center rounded-md bg-green-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600"
onClick={(e) => {
e.preventDefault()
doRandomRegistration()
}}
>
<i18n.Translate>Create a random user</i18n.Translate>
</button>
</p>
}
</div> </div>
</article> </div>
</Fragment> </Fragment>
); );
} }

View File

@ -1,110 +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 <http://www.gnu.org/licenses/>
*/
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { createHashHistory } from "history";
import { VNode, h } from "preact";
import { Route, Router, route } from "preact-router";
import { useEffect, useMemo, useState } from "preact/hooks";
import { BankFrame } from "./BankFrame.js";
import { BusinessAccount } from "./BusinessAccount.js";
import { HomePage, WithdrawalOperationPage } from "./HomePage.js";
import { PublicHistoriesPage } from "./PublicHistoriesPage.js";
import { RegistrationPage } from "./RegistrationPage.js";
export function Routing(): VNode {
const history = createHashHistory();
return (
<BankFrame
goToBusinessAccount={() => {
route("/business");
}}
>
<Router history={history}>
<Route
path="/operation/:wopid"
component={({ wopid }: { wopid: string }) => (
<WithdrawalOperationPage
operationId={wopid}
onContinue={() => {
route("/account");
}}
onLoadNotOk={() => {
route("/account");
}}
/>
)}
/>
<Route
path="/public-accounts"
component={() => <PublicHistoriesPage />}
/>
<Route
path="/register"
component={() => (
<RegistrationPage
onComplete={() => {
route("/account");
}}
/>
)}
/>
<Route
path="/account"
component={() => (
<HomePage
onPendingOperationFound={(wopid) => {
route(`/operation/${wopid}`);
}}
onRegister={() => {
route("/register");
}}
/>
)}
/>
<Route
path="/business"
component={() => (
<BusinessAccount
onClose={() => {
route("/account");
}}
onRegister={() => {
route("/register");
}}
onLoadNotOk={() => {
route("/account");
}}
/>
)}
/>
<Route default component={Redirect} to="/account" />
</Router>
</BankFrame>
);
}
function Redirect({ to }: { to: string }): VNode {
useEffect(() => {
route(to, true);
}, []);
return <div>being redirected to {to}</div>;
}
export function assertUnreachable(x: never): never {
throw new Error("Didn't expect to get here");
}

View File

@ -0,0 +1,167 @@
import { ErrorType, HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
import { useAdminAccountAPI, useBusinessAccountDetails } from "../hooks/circuit.js";
import { useState } from "preact/hooks";
import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
import { buildRequestErrorMessage } from "../utils.js";
import { AccountForm } from "./admin/AccountForm.js";
export function ShowAccountDetails({
account,
onClear,
onUpdateSuccess,
onLoadNotOk,
onChangePassword,
}: {
onLoadNotOk: <T>(
error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
) => 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
>();
if (!result.ok) {
if (result.loading || result.type === ErrorType.TIMEOUT) {
return onLoadNotOk(result);
}
if (result.status === HttpStatusCode.NotFound) {
return <div>account not found</div>;
}
return onLoadNotOk(result);
}
async function doUpdate() {
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) {
notify(
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 {
notifyError(
i18n.str`Operation failed, please report`,
(error instanceof Error
? error.message
: JSON.stringify(error)) as TranslatedString
)
}
}
}
}
return (
<div>
<div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
<div class="px-4 sm:px-0">
<h2 class="text-base font-semibold leading-7 text-gray-900">
{update ?
<i18n.Translate>Update account</i18n.Translate>
:
<i18n.Translate>Account details</i18n.Translate>
}
</h2>
<div class="mt-4">
<div class="flex items-center justify-between">
<span class="flex flex-grow flex-col">
<span class="text-sm text-black font-medium leading-6 " id="availability-label">
<i18n.Translate>change the account details</i18n.Translate>
</span>
</span>
<button type="button" data-enabled={!update} class="bg-indigo-600 data-[enabled=true]:bg-gray-200 relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer rounded-full ring-2 border-gray-600 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description"
onClick={() => {
setUpdate(!update)
}}>
<span aria-hidden="true" data-enabled={!update} class="translate-x-5 data-[enabled=true]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
</button>
</div>
</div>
</div>
<AccountForm
template={result.data}
purpose={update ? "update" : "show"}
onChange={(a) => setSubmitAccount(a)}
>
</AccountForm>
<p class="buttons-account">
<div
style={{
display: "flex",
justifyContent: "space-between",
flexFlow: "wrap-reverse",
}}
>
<div>
{onClear ? (
<input
class="pure-button"
type="submit"
value={i18n.str`Close`}
onClick={async (e) => {
e.preventDefault();
onClear();
}}
/>
) : undefined}
</div>
<div style={{ display: "flex" }}>
<div>
<input
id="select-exchange"
class="pure-button pure-button-primary content"
disabled={update && !submitAccount}
type="submit"
value={i18n.str`Change password`}
onClick={async (e) => {
e.preventDefault();
onChangePassword();
}}
/>
</div>
<div>
<input
id="select-exchange"
class="pure-button pure-button-primary content"
disabled={update && !submitAccount}
type="submit"
value={update ? i18n.str`Confirm` : i18n.str`Update`}
onClick={async (e) => {
e.preventDefault();
doUpdate()
}}
/>
</div>
</div>
</div>
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,177 @@
import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
import { ErrorType, HttpResponsePaginated, RequestError, notify, 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 { useAdminAccountAPI, useBusinessAccountDetails } from "../hooks/circuit.js";
import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js";
import { doAutoFocus } from "./PaytoWireTransferForm.js";
export function UpdateAccountPassword({
account,
onCancel,
onUpdateSuccess,
onLoadNotOk,
focus,
}: {
onLoadNotOk: <T>(
error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
) => VNode;
onCancel: () => void;
focus?: boolean,
onUpdateSuccess: () => void;
account: string;
}): VNode {
const { i18n } = useTranslationContext();
const result = useBusinessAccountDetails(account);
const { changePassword } = useAdminAccountAPI();
const [password, setPassword] = useState<string | undefined>();
const [repeat, setRepeat] = useState<string | undefined>();
if (!result.ok) {
if (result.loading || result.type === ErrorType.TIMEOUT) {
return onLoadNotOk(result);
}
if (result.status === HttpStatusCode.NotFound) {
return <div>account not found</div>;
}
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,
});
async function doChangePassword() {
if (!!errors || !password) return;
try {
const r = await changePassword(account, {
new_password: password,
});
onUpdateSuccess();
} catch (error) {
if (error instanceof RequestError) {
notify(buildRequestErrorMessage(i18n, error.cause));
} else {
notifyError(i18n.str`Operation failed, please report`, (error instanceof Error
? error.message
: JSON.stringify(error)) as TranslatedString)
}
}
}
return (
<div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
<div class="px-4 sm:px-0">
<h2 class="text-base font-semibold leading-7 text-gray-900">
<i18n.Translate>Update password for account "{account}"</i18n.Translate>
</h2>
</div>
<form
class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
autoCapitalize="none"
autoCorrect="off"
onSubmit={e => {
e.preventDefault()
}}
>
<div class="px-4 py-6 sm:p-8">
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<div class="sm:col-span-5">
<label
class="block text-sm font-medium leading-6 text-gray-900"
for="password"
>
{i18n.str`New password`}
</label>
<div class="mt-2">
<input
ref={focus ? doAutoFocus : undefined}
type="password"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
name="password"
id="password"
data-error={!!errors?.password && password !== undefined}
value={password ?? ""}
onChange={(e) => {
setPassword(e.currentTarget.value)
}}
// placeholder=""
autocomplete="off"
/>
<ShowInputErrorLabel
message={errors?.password}
isDirty={password !== undefined}
/>
</div>
{/* <p class="mt-2 text-sm text-gray-500" >
<i18n.Translate>user </i18n.Translate>
</p> */}
</div>
<div class="sm:col-span-5">
<label
class="block text-sm font-medium leading-6 text-gray-900"
for="repeat"
>
{i18n.str`Type it again`}
</label>
<div class="mt-2">
<input
type="password"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
name="repeat"
id="repeat"
data-error={!!errors?.repeat && repeat !== undefined}
value={repeat ?? ""}
onChange={(e) => {
setRepeat(e.currentTarget.value)
}}
// placeholder=""
autocomplete="off"
/>
<ShowInputErrorLabel
message={errors?.repeat}
isDirty={repeat !== undefined}
/>
</div>
<p class="mt-2 text-sm text-gray-500" >
<i18n.Translate>repeat the same password</i18n.Translate>
</p>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
{onCancel ?
<button type="button" class="text-sm font-semibold leading-6 text-gray-900"
onClick={onCancel}
>
<i18n.Translate>Cancel</i18n.Translate>
</button>
: <div />
}
<button type="submit"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
disabled={!!errors}
onClick={(e) => {
e.preventDefault()
doChangePassword()
}}
>
<i18n.Translate>Change</i18n.Translate>
</button>
</div>
</form>
</div>
);
}

View File

@ -19,40 +19,49 @@ import {
Amounts, Amounts,
HttpStatusCode, HttpStatusCode,
Logger, Logger,
TranslatedString,
WithdrawUriResult,
parseWithdrawUri, parseWithdrawUri,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
RequestError, RequestError,
notify,
notifyError,
useTranslationContext, useTranslationContext,
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { Ref, VNode, h } from "preact"; import { Fragment, VNode, h } from "preact";
import { forwardRef } from "preact/compat";
import { useEffect, useRef, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";
import { useAccessAPI } from "../hooks/access.js"; import { useAccessAPI } from "../hooks/access.js";
import { notifyError } from "../hooks/notification.js";
import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js"; import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; import { InputAmount, doAutoFocus } from "./PaytoWireTransferForm.js";
import { forwardRef } from "preact/compat"; import { useSettings } from "../hooks/settings.js";
import { OperationState } from "./OperationState/index.js";
import { Attention } from "../components/Attention.js";
const logger = new Logger("WalletWithdrawForm"); const logger = new Logger("WalletWithdrawForm");
const RefAmount = forwardRef(Amount); const RefAmount = forwardRef(InputAmount);
export function WalletWithdrawForm({
focus, function OldWithdrawalForm({ goToConfirmOperation, limit, onCancel, focus }: {
limit,
onSuccess,
}: {
limit: AmountJson; limit: AmountJson;
focus?: boolean; focus?: boolean;
onSuccess: (operationId: string) => void; goToConfirmOperation: (operationId: string) => void;
onCancel: () => void;
}): VNode { }): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const { createWithdrawal } = useAccessAPI(); const [settings, updateSettings] = useSettings()
const [amountStr, setAmountStr] = useState<string | undefined>("5.00"); const { createWithdrawal } = useAccessAPI();
const ref = useRef<HTMLInputElement>(null); const [amountStr, setAmountStr] = useState<string | undefined>(`${settings.maxWithdrawalAmount}`);
useEffect(() => {
if (focus) ref.current?.focus(); if (!!settings.currentWithdrawalOperationId) {
}, [focus]); return <Attention type="warning" title={i18n.str`There is an operation already`}>
<i18n.Translate>
To complete or cancel the operation click <a class="font-semibold text-yellow-700 hover:text-yellow-600" href={`#/operation/${settings.currentWithdrawalOperationId}`}>here</a>
</i18n.Translate>
</Attention>
}
const trimmedAmountStr = amountStr?.trim(); const trimmedAmountStr = amountStr?.trim();
@ -71,40 +80,7 @@ export function WalletWithdrawForm({
: undefined, : undefined,
}); });
return ( async function doStart() {
<form
id="reserve-form"
class="pure-form"
name="tform"
onSubmit={(e) => {
e.preventDefault();
}}
autoCapitalize="none"
autoCorrect="off"
>
<p>
<label for="withdraw-amount">{i18n.str`Amount to withdraw:`}</label>
&nbsp;
<RefAmount
currency={limit.currency}
value={amountStr}
onChange={(v) => {
setAmountStr(v);
}}
error={errors?.amount}
ref={ref}
/>
</p>
<p>
<div>
<input
id="select-exchange"
class="pure-button pure-button-primary"
type="submit"
disabled={!!errors}
value={i18n.str`Withdraw`}
onClick={async (e) => {
e.preventDefault();
if (!parsedAmount) return; if (!parsedAmount) return;
try { try {
const result = await createWithdrawal({ const result = await createWithdrawal({
@ -112,16 +88,16 @@ export function WalletWithdrawForm({
}); });
const uri = parseWithdrawUri(result.data.taler_withdraw_uri); const uri = parseWithdrawUri(result.data.taler_withdraw_uri);
if (!uri) { if (!uri) {
return notifyError({ return notifyError(
title: i18n.str`Server responded with an invalid withdraw URI`, i18n.str`Server responded with an invalid withdraw URI`,
description: i18n.str`Withdraw URI: ${result.data.taler_withdraw_uri}`, i18n.str`Withdraw URI: ${result.data.taler_withdraw_uri}`);
});
} else { } else {
onSuccess(uri.withdrawalOperationId); updateSettings("currentWithdrawalOperationId", uri.withdrawalOperationId)
goToConfirmOperation(uri.withdrawalOperationId);
} }
} catch (error) { } catch (error) {
if (error instanceof RequestError) { if (error instanceof RequestError) {
notifyError( notify(
buildRequestErrorMessage(i18n, error.cause, { buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) => onClientError: (status) =>
status === HttpStatusCode.Forbidden status === HttpStatusCode.Forbidden
@ -130,77 +106,154 @@ export function WalletWithdrawForm({
}), }),
); );
} else { } else {
notifyError({ notifyError(
title: i18n.str`Operation failed, please report`, i18n.str`Operation failed, please report`,
description: (error instanceof Error
error instanceof Error
? error.message ? error.message
: JSON.stringify(error), : JSON.stringify(error)) as TranslatedString
}); )
} }
} }
}
return <form
class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2 mt-4"
autoCapitalize="none"
autoCorrect="off"
onSubmit={e => {
e.preventDefault()
}} }}
>
<div class="px-4 py-6 ">
<div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<div class="sm:col-span-5">
<label for="withdraw-amount">{i18n.str`Amount`}</label>
<RefAmount
currency={limit.currency}
value={amountStr}
name="withdraw-amount"
onChange={(v) => {
setAmountStr(v);
}}
error={errors?.amount}
ref={focus ? doAutoFocus : undefined}
/> />
</div> </div>
</p> </div>
<div class="mt-4">
<div class="sm:inline">
<button type="button"
class=" inline-flex px-6 py-4 text-sm items-center rounded-l-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
onClick={(e) => {
e.preventDefault();
setAmountStr("50.00")
}}
>
50.00
</button>
<button type="button"
class=" -ml-px -mr-px inline-flex px-6 py-4 text-sm items-center rounded-r-md sm:rounded-none bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
onClick={(e) => {
e.preventDefault();
setAmountStr("25.00")
}}
>
25.00
</button>
</div>
<div class="mt-4 sm:inline">
<button type="button"
class=" -ml-px -mr-px inline-flex px-6 py-4 text-sm items-center rounded-l-md sm:rounded-none bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
onClick={(e) => {
e.preventDefault();
setAmountStr("10.00")
}}
>
10.00
</button>
<button type="button"
class=" inline-flex px-6 py-4 text-sm items-center rounded-r-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
onClick={(e) => {
e.preventDefault();
setAmountStr("5.00")
}}
>
5.00
</button>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
<button type="button" class="text-sm font-semibold leading-6 text-gray-900"
onClick={onCancel}
>
<i18n.Translate>Cancel</i18n.Translate></button>
<button type="submit"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
// disabled={isRawPayto ? !!errorsPayto : !!errorsWire}
onClick={(e) => {
e.preventDefault()
doStart()
}}
>
<i18n.Translate>Continue</i18n.Translate>
</button>
</div>
</form> </form>
}
export function WalletWithdrawForm({
focus,
limit,
onCancel,
goToConfirmOperation,
}: {
limit: AmountJson;
focus?: boolean;
goToConfirmOperation: (operationId: string) => void;
onCancel: () => void;
}): VNode {
const { i18n } = useTranslationContext();
const [settings, updateSettings] = useSettings()
return (<div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
<div class="px-4 sm:px-0">
<h2 class="text-base font-semibold leading-7 text-gray-900"><i18n.Translate>Prepare your wallet</i18n.Translate></h2>
<p class="mt-1 text-sm text-gray-500">
<i18n.Translate>After using your wallet you will need to confirm or cancel the operation on this site.</i18n.Translate>
</p>
</div>
<div class="col-span-2">
{settings.showInstallWallet &&
<Attention title={i18n.str`You need a GNU Taler Wallet`} onClose={() => {
updateSettings("showInstallWallet", false);
}}>
<i18n.Translate>
If you don't have one yet you can follow the instruction <a target="_blank" rel="noreferrer noopener" class="font-semibold text-blue-700 hover:text-blue-600" href="https://taler.net/en/wallet.html">here</a>
</i18n.Translate>
</Attention>
}
{!settings.fastWithdrawal ?
<OldWithdrawalForm
focus={focus}
limit={limit}
onCancel={onCancel}
goToConfirmOperation={goToConfirmOperation}
/>
:
<OperationState
currency={limit.currency}
onClose={onCancel}
/>
}
</div>
</div>
); );
} }
export function Amount(
{
currency,
value,
error,
onChange,
}: {
error?: string;
currency: string;
value: string | undefined;
onChange?: (s: string) => void;
},
ref: Ref<HTMLInputElement>,
): VNode {
return (
<div style={{ width: "max-content" }}>
<div>
<input
type="text"
readonly
class="currency-indicator"
size={currency.length}
maxLength={currency.length}
tabIndex={-1}
style={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
borderRight: 0,
}}
value={currency}
/>
<input
type="number"
ref={ref}
name="amount"
id="amount"
placeholder="0"
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderLeft: 0,
width: 150,
color: "black",
}}
value={value ?? ""}
disabled={!onChange}
onInput={(e): void => {
if (onChange) {
onChange(e.currentTarget.value);
}
}}
/>
</div>
<ShowInputErrorLabel message={error} isDirty={value !== undefined} />
</div>
);
}

View File

@ -15,26 +15,41 @@
*/ */
import { import {
AmountJson,
Amounts,
HttpStatusCode, HttpStatusCode,
Logger, Logger,
WithdrawUriResult, PaytoUri,
PaytoUriIBAN,
PaytoUriTalerBank,
TranslatedString,
WithdrawUriResult
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
RequestError, RequestError,
notify,
notifyError,
notifyInfo,
useTranslationContext, useTranslationContext,
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact"; import { Fragment, VNode, h } from "preact";
import { useMemo, useState } from "preact/hooks"; import { useMemo, useState } from "preact/hooks";
import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js";
import { useAccessAnonAPI } from "../hooks/access.js"; import { useAccessAnonAPI } from "../hooks/access.js";
import { notifyError } from "../hooks/notification.js";
import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js"; import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; import { useSettings } from "../hooks/settings.js";
import { RenderAmount } from "./PaytoWireTransferForm.js";
const logger = new Logger("WithdrawalConfirmationQuestion"); const logger = new Logger("WithdrawalConfirmationQuestion");
interface Props { interface Props {
onAborted: () => void; onAborted: () => void;
withdrawUri: WithdrawUriResult; withdrawUri: WithdrawUriResult;
details: {
account: PaytoUri,
reserve: string,
amount: AmountJson,
}
} }
/** /**
* Additional authentication required to complete the operation. * Additional authentication required to complete the operation.
@ -42,9 +57,11 @@ interface Props {
*/ */
export function WithdrawalConfirmationQuestion({ export function WithdrawalConfirmationQuestion({
onAborted, onAborted,
details,
withdrawUri, withdrawUri,
}: Props): VNode { }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const [settings, updateSettings] = useSettings()
const captchaNumbers = useMemo(() => { const captchaNumbers = useMemo(() => {
return { return {
@ -56,6 +73,7 @@ export function WithdrawalConfirmationQuestion({
const { confirmWithdrawal, abortWithdrawal } = useAccessAnonAPI(); const { confirmWithdrawal, abortWithdrawal } = useAccessAnonAPI();
const [captchaAnswer, setCaptchaAnswer] = useState<string | undefined>(); const [captchaAnswer, setCaptchaAnswer] = useState<string | undefined>();
const answer = parseInt(captchaAnswer ?? "", 10); const answer = parseInt(captchaAnswer ?? "", 10);
const [busy, setBusy] = useState<Record<string, undefined>>()
const errors = undefinedIfEmpty({ const errors = undefinedIfEmpty({
answer: !captchaAnswer answer: !captchaAnswer
? i18n.str`Answer the question before continue` ? i18n.str`Answer the question before continue`
@ -64,62 +82,20 @@ export function WithdrawalConfirmationQuestion({
: answer !== captchaNumbers.a + captchaNumbers.b : answer !== captchaNumbers.a + captchaNumbers.b
? i18n.str`The answer "${answer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.` ? i18n.str`The answer "${answer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.`
: undefined, : undefined,
}); }) ?? busy;
return (
<Fragment> async function doTransfer() {
<h1 class="nav">{i18n.str`Confirm Withdrawal`}</h1>
<article>
<div class="challenge-div">
<form
class="challenge-form"
noValidate
onSubmit={(e) => {
e.preventDefault();
}}
autoCapitalize="none"
autoCorrect="off"
>
<div class="pure-form" id="captcha" name="capcha-form">
<h2>{i18n.str`Authorize withdrawal by solving challenge`}</h2>
<p>
<label for="answer">
{i18n.str`What is`}&nbsp;
<em>
{captchaNumbers.a}&nbsp;+&nbsp;{captchaNumbers.b}
</em>
?&nbsp;
</label>
&nbsp;
<input
name="answer"
id="answer"
value={captchaAnswer ?? ""}
type="text"
autoFocus
required
onInput={(e): void => {
setCaptchaAnswer(e.currentTarget.value);
}}
/>
<ShowInputErrorLabel
message={errors?.answer}
isDirty={captchaAnswer !== undefined}
/>
</p>
<p>
<button
type="submit"
class="pure-button pure-button-primary btn-confirm"
disabled={!!errors}
onClick={async (e) => {
e.preventDefault();
try { try {
setBusy({})
await confirmWithdrawal( await confirmWithdrawal(
withdrawUri.withdrawalOperationId, withdrawUri.withdrawalOperationId,
); );
if (!settings.showWithdrawalSuccess) {
notifyInfo(i18n.str`Wire transfer completed!`)
}
} catch (error) { } catch (error) {
if (error instanceof RequestError) { if (error instanceof RequestError) {
notifyError( notify(
buildRequestErrorMessage(i18n, error.cause, { buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) => onClientError: (status) =>
status === HttpStatusCode.Conflict status === HttpStatusCode.Conflict
@ -130,30 +106,25 @@ export function WithdrawalConfirmationQuestion({
}), }),
); );
} else { } else {
notifyError({ notifyError(
title: i18n.str`Operation failed, please report`, i18n.str`Operation failed, please report`,
description: (error instanceof Error
error instanceof Error
? error.message ? error.message
: JSON.stringify(error), : JSON.stringify(error)) as TranslatedString
}); )
} }
} }
}} setBusy(undefined)
> }
{i18n.str`Confirm`}
</button> async function doCancel() {
&nbsp;
<button
class="pure-button pure-button-secondary btn-cancel"
onClick={async (e) => {
e.preventDefault();
try { try {
setBusy({})
await abortWithdrawal(withdrawUri.withdrawalOperationId); await abortWithdrawal(withdrawUri.withdrawalOperationId);
onAborted(); onAborted();
} catch (error) { } catch (error) {
if (error instanceof RequestError) { if (error instanceof RequestError) {
notifyError( notify(
buildRequestErrorMessage(i18n, error.cause, { buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) => onClientError: (status) =>
status === HttpStatusCode.Conflict status === HttpStatusCode.Conflict
@ -162,33 +133,203 @@ export function WithdrawalConfirmationQuestion({
}), }),
); );
} else { } else {
notifyError({ notifyError(
title: i18n.str`Operation failed, please report`, i18n.str`Operation failed, please report`,
description: (error instanceof Error
error instanceof Error
? error.message ? error.message
: JSON.stringify(error), : JSON.stringify(error)) as TranslatedString
}); )
} }
} }
setBusy(undefined)
}
return (
<Fragment>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold text-gray-900">
<i18n.Translate>Confirm the withdrawal operation</i18n.Translate>
</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<div class="px-4 mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-3 sm:gap-x-3">
<label class={"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-noneborder-indigo-600 ring-2 ring-indigo-600"}>
<input type="radio" name="project-type" value="Newsletter" class="sr-only" aria-labelledby="project-type-0-label" aria-describedby="project-type-0-description-0 project-type-0-description-1" />
<span class="flex flex-1">
<span class="flex flex-col">
<span id="project-type-0-label" class="block text-sm font-medium text-gray-900 ">
<i18n.Translate>challenge response test</i18n.Translate>
</span>
</span>
</span>
<svg class="h-5 w-5 text-indigo-600" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</label>
<label class="relative flex cursor-pointer rounded-lg border bg-gray-100 p-4 shadow-sm focus:outline-none border-gray-300">
<input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" />
<span class="flex flex-1">
<span class="flex flex-col">
<span id="project-type-1-label" class="block text-sm font-medium text-gray-900">
<i18n.Translate>using SMS</i18n.Translate>
</span>
<span id="project-type-1-description-0" class="mt-1 flex items-center text-sm text-gray-500">
<i18n.Translate>not available</i18n.Translate>
</span>
</span>
</span>
<svg class="h-5 w-5 text-indigo-600 hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</label>
<label class="relative flex cursor-pointer rounded-lg border bg-gray-100 p-4 shadow-sm focus:outline-none border-gray-300">
<input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" />
<span class="flex flex-1">
<span class="flex flex-col">
<span id="project-type-1-label" class="block text-sm font-medium text-gray-900">
<i18n.Translate>one time password</i18n.Translate>
</span>
<span id="project-type-1-description-0" class="mt-1 flex items-center text-sm text-gray-500">
<i18n.Translate>not available</i18n.Translate>
</span>
</span>
</span>
<svg class="h-5 w-5 text-indigo-600 hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</label>
</div>
</div>
<div class="mt-3 text-sm leading-6">
<div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
<div class="px-4 sm:px-0">
<h2 class="text-base font-semibold text-gray-900"><i18n.Translate>Answer the next question to authorize the wire transfer.</i18n.Translate></h2>
</div>
<form
class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
autoCapitalize="none"
autoCorrect="off"
onSubmit={e => {
e.preventDefault()
}} }}
> >
{i18n.str`Cancel`} <div class="px-4 py-6 sm:p-8">
<label for="withdraw-amount">{i18n.str`What is`}&nbsp;
<em>
{captchaNumbers.a}&nbsp;+&nbsp;{captchaNumbers.b}
</em>
?
</label>
<div class="mt-2">
<div class="relative rounded-md shadow-sm">
<input
type="text"
// class="block w-full rounded-md border-0 py-1.5 pl-16 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
aria-describedby="answer"
autoFocus
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
value={captchaAnswer ?? ""}
required
name="answer"
id="answer"
autocomplete="off"
onChange={(e): void => {
setCaptchaAnswer(e.currentTarget.value)
}}
/>
</div>
<ShowInputErrorLabel message={errors?.answer} isDirty={captchaAnswer !== undefined} />
</div>
</div>
<div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
<button type="button" class="text-sm font-semibold leading-6 text-gray-900"
onClick={doCancel}
>
<i18n.Translate>Cancel</i18n.Translate></button>
<button type="submit"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
disabled={!!errors}
onClick={(e) => {
e.preventDefault()
doTransfer()
}}
>
<i18n.Translate>Transfer</i18n.Translate>
</button> </button>
</p>
</div> </div>
</form> </form>
<div class="hint">
<p>
<i18n.Translate>
A this point, a <b>real</b> bank would ask for an additional
authentication proof (PIN/TAN, one time password, ..), instead
of a simple calculation.
</i18n.Translate>
</p>
</div> </div>
</div> </div>
</article> <div class="px-4 mt-4 ">
<div class="w-full">
<div class="px-4 sm:px-0 text-sm">
<p><i18n.Translate>Wire transfer details</i18n.Translate></p>
</div>
<div class="mt-6 border-t border-gray-100">
<dl class="divide-y divide-gray-100">
{((): VNode => {
switch (details.account.targetType) {
case "iban": {
const p = details.account as PaytoUriIBAN
const name = p.params["receiver-name"]
return <Fragment>
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.iban}</dd>
</div>
{name &&
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Exchange name</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd>
</div>
}
</Fragment>
}
case "x-taler-bank": {
const p = details.account as PaytoUriTalerBank
const name = p.params["receiver-name"]
return <Fragment>
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.account}</dd>
</div>
{name &&
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Exchange name</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd>
</div>
}
</Fragment>
}
default:
return <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{details.account.targetPath}</dd>
</div>
}
})()}
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
<RenderAmount value={details.amount} />
</dd>
</div>
</dl>
</div>
</div>
</div>
</div>
</div>
</Fragment> </Fragment>
); );
} }

View File

@ -15,15 +15,16 @@
*/ */
import { import {
Amounts,
HttpStatusCode, HttpStatusCode,
Logger, Logger,
WithdrawUriResult, WithdrawUriResult,
parsePaytoUri
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser"; import { ErrorType, notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact"; import { Fragment, VNode, h } from "preact";
import { Loading } from "../components/Loading.js"; import { Loading } from "../components/Loading.js";
import { useWithdrawalDetails } from "../hooks/access.js"; import { useWithdrawalDetails } from "../hooks/access.js";
import { notifyInfo } from "../hooks/notification.js";
import { useSettings } from "../hooks/settings.js"; import { useSettings } from "../hooks/settings.js";
import { handleNotOkResult } from "./HomePage.js"; import { handleNotOkResult } from "./HomePage.js";
import { QrCodeSection } from "./QrCodeSection.js"; import { QrCodeSection } from "./QrCodeSection.js";
@ -33,8 +34,7 @@ const logger = new Logger("WithdrawalQRCode");
interface Props { interface Props {
withdrawUri: WithdrawUriResult; withdrawUri: WithdrawUriResult;
onContinue: () => void; onClose: () => void;
onLoadNotOk: () => void;
} }
/** /**
* Offer the QR code (and a clickable taler://-link) to * Offer the QR code (and a clickable taler://-link) to
@ -43,27 +43,15 @@ interface Props {
*/ */
export function WithdrawalQRCode({ export function WithdrawalQRCode({
withdrawUri, withdrawUri,
onContinue, onClose,
onLoadNotOk,
}: Props): VNode { }: Props): VNode {
const [settings, updateSettings] = useSettings();
function clearCurrentWithdrawal(): void {
updateSettings("currentWithdrawalOperationId", undefined);
onContinue();
}
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const result = useWithdrawalDetails(withdrawUri.withdrawalOperationId); const result = useWithdrawalDetails(withdrawUri.withdrawalOperationId);
if (!result.ok) { if (!result.ok) {
if (result.loading) { if (result.loading) {
return <Loading />; return <Loading />;
} }
if (
result.type === ErrorType.CLIENT &&
result.status === HttpStatusCode.NotFound
) {
return <div>operation not found</div>;
}
onLoadNotOk();
return handleNotOkResult(i18n)(result); return handleNotOkResult(i18n)(result);
} }
const { data } = result; const { data } = result;
@ -87,8 +75,7 @@ export function WithdrawalQRCode({
style={{ float: "right" }} style={{ float: "right" }}
onClick={async (e) => { onClick={async (e) => {
e.preventDefault(); e.preventDefault();
clearCurrentWithdrawal() onClose()
onContinue()
}}> }}>
{i18n.str`Continue`} {i18n.str`Continue`}
</a> </a>
@ -98,56 +85,76 @@ export function WithdrawalQRCode({
} }
if (data.confirmation_done) { if (data.confirmation_done) {
return <section id="main" class="content"> return <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
<h1 class="nav">{i18n.str`Operation completed`}</h1> <div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<section id="assets" style={{maxWidth: 400, marginLeft: "auto", marginRight:"auto"}}> <svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<p> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<div class="mt-3 text-center sm:mt-5">
<h3 class="text-base font-semibold leading-6 text-gray-900" id="modal-title">
<i18n.Translate>Withdrawal confirmed</i18n.Translate>
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
<i18n.Translate> <i18n.Translate>
The wire transfer to the GNU Taler Exchange bank's account is completed, now the The wire transfer to the Taler operator has been initiated. You will soon receive the requested amount in your Taler wallet.
exchange will send the requested amount into your GNU Taler wallet.
</i18n.Translate> </i18n.Translate>
</p> </p>
<p> </div>
<i18n.Translate> </div>
You can close this page now or continue to the account page. </div>
</i18n.Translate> <div class="mt-5 sm:mt-6">
</p> <button type="button"
<div style={{textAlign:"center"}}> class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
<a class="pure-button pure-button-primary"
onClick={async (e) => { onClick={async (e) => {
e.preventDefault(); e.preventDefault();
clearCurrentWithdrawal() onClose()
onContinue()
}}> }}>
{i18n.str`Continue`} <i18n.Translate>Done</i18n.Translate>
</a> </button>
</div>
</div> </div>
</section>
</section>
}
}
if (!data.selection_done) { if (!data.selection_done) {
return ( return (
<QrCodeSection <QrCodeSection
withdrawUri={withdrawUri} withdrawUri={withdrawUri}
onAborted={() => { onAborted={() => {
notifyInfo(i18n.str`Operation canceled`); notifyInfo(i18n.str`Operation canceled`);
clearCurrentWithdrawal() onClose()
onContinue()
}} }}
/> />
); );
} }
if (!data.selected_reserve_pub) {
return <div>
the exchange is selcted but no reserve pub
</div>
}
const account = !data.selected_exchange_account ? undefined : parsePaytoUri(data.selected_exchange_account)
if (!account) {
return <div>
the exchange is selcted but no account
</div>
}
return ( return (
<WithdrawalConfirmationQuestion <WithdrawalConfirmationQuestion
withdrawUri={withdrawUri} withdrawUri={withdrawUri}
details={{
account,
reserve: data.selected_reserve_pub,
amount: Amounts.parseOrThrow(data.amount)
}}
onAborted={() => { onAborted={() => {
notifyInfo(i18n.str`Operation canceled`); notifyInfo(i18n.str`Operation canceled`);
clearCurrentWithdrawal() onClose()
onContinue()
}} }}
/> />
); );

View File

@ -0,0 +1,38 @@
import { Amounts } from "@gnu-taler/taler-util";
import { PaytoWireTransferForm } from "../PaytoWireTransferForm.js";
import { handleNotOkResult } from "../HomePage.js";
import { useAccountDetails } from "../../hooks/access.js";
import { useBackendContext } from "../../context/backend.js";
import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
export function AdminAccount({ onRegister }: { onRegister: () => void }): VNode {
const { i18n } = useTranslationContext();
const r = useBackendContext();
const account = r.state.status !== "loggedOut" ? r.state.username : "admin";
const result = useAccountDetails(account);
if (!result.ok) {
return handleNotOkResult(i18n)(result);
}
const { data } = result;
const balance = Amounts.parseOrThrow(data.balance.amount);
const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit";
const debitThreshold = Amounts.parseOrThrow(result.data.debit_threshold);
const limit = balanceIsDebit
? Amounts.sub(debitThreshold, balance).amount
: Amounts.add(balance, debitThreshold).amount;
if (!balance) return <Fragment />;
return (
<PaytoWireTransferForm
title={i18n.str`Make a wire transfer`}
limit={limit}
onSuccess={() => {
notifyInfo(i18n.str`Wire transfer created!`);
}}
onCancel={undefined}
/>
);
}

View File

@ -0,0 +1,315 @@
import { ComponentChildren, VNode, h } from "preact";
import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js";
import { PartialButDefined, RecursivePartial, WithIntermediate, undefinedIfEmpty, validateIBAN } from "../../utils.js";
import { useEffect, useRef, useState } from "preact/hooks";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { buildPayto, parsePaytoUri } from "@gnu-taler/taler-util";
import { doAutoFocus } from "../PaytoWireTransferForm.js";
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 ]*$/;
/**
* 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
*/
export function AccountForm({
template,
purpose,
onChange,
focus,
children,
}: {
focus?: boolean,
children: ComponentChildren,
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<typeof initial> | undefined
>(undefined);
const { i18n } = useTranslationContext();
function updateForm(newForm: typeof initial): void {
const parsed = !newForm.cashout_address
? undefined
: buildPayto("iban", newForm.cashout_address, undefined);;
const errors = undefinedIfEmpty<RecursivePartial<typeof initial>>({
cashout_address: !newForm.cashout_address
? i18n.str`required`
: !parsed
? i18n.str`does not follow the pattern`
: !parsed.isKnown || parsed.targetType !== "iban"
? i18n.str`only "IBAN" target are supported`
: !IBAN_REGEX.test(parsed.iban)
? i18n.str`IBAN should have just uppercased letters and numbers`
: 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
class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
autoCapitalize="none"
autoCorrect="off"
onSubmit={e => {
e.preventDefault()
}}
>
<div class="px-4 py-6 sm:p-8">
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<div class="sm:col-span-5">
<label
class="block text-sm font-medium leading-6 text-gray-900"
for="username"
>
{i18n.str`Username`}
{purpose === "create" && <b style={{ color: "red" }}> *</b>}
</label>
<div class="mt-2">
<input
ref={focus ? doAutoFocus : undefined}
type="text"
class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
name="username"
id="username"
data-error={!!errors?.username && form.username !== undefined}
disabled={purpose !== "create"}
value={form.username ?? ""}
onChange={(e) => {
form.username = e.currentTarget.value;
updateForm(structuredClone(form));
}}
// placeholder=""
autocomplete="off"
/>
<ShowInputErrorLabel
message={errors?.username}
isDirty={form.username !== undefined}
/>
</div>
<p class="mt-2 text-sm text-gray-500" >
<i18n.Translate>account identification in the bank</i18n.Translate>
</p>
</div>
<div class="sm:col-span-5">
<label
class="block text-sm font-medium leading-6 text-gray-900"
for="name"
>
{i18n.str`Name`}
{purpose === "create" && <b style={{ color: "red" }}> *</b>}
</label>
<div class="mt-2">
<input
type="text"
class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
name="name"
data-error={!!errors?.name && form.name !== undefined}
id="name"
disabled={purpose !== "create"}
value={form.name ?? ""}
onChange={(e) => {
form.name = e.currentTarget.value;
updateForm(structuredClone(form));
}}
// placeholder=""
autocomplete="off"
/>
<ShowInputErrorLabel
message={errors?.name}
isDirty={form.name !== undefined}
/>
</div>
<p class="mt-2 text-sm text-gray-500" >
<i18n.Translate>name of the person owner the account</i18n.Translate>
</p>
</div>
{purpose !== "create" && (<div class="sm:col-span-5">
<label
class="block text-sm font-medium leading-6 text-gray-900"
for="internal-iban"
>
{i18n.str`Internal IBAN`}
</label>
<div class="mt-2">
<input
type="text"
class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
name="internal-iban"
id="internal-iban"
disabled={true}
value={form.iban ?? ""}
/>
</div>
<p class="mt-2 text-sm text-gray-500" >
<i18n.Translate>international bank account number</i18n.Translate>
</p>
</div>)}
<div class="sm:col-span-5">
<label
class="block text-sm font-medium leading-6 text-gray-900"
for="email"
>
{i18n.str`Email`}
{purpose === "create" && <b style={{ color: "red" }}> *</b>}
</label>
<div class="mt-2">
<input
type="email"
class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
name="email"
id="email"
data-error={!!errors?.contact_data?.email && form.contact_data.email !== undefined}
disabled={purpose !== "create"}
value={form.contact_data.email ?? ""}
onChange={(e) => {
form.contact_data.email = e.currentTarget.value;
updateForm(structuredClone(form));
}}
autocomplete="off"
/>
<ShowInputErrorLabel
message={errors?.contact_data?.email}
isDirty={form.contact_data.email !== undefined}
/>
</div>
</div>
<div class="sm:col-span-5">
<label
class="block text-sm font-medium leading-6 text-gray-900"
for="phone"
>
{i18n.str`Phone`}
{purpose === "create" && <b style={{ color: "red" }}> *</b>}
</label>
<div class="mt-2">
<input
type="text"
class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
name="phone"
id="phone"
disabled={purpose !== "create"}
value={form.contact_data.phone ?? ""}
data-error={!!errors?.contact_data?.phone && form.contact_data.phone !== undefined}
onChange={(e) => {
form.contact_data.phone = e.currentTarget.value;
updateForm(structuredClone(form));
}}
// placeholder=""
autocomplete="off"
/>
<ShowInputErrorLabel
message={errors?.contact_data?.phone}
isDirty={form.contact_data.phone !== undefined}
/>
</div>
</div>
<div class="sm:col-span-5">
<label
class="block text-sm font-medium leading-6 text-gray-900"
for="cashout"
>
{i18n.str`Cashout IBAN`}
{purpose !== "show" && <b style={{ color: "red" }}> *</b>}
</label>
<div class="mt-2">
<input
type="text"
data-error={!!errors?.cashout_address && form.cashout_address !== undefined}
class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
name="cashout"
id="cashout"
disabled={purpose === "show"}
value={form.cashout_address ?? ""}
onChange={(e) => {
form.cashout_address = e.currentTarget.value;
updateForm(structuredClone(form));
}}
autocomplete="off"
/>
<ShowInputErrorLabel
message={errors?.cashout_address}
isDirty={form.cashout_address !== undefined}
/>
</div>
<p class="mt-2 text-sm text-gray-500" >
<i18n.Translate>account number where the money is going to be sent when doing cashouts</i18n.Translate>
</p>
</div>
</div>
</div>
{children}
</form>
);
}
function initializeFromTemplate(
account: SandboxBackend.Circuit.CircuitAccountData | undefined,
): WithIntermediate<SandboxBackend.Circuit.CircuitAccountData> {
const emptyAccount = {
cashout_address: undefined,
iban: undefined,
name: undefined,
username: undefined,
contact_data: undefined,
};
const emptyContact = {
email: undefined,
phone: undefined,
};
const initial: PartialButDefined<SandboxBackend.Circuit.CircuitAccountData> =
structuredClone(account) ?? emptyAccount;
if (typeof initial.contact_data === "undefined") {
initial.contact_data = emptyContact;
}
initial.contact_data.email;
return initial as any;
}

View File

@ -0,0 +1,132 @@
import { h, VNode } from "preact";
import { useBusinessAccounts } from "../../hooks/circuit.js";
import { handleNotOkResult } from "../HomePage.js";
import { AccountAction } from "./Home.js";
import { Amounts } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { RenderAmount } from "../PaytoWireTransferForm.js";
interface Props {
onAction: (type: AccountAction, account: string) => void;
account: string | undefined;
onCreateAccount: () => void;
}
export function AccountList({ account, onAction, onCreateAccount }: Props): VNode {
const result = useBusinessAccounts({ account });
const { i18n } = useTranslationContext();
if (result.loading) return <div />;
if (!result.ok) {
return handleNotOkResult(i18n)(result);
}
const { customers } = result.data;
return <div class="px-4 sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold leading-6 text-gray-900">
<i18n.Translate>Accounts</i18n.Translate>
</h1>
<p class="mt-2 text-sm text-gray-700">
<i18n.Translate>A list of all business account in the bank.</i18n.Translate>
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button type="button" class="block rounded-md bg-indigo-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
onClick={(e) => {
e.preventDefault()
onCreateAccount()
}}>
<i18n.Translate>Create account</i18n.Translate>
</button>
</div>
</div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
{!customers.length ? (
<div></div>
) : (
<table class="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">{i18n.str`Username`}</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">{i18n.str`Name`}</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">{i18n.str`Balance`}</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
<span class="sr-only">{i18n.str`Actions`}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{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 <tr key={idx}>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
<a href="#" class="text-indigo-600 hover:text-indigo-900"
onClick={(e) => {
e.preventDefault();
onAction("show-details", item.username)
}}
>
{item.username}
</a>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{item.name}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{!balance ? (
i18n.str`unknown`
) : (
<span class="amount">
<RenderAmount value={balance} negative={balanceIsDebit} />
</span>
)}
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
<a href="#" class="text-indigo-600 hover:text-indigo-900"
onClick={(e) => {
e.preventDefault();
onAction("update-password", item.username)
}}
>
change password
</a>
<br />
<a href="#" class="text-indigo-600 hover:text-indigo-900" onClick={(e) => {
e.preventDefault();
onAction("show-cashout", item.username)
}}
>
cashouts
</a>
<br />
<a href="#" class="text-indigo-600 hover:text-indigo-900" onClick={(e) => {
e.preventDefault();
onAction("remove-account", item.username)
}}
>
remove
</a>
</td>
</tr>
})}
</tbody>
</table>
)}
</div>
</div>
</div>
</div>
}

View File

@ -0,0 +1,101 @@
import { RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
import { VNode, h, Fragment } from "preact";
import { useAdminAccountAPI } from "../../hooks/circuit.js";
import { useState } from "preact/hooks";
import { buildRequestErrorMessage } from "../../utils.js";
import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
import { getRandomPassword } from "../rnd.js";
import { AccountForm } from "./AccountForm.js";
export function CreateNewAccount({
onCancel,
onCreateSuccess,
}: {
onCancel: () => void;
onCreateSuccess: (password: string) => void;
}): VNode {
const { i18n } = useTranslationContext();
const { createAccount } = useAdminAccountAPI();
const [submitAccount, setSubmitAccount] = useState<
SandboxBackend.Circuit.CircuitAccountData | undefined
>();
async function doCreate() {
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: getRandomPassword(),
};
await createAccount(account);
onCreateSuccess(account.password);
} catch (error) {
if (error instanceof RequestError) {
notify(
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`Server replied that input data was invalid`
: status === HttpStatusCode.Conflict
? i18n.str`At least one registration detail was not available`
: undefined,
}),
);
} else {
notifyError(
i18n.str`Operation failed, please report`,
(error instanceof Error
? error.message
: JSON.stringify(error)) as TranslatedString
)
}
}
}
return (
<div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
<div class="px-4 sm:px-0">
<h2 class="text-base font-semibold leading-7 text-gray-900">
<i18n.Translate>New business account</i18n.Translate>
</h2>
</div>
<AccountForm
template={undefined}
purpose="create"
onChange={(a) => {
setSubmitAccount(a);
}}
>
<div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
{onCancel ?
<button type="button" class="text-sm font-semibold leading-6 text-gray-900"
onClick={onCancel}
>
<i18n.Translate>Cancel</i18n.Translate>
</button>
: <div />
}
<button type="submit"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
disabled={!submitAccount}
onClick={(e) => {
e.preventDefault()
doCreate()
}}
>
<i18n.Translate>Create</i18n.Translate>
</button>
</div>
</AccountForm>
</div>
);
}

View File

@ -0,0 +1,148 @@
import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { Cashouts } from "../../components/Cashouts/index.js";
import { ShowCashoutDetails } from "../business/Home.js";
import { handleNotOkResult } from "../HomePage.js";
import { ShowAccountDetails } from "../ShowAccountDetails.js";
import { UpdateAccountPassword } from "../UpdateAccountPassword.js";
import { AdminAccount } from "./Account.js";
import { AccountList } from "./AccountList.js";
import { CreateNewAccount } from "./CreateNewAccount.js";
import { RemoveAccount } from "./RemoveAccount.js";
import { Transactions } from "../../components/Transactions/index.js";
/**
* Query account information and show QR code if there is pending withdrawal
*/
interface Props {
onRegister: () => void;
}
export type AccountAction = "show-details" |
"show-cashout" |
"update-password" |
"remove-account" |
"show-cashouts-details";
export function AdminHome({ onRegister }: Props): VNode {
const [action, setAction] = useState<{
type: AccountAction,
account: string
} | undefined>()
const [createAccount, setCreateAccount] = useState(false);
const { i18n } = useTranslationContext();
if (action) {
switch (action.type) {
case "show-cashouts-details": return <ShowCashoutDetails
id={action.account}
onLoadNotOk={handleNotOkResult(i18n)}
onCancel={() => {
setAction(undefined);
}}
/>
case "show-cashout": return (
<div>
<div>
<h1 class="nav welcome-text">
<i18n.Translate>Cashout for account {action.account}</i18n.Translate>
</h1>
</div>
<Cashouts
account={action.account}
onSelected={(id) => {
setAction({
type: "show-cashouts-details",
account: action.account
});
}}
/>
<p>
<input
class="pure-button"
type="submit"
value={i18n.str`Close`}
onClick={async (e) => {
e.preventDefault();
setAction(undefined);
}}
/>
</p>
</div>
)
case "update-password": return <UpdateAccountPassword
account={action.account}
onLoadNotOk={handleNotOkResult(i18n)}
onUpdateSuccess={() => {
notifyInfo(i18n.str`Password changed`);
setAction(undefined);
}}
onCancel={() => {
setAction(undefined);
}}
/>
case "remove-account": return <RemoveAccount
account={action.account}
onLoadNotOk={handleNotOkResult(i18n)}
onUpdateSuccess={() => {
notifyInfo(i18n.str`Account removed`);
setAction(undefined);
}}
onCancel={() => {
setAction(undefined);
}}
/>
case "show-details": return <ShowAccountDetails
account={action.account}
onLoadNotOk={handleNotOkResult(i18n)}
onChangePassword={() => {
setAction({
type: "update-password",
account: action.account,
})
}}
onUpdateSuccess={() => {
notifyInfo(i18n.str`Account updated`);
setAction(undefined);
}}
onClear={() => {
setAction(undefined);
}}
/>
}
}
if (createAccount) {
return (
<CreateNewAccount
onCancel={() => 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 (
<Fragment>
<AccountList
onCreateAccount={() => {
setCreateAccount(true);
}}
account={undefined}
onAction={(type, account) => setAction({ account, type })}
/>
<AdminAccount onRegister={onRegister} />
<Transactions account="admin"/>
</Fragment>
);
}

View File

@ -0,0 +1,171 @@
import { ErrorType, HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
import { VNode, h, Fragment } from "preact";
import { useAccountDetails } from "../../hooks/access.js";
import { useAdminAccountAPI } from "../../hooks/circuit.js";
import { Amounts, HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
import { buildRequestErrorMessage, undefinedIfEmpty } from "../../utils.js";
import { useEffect, useRef, useState } from "preact/hooks";
import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js";
import { Attention } from "../../components/Attention.js";
import { doAutoFocus } from "../PaytoWireTransferForm.js";
export function RemoveAccount({
account,
onCancel,
onUpdateSuccess,
onLoadNotOk,
focus,
}: {
onLoadNotOk: <T>(
error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
) => VNode;
focus?: boolean;
onCancel: () => void;
onUpdateSuccess: () => void;
account: string;
}): VNode {
const { i18n } = useTranslationContext();
const result = useAccountDetails(account);
const [accountName, setAccountName] = useState<string | undefined>()
const { deleteAccount } = useAdminAccountAPI();
if (!result.ok) {
if (result.loading || result.type === ErrorType.TIMEOUT) {
return onLoadNotOk(result);
}
if (result.status === HttpStatusCode.NotFound) {
return <div>account not found</div>;
}
return onLoadNotOk(result);
}
const balance = Amounts.parse(result.data.balance.amount);
if (!balance) {
return <div>there was an error reading the balance</div>;
}
const isBalanceEmpty = Amounts.isZero(balance);
if (!isBalanceEmpty) {
return <Attention type="warning" title={i18n.str`Can't delete the account`} onClose={onCancel}>
<i18n.Translate>The account can't be delete while still holding some balance. First make sure that the owner make a complete cashout.</i18n.Translate>
</Attention>
}
async function doRemove() {
try {
const r = await deleteAccount(account);
onUpdateSuccess();
} catch (error) {
if (error instanceof RequestError) {
notify(
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 {
notifyError(i18n.str`Operation failed, please report`,
(error instanceof Error
? error.message
: JSON.stringify(error)) as TranslatedString);
}
}
}
const errors = undefinedIfEmpty({
accountName: !accountName
? i18n.str`required`
: account !== accountName
? i18n.str`name doesn't match`
: undefined,
});
return (
<div>
<Attention type="warning" title={i18n.str`You are going to remove the account`}>
<i18n.Translate>This step can't be undone.</i18n.Translate>
</Attention>
<div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
<div class="px-4 sm:px-0">
<h2 class="text-base font-semibold leading-7 text-gray-900">
<i18n.Translate>Deleting account "{account}"</i18n.Translate>
</h2>
</div>
<form
class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
autoCapitalize="none"
autoCorrect="off"
onSubmit={e => {
e.preventDefault()
}}
>
<div class="px-4 py-6 sm:p-8">
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<div class="sm:col-span-5">
<label
class="block text-sm font-medium leading-6 text-gray-900"
for="password"
>
{i18n.str`Verification`}
</label>
<div class="mt-2">
<input
ref={focus ? doAutoFocus : undefined}
type="text"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
name="password"
id="password"
data-error={!!errors?.accountName && accountName !== undefined}
value={accountName ?? ""}
onChange={(e) => {
setAccountName(e.currentTarget.value)
}}
placeholder={account}
autocomplete="off"
/>
<ShowInputErrorLabel
message={errors?.accountName}
isDirty={accountName !== undefined}
/>
</div>
<p class="mt-2 text-sm text-gray-500" >
<i18n.Translate>enter the account name that is going to be deleted</i18n.Translate>
</p>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
{onCancel ?
<button type="button" class="text-sm font-semibold leading-6 text-gray-900"
onClick={onCancel}
>
<i18n.Translate>Cancel</i18n.Translate>
</button>
: <div />
}
<button type="submit"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
disabled={!!errors}
onClick={(e) => {
e.preventDefault()
doRemove()
}}
>
<i18n.Translate>Delete</i18n.Translate>
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -17,65 +17,63 @@ import {
AmountJson, AmountJson,
Amounts, Amounts,
HttpStatusCode, HttpStatusCode,
TranslatedString, TranslatedString
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
HttpResponse, HttpResponse,
HttpResponsePaginated, HttpResponsePaginated,
RequestError, RequestError,
notify,
notifyError,
notifyInfo,
useTranslationContext, useTranslationContext,
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact"; import { Fragment, VNode, h } from "preact";
import { StateUpdater, useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Cashouts } from "../components/Cashouts/index.js"; import { Cashouts } from "../../components/Cashouts/index.js";
import { useBackendContext } from "../context/backend.js"; import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js";
import { useAccountDetails } from "../hooks/access.js"; import { useAccountDetails } from "../../hooks/access.js";
import { import {
useCashoutDetails, useCashoutDetails,
useCircuitAccountAPI, useCircuitAccountAPI,
useEstimator, useEstimator,
useRatiosAndFeeConfig, useRatiosAndFeeConfig,
} from "../hooks/circuit.js"; } from "../../hooks/circuit.js";
import { import {
TanChannel, TanChannel,
buildRequestErrorMessage, buildRequestErrorMessage,
undefinedIfEmpty, undefinedIfEmpty,
} from "../utils.js"; } from "../../utils.js";
import { ShowAccountDetails, UpdateAccountPassword } from "./AdminPage.js"; import { handleNotOkResult } from "../HomePage.js";
import { ErrorBannerFloat } from "./BankFrame.js"; import { InputAmount } from "../PaytoWireTransferForm.js";
import { LoginForm } from "./LoginForm.js"; import { ShowAccountDetails } from "../ShowAccountDetails.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; import { UpdateAccountPassword } from "../UpdateAccountPassword.js";
import { handleNotOkResult } from "./HomePage.js";
import { ErrorMessage, notifyInfo } from "../hooks/notification.js";
import { Amount } from "./WalletWithdrawForm.js";
interface Props { interface Props {
account: string,
onClose: () => void; onClose: () => void;
onRegister: () => void; onRegister: () => void;
onLoadNotOk: () => void; onLoadNotOk: () => void;
} }
export function BusinessAccount({ export function BusinessAccount({
onClose, onClose,
account,
onLoadNotOk, onLoadNotOk,
onRegister, onRegister,
}: Props): VNode { }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext();
const [updatePassword, setUpdatePassword] = useState(false); const [updatePassword, setUpdatePassword] = useState(false);
const [newCashout, setNewcashout] = useState(false); const [newCashout, setNewcashout] = useState(false);
const [showCashoutDetails, setShowCashoutDetails] = useState< const [showCashoutDetails, setShowCashoutDetails] = useState<
string | undefined string | undefined
>(); >();
if (backend.state.status === "loggedOut") {
return <LoginForm onRegister={onRegister} />;
}
if (newCashout) { if (newCashout) {
return ( return (
<CreateCashout <CreateCashout
account={backend.state.username} account={account}
onLoadNotOk={handleNotOkResult(i18n, onRegister)} onLoadNotOk={handleNotOkResult(i18n)}
onCancel={() => { onCancel={() => {
setNewcashout(false); setNewcashout(false);
}} }}
@ -93,7 +91,7 @@ export function BusinessAccount({
return ( return (
<ShowCashoutDetails <ShowCashoutDetails
id={showCashoutDetails} id={showCashoutDetails}
onLoadNotOk={handleNotOkResult(i18n, onRegister)} onLoadNotOk={handleNotOkResult(i18n)}
onCancel={() => { onCancel={() => {
setShowCashoutDetails(undefined); setShowCashoutDetails(undefined);
}} }}
@ -103,13 +101,13 @@ export function BusinessAccount({
if (updatePassword) { if (updatePassword) {
return ( return (
<UpdateAccountPassword <UpdateAccountPassword
account={backend.state.username} account={account}
onLoadNotOk={handleNotOkResult(i18n, onRegister)} onLoadNotOk={handleNotOkResult(i18n)}
onUpdateSuccess={() => { onUpdateSuccess={() => {
notifyInfo(i18n.str`Password changed`); notifyInfo(i18n.str`Password changed`);
setUpdatePassword(false); setUpdatePassword(false);
}} }}
onClear={() => { onCancel={() => {
setUpdatePassword(false); setUpdatePassword(false);
}} }}
/> />
@ -118,8 +116,8 @@ export function BusinessAccount({
return ( return (
<div> <div>
<ShowAccountDetails <ShowAccountDetails
account={backend.state.username} account={account}
onLoadNotOk={handleNotOkResult(i18n, onRegister)} onLoadNotOk={handleNotOkResult(i18n)}
onUpdateSuccess={() => { onUpdateSuccess={() => {
notifyInfo(i18n.str`Account updated`); notifyInfo(i18n.str`Account updated`);
}} }}
@ -132,7 +130,7 @@ export function BusinessAccount({
<div class="active"> <div class="active">
<h3>{i18n.str`Latest cashouts`}</h3> <h3>{i18n.str`Latest cashouts`}</h3>
<Cashouts <Cashouts
account={backend.state.username} account={account}
onSelected={(id) => { onSelected={(id) => {
setShowCashoutDetails(id); setShowCashoutDetails(id);
}} }}
@ -225,7 +223,6 @@ function CreateCashout({
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const ratiosResult = useRatiosAndFeeConfig(); const ratiosResult = useRatiosAndFeeConfig();
const result = useAccountDetails(account); const result = useAccountDetails(account);
const [error, saveError] = useState<ErrorMessage | undefined>();
const { const {
estimateByCredit: calculateFromCredit, estimateByCredit: calculateFromCredit,
estimateByDebit: calculateFromDebit, estimateByDebit: calculateFromDebit,
@ -238,9 +235,10 @@ function CreateCashout({
const config = ratiosResult.data; const config = ratiosResult.data;
const balance = Amounts.parseOrThrow(result.data.balance.amount); const balance = Amounts.parseOrThrow(result.data.balance.amount);
const debitThreshold = Amounts.parseOrThrow(result.data.debitThreshold);
const zero = Amounts.zeroOfCurrency(balance.currency);
const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit"; const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit";
const debitThreshold = Amounts.parseOrThrow(result.data.debit_threshold);
const zero = Amounts.zeroOfCurrency(balance.currency);
const limit = balanceIsDebit const limit = balanceIsDebit
? Amounts.sub(debitThreshold, balance).amount ? Amounts.sub(debitThreshold, balance).amount
: Amounts.add(balance, debitThreshold).amount; : Amounts.add(balance, debitThreshold).amount;
@ -258,8 +256,7 @@ function CreateCashout({
if (!sellRate || sellRate < 0) return <div>error rate</div>; if (!sellRate || sellRate < 0) return <div>error rate</div>;
const amount = Amounts.parseOrThrow( const amount = Amounts.parseOrThrow(
`${!form.isDebit ? fiatCurrency : balance.currency}:${ `${!form.isDebit ? fiatCurrency : balance.currency}:${!form.amount ? "0" : form.amount
!form.amount ? "0" : form.amount
}`, }`,
); );
@ -268,15 +265,15 @@ function CreateCashout({
calculateFromDebit(amount, sellFee, sellRate) calculateFromDebit(amount, sellFee, sellRate)
.then((r) => { .then((r) => {
setCalc(r); setCalc(r);
saveError(undefined);
}) })
.catch((error) => { .catch((error) => {
saveError( notify(
error instanceof RequestError error instanceof RequestError
? buildRequestErrorMessage(i18n, error.cause) ? buildRequestErrorMessage(i18n, error.cause)
: { : {
type: "error",
title: i18n.str`Could not estimate the cashout`, title: i18n.str`Could not estimate the cashout`,
description: error.message, description: error.message as TranslatedString
}, },
); );
}); });
@ -284,13 +281,13 @@ function CreateCashout({
calculateFromCredit(amount, sellFee, sellRate) calculateFromCredit(amount, sellFee, sellRate)
.then((r) => { .then((r) => {
setCalc(r); setCalc(r);
saveError(undefined);
}) })
.catch((error) => { .catch((error) => {
saveError( notify(
error instanceof RequestError error instanceof RequestError
? buildRequestErrorMessage(i18n, error.cause) ? buildRequestErrorMessage(i18n, error.cause)
: { : {
type: "error",
title: i18n.str`Could not estimate the cashout`, title: i18n.str`Could not estimate the cashout`,
description: error.message, description: error.message,
}, },
@ -321,9 +318,6 @@ function CreateCashout({
return ( return (
<div> <div>
{error && (
<ErrorBannerFloat error={error} onClear={() => saveError(undefined)} />
)}
<h1>New cashout</h1> <h1>New cashout</h1>
<form class="pure-form"> <form class="pure-form">
<fieldset> <fieldset>
@ -341,13 +335,15 @@ function CreateCashout({
/> />
</fieldset> </fieldset>
<fieldset> <fieldset>
<label> <label for="amount">
{form.isDebit {form.isDebit
? i18n.str`Amount to send` ? i18n.str`Amount to send`
: i18n.str`Amount to receive`} : i18n.str`Amount to receive`}
</label> </label>
<div style={{ display: "flex" }}> <div style={{ display: "flex" }}>
<Amount <InputAmount
name="amount"
currency={amount.currency} currency={amount.currency}
value={form.amount} value={form.amount}
onChange={(v) => { onChange={(v) => {
@ -362,7 +358,6 @@ function CreateCashout({
type="checkbox" type="checkbox"
name="asd" name="asd"
onChange={(e): void => { onChange={(e): void => {
console.log("asdasd", form.isDebit);
form.isDebit = !form.isDebit; form.isDebit = !form.isDebit;
updateForm(structuredClone(form)); updateForm(structuredClone(form));
}} }}
@ -376,24 +371,27 @@ function CreateCashout({
<input value={sellRate} disabled /> <input value={sellRate} disabled />
</fieldset> </fieldset>
<fieldset> <fieldset>
<label>{i18n.str`Balance now`}</label> <label for="balance-now">{i18n.str`Balance now`}</label>
<Amount <InputAmount
name="banace-now"
currency={balance.currency} currency={balance.currency}
value={Amounts.stringifyValue(balance)} value={Amounts.stringifyValue(balance)}
/> />
</fieldset> </fieldset>
<fieldset> <fieldset>
<label <label for="total-cost"
style={{ fontWeight: "bold", color: "red" }} style={{ fontWeight: "bold", color: "red" }}
>{i18n.str`Total cost`}</label> >{i18n.str`Total cost`}</label>
<Amount <InputAmount
name="total-cost"
currency={balance.currency} currency={balance.currency}
value={Amounts.stringifyValue(calc.debit)} value={Amounts.stringifyValue(calc.debit)}
/> />
</fieldset> </fieldset>
<fieldset> <fieldset>
<label>{i18n.str`Balance after`}</label> <label for="balance-after">{i18n.str`Balance after`}</label>
<Amount <InputAmount
name="balance-after"
currency={balance.currency} currency={balance.currency}
value={balanceAfter ? Amounts.stringifyValue(balanceAfter) : ""} value={balanceAfter ? Amounts.stringifyValue(balanceAfter) : ""}
/> />
@ -401,16 +399,18 @@ function CreateCashout({
{Amounts.isZero(sellFee) ? undefined : ( {Amounts.isZero(sellFee) ? undefined : (
<Fragment> <Fragment>
<fieldset> <fieldset>
<label>{i18n.str`Amount after conversion`}</label> <label for="amount-conversiojn">{i18n.str`Amount after conversion`}</label>
<Amount <InputAmount
name="amount-conversion"
currency={fiatCurrency} currency={fiatCurrency}
value={Amounts.stringifyValue(calc.beforeFee)} value={Amounts.stringifyValue(calc.beforeFee)}
/> />
</fieldset> </fieldset>
<fieldset> <fieldset>
<label>{i18n.str`Cashout fee`}</label> <label form="cashout-fee">{i18n.str`Cashout fee`}</label>
<Amount <InputAmount
name="cashout-fee"
currency={fiatCurrency} currency={fiatCurrency}
value={Amounts.stringifyValue(sellFee)} value={Amounts.stringifyValue(sellFee)}
/> />
@ -418,10 +418,11 @@ function CreateCashout({
</Fragment> </Fragment>
)} )}
<fieldset> <fieldset>
<label <label for="total"
style={{ fontWeight: "bold", color: "green" }} style={{ fontWeight: "bold", color: "green" }}
>{i18n.str`Total cashout transfer`}</label> >{i18n.str`Total cashout transfer`}</label>
<Amount <InputAmount
name="total"
currency={fiatCurrency} currency={fiatCurrency}
value={Amounts.stringifyValue(calc.credit)} value={Amounts.stringifyValue(calc.credit)}
/> />
@ -511,7 +512,7 @@ function CreateCashout({
onComplete(res.data.uuid); onComplete(res.data.uuid);
} catch (error) { } catch (error) {
if (error instanceof RequestError) { if (error instanceof RequestError) {
saveError( notify(
buildRequestErrorMessage(i18n, error.cause, { buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) => onClientError: (status) =>
status === HttpStatusCode.BadRequest status === HttpStatusCode.BadRequest
@ -530,13 +531,12 @@ function CreateCashout({
}), }),
); );
} else { } else {
saveError({ notifyError(
title: i18n.str`Operation failed, please report`, i18n.str`Operation failed, please report`,
description: (error instanceof Error
error instanceof Error
? error.message ? error.message
: JSON.stringify(error), : JSON.stringify(error)) as TranslatedString
}); )
} }
} }
}} }}
@ -565,7 +565,6 @@ export function ShowCashoutDetails({
const result = useCashoutDetails(id); const result = useCashoutDetails(id);
const { abortCashout, confirmCashout } = useCircuitAccountAPI(); const { abortCashout, confirmCashout } = useCircuitAccountAPI();
const [code, setCode] = useState<string | undefined>(undefined); const [code, setCode] = useState<string | undefined>(undefined);
const [error, saveError] = useState<ErrorMessage | undefined>();
if (!result.ok) return onLoadNotOk(result); if (!result.ok) return onLoadNotOk(result);
const errors = undefinedIfEmpty({ const errors = undefinedIfEmpty({
code: !code ? i18n.str`required` : undefined, code: !code ? i18n.str`required` : undefined,
@ -574,9 +573,6 @@ export function ShowCashoutDetails({
return ( return (
<div> <div>
<h1>Cashout details {id}</h1> <h1>Cashout details {id}</h1>
{error && (
<ErrorBannerFloat error={error} onClear={() => saveError(undefined)} />
)}
<form class="pure-form"> <form class="pure-form">
<fieldset> <fieldset>
<label> <label>
@ -661,7 +657,7 @@ export function ShowCashoutDetails({
onCancel(); onCancel();
} catch (error) { } catch (error) {
if (error instanceof RequestError) { if (error instanceof RequestError) {
saveError( notify(
buildRequestErrorMessage(i18n, error.cause, { buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) => onClientError: (status) =>
status === HttpStatusCode.NotFound status === HttpStatusCode.NotFound
@ -672,13 +668,12 @@ export function ShowCashoutDetails({
}), }),
); );
} else { } else {
saveError({ notifyError(
title: i18n.str`Operation failed, please report`, i18n.str`Operation failed, please report`,
description: (error instanceof Error
error instanceof Error
? error.message ? error.message
: JSON.stringify(error), : JSON.stringify(error)) as TranslatedString
}); )
} }
} }
}} }}
@ -699,7 +694,7 @@ export function ShowCashoutDetails({
}); });
} catch (error) { } catch (error) {
if (error instanceof RequestError) { if (error instanceof RequestError) {
saveError( notify(
buildRequestErrorMessage(i18n, error.cause, { buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) => onClientError: (status) =>
status === HttpStatusCode.NotFound status === HttpStatusCode.NotFound
@ -714,13 +709,12 @@ export function ShowCashoutDetails({
}), }),
); );
} else { } else {
saveError({ notifyError(
title: i18n.str`Operation failed, please report`, i18n.str`Operation failed, please report`,
description: (error instanceof Error
error instanceof Error
? error.message ? error.message
: JSON.stringify(error), : JSON.stringify(error)) as TranslatedString
}); )
} }
} }
}} }}

File diff suppressed because it is too large Load Diff

View File

@ -1,70 +0,0 @@
.rdp-picker {
display: flex;
height: 175px;
}
@media (max-width: 400px) {
.rdp-picker {
width: 250px;
}
}
.rdp-masked-div {
overflow: hidden;
height: 175px;
position: relative;
}
.rdp-column-container {
flex-grow: 1;
display: inline-block;
}
.rdp-column {
position: absolute;
z-index: 0;
width: 100%;
}
.rdp-reticule {
border: 0;
border-top: 2px solid rgba(109, 202, 236, 1);
height: 2px;
position: absolute;
width: 80%;
margin: 0;
z-index: 100;
left: 50%;
-webkit-transform: translateX(-50%);
transform: translateX(-50%);
}
.rdp-text-overlay {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
height: 35px;
font-size: 20px;
left: 50%;
-webkit-transform: translateX(-50%);
transform: translateX(-50%);
}
.rdp-cell div {
font-size: 17px;
color: gray;
font-style: italic;
}
.rdp-cell {
display: flex;
align-items: center;
justify-content: center;
height: 35px;
font-size: 18px;
}
.rdp-center {
font-size: 25px;
}

View File

@ -1,128 +0,0 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
html {
&.has-aside-left {
&.has-aside-expanded {
nav.navbar,
body {
padding-left: $aside-width;
}
}
aside.is-placed-left {
display: block;
}
}
}
aside.aside.is-expanded {
width: $aside-width;
.menu-list {
@include icon-with-update-mark($aside-icon-width);
span.menu-item-label {
display: inline-block;
}
li.is-active {
ul {
display: block;
}
background-color: $body-background-color;
}
}
}
aside.aside {
display: none;
position: fixed;
top: 0;
left: 0;
z-index: 40;
height: 100vh;
padding: 0;
box-shadow: $aside-box-shadow;
background: $aside-background-color;
.aside-tools {
display: flex;
flex-direction: row;
width: 100%;
background-color: $aside-tools-background-color;
color: $aside-tools-color;
line-height: $navbar-height;
height: $navbar-height;
padding-left: $default-padding * 0.5;
flex: 1;
.icon {
margin-right: $default-padding * 0.5;
}
}
.menu-list {
li {
a {
&.has-dropdown-icon {
position: relative;
padding-right: $aside-icon-width;
.dropdown-icon {
position: absolute;
top: $size-base * 0.5;
right: 0;
}
}
}
ul {
display: none;
border-left: 0;
background-color: darken($base-color, 2.5%);
padding-left: 0;
margin: 0 0 $default-padding * 0.5;
li {
a {
padding: $default-padding * 0.5 0 $default-padding * 0.5
$default-padding * 0.5;
font-size: $aside-submenu-font-size;
&.has-icon {
padding-left: 0;
}
&.is-active {
&:not(:hover) {
background: transparent;
}
}
}
}
}
}
}
.menu-label {
padding: 0 $default-padding * 0.5;
margin-top: $default-padding * 0.5;
margin-bottom: $default-padding * 0.5;
}
}

View File

@ -1,69 +0,0 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
.card:not(:last-child) {
margin-bottom: $default-padding;
}
.card {
border-radius: $radius-large;
border: $card-border;
&.has-table {
.card-content {
padding: 0;
}
.b-table {
border-radius: $radius-large;
overflow: hidden;
}
}
&.is-card-widget {
.card-content {
padding: $default-padding * 0.5;
}
}
.card-header {
border-bottom: 1px solid $base-color-light;
}
.card-content {
hr {
margin-left: $card-content-padding * -1;
margin-right: $card-content-padding * -1;
}
}
.is-widget-icon {
.icon {
width: 5rem;
height: 5rem;
}
}
.is-widget-label {
.subtitle {
color: $grey;
}
}
}

View File

@ -1,263 +0,0 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
:root {
--primary-color: #3298dc;
--primary-text-color-dark: rgba(0, 0, 0, 0.87);
--secondary-text-color-dark: rgba(0, 0, 0, 0.57);
--disabled-text-color-dark: rgba(0, 0, 0, 0.13);
--primary-text-color-light: rgba(255, 255, 255, 0.87);
--secondary-text-color-light: rgba(255, 255, 255, 0.57);
--disabled-text-color-light: rgba(255, 255, 255, 0.13);
--font-stack: "Roboto", "Helvetica Neue", Helvetica, Arial, sans-serif;
--primary-card-color: #fff;
--primary-background-color: #f2f2f2;
--box-shadow-lvl-1: 0 1px 3px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.24);
--box-shadow-lvl-2: 0 3px 6px rgba(0, 0, 0, 0.16),
0 3px 6px rgba(0, 0, 0, 0.23);
--box-shadow-lvl-3: 0 10px 20px rgba(0, 0, 0, 0.19),
0 6px 6px rgba(0, 0, 0, 0.23);
--box-shadow-lvl-4: 0 14px 28px rgba(0, 0, 0, 0.25),
0 10px 10px rgba(0, 0, 0, 0.22);
}
.home .datePicker div {
margin-top: 0px;
margin-bottom: 0px;
}
.datePicker {
text-align: left;
background: var(--primary-card-color);
border-radius: 3px;
z-index: 200;
position: fixed;
height: auto;
max-height: 90vh;
width: 90vw;
max-width: 448px;
transform-origin: top left;
transition: transform 0.22s ease-in-out, opacity 0.22s ease-in-out;
top: 50%;
left: 50%;
opacity: 0;
transform: scale(0) translate(-50%, -50%);
user-select: none;
&.datePicker--opened {
opacity: 1;
transform: scale(1) translate(-50%, -50%);
}
.datePicker--titles {
border-top-left-radius: 3px;
border-top-right-radius: 3px;
padding: 24px;
height: 100px;
background: var(--primary-color);
h2,
h3 {
cursor: pointer;
color: #fff;
line-height: 1;
padding: 0;
margin: 0;
font-size: 32px;
}
h3 {
color: rgba(255, 255, 255, 0.57);
font-size: 18px;
padding-bottom: 2px;
}
}
nav {
padding: 20px;
height: 56px;
h4 {
width: calc(100% - 60px);
text-align: center;
display: inline-block;
padding: 0;
font-size: 14px;
line-height: 24px;
margin: 0;
position: relative;
top: -9px;
color: var(--primary-text-color);
}
i {
cursor: pointer;
color: var(--secondary-text-color);
font-size: 26px;
user-select: none;
border-radius: 50%;
&:hover {
background: var(--disabled-text-color-dark);
}
}
}
.datePicker--scroll {
overflow-y: auto;
max-height: calc(90vh - 56px - 100px);
}
.datePicker--calendar {
padding: 0 20px;
.datePicker--dayNames {
width: 100%;
display: grid;
text-align: center;
// there's probably a better way to do this, but wanted to try out CSS grid
grid-template-columns:
calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7)
calc(100% / 7) calc(100% / 7) calc(100% / 7);
span {
color: var(--secondary-text-color-dark);
font-size: 14px;
line-height: 42px;
display: inline-grid;
}
}
.datePicker--days {
width: 100%;
display: grid;
text-align: center;
grid-template-columns:
calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7)
calc(100% / 7) calc(100% / 7) calc(100% / 7);
span {
color: var(--primary-text-color-dark);
line-height: 42px;
font-size: 14px;
display: inline-grid;
transition: color 0.22s;
height: 42px;
position: relative;
cursor: pointer;
user-select: none;
border-radius: 50%;
&::before {
content: "";
position: absolute;
z-index: -1;
height: 42px;
width: 42px;
left: calc(50% - 21px);
background: var(--primary-color);
border-radius: 50%;
transition: transform 0.22s, opacity 0.22s;
transform: scale(0);
opacity: 0;
}
&[disabled="true"] {
cursor: unset;
}
&.datePicker--today {
font-weight: 700;
}
&.datePicker--selected {
color: rgba(255, 255, 255, 0.87);
&:before {
transform: scale(1);
opacity: 1;
}
}
}
}
}
.datePicker--selectYear {
padding: 0 20px;
display: block;
width: 100%;
text-align: center;
max-height: 362px;
span {
display: block;
width: 100%;
font-size: 24px;
margin: 20px auto;
cursor: pointer;
&.selected {
font-size: 42px;
color: var(--primary-color);
}
}
}
div.datePicker--actions {
width: 100%;
padding: 8px;
text-align: right;
button {
margin-bottom: 0;
font-size: 15px;
cursor: pointer;
color: var(--primary-text-color);
border: none;
margin-left: 8px;
min-width: 64px;
line-height: 36px;
background-color: transparent;
appearance: none;
padding: 0 16px;
border-radius: 3px;
transition: background-color 0.13s;
&:hover,
&:focus {
outline: none;
background-color: var(--disabled-text-color-dark);
}
}
}
}
.datePicker--background {
z-index: 199;
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.52);
animation: fadeIn 0.22s forwards;
}

View File

@ -1,71 +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 <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
.field {
&.has-check {
.field-body {
margin-top: $default-padding * 0.125;
}
}
.control {
.mdi-24px.mdi-set,
.mdi-24px.mdi:before {
font-size: inherit;
}
}
}
.upload {
.upload-draggable {
display: block;
}
}
.input,
.textarea,
select {
box-shadow: none;
&:focus,
&:active {
box-shadow: none !important;
}
}
.switch input[type="checkbox"] + .check:before {
box-shadow: none;
}
.switch,
.b-checkbox.checkbox {
input[type="checkbox"] {
&:focus + .check,
&:focus:checked + .check {
box-shadow: none !important;
}
}
}
.b-checkbox.checkbox input[type="checkbox"],
.b-radio.radio input[type="radio"] {
& + .check {
border: $checkbox-border;
}
}

View File

@ -1,55 +0,0 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
section.hero.is-hero-bar {
background-color: $hero-bar-background;
border-bottom: $light-border;
.hero-body {
padding: $default-padding;
.level-item {
&.is-hero-avatar-item {
margin-right: $default-padding;
}
> div > .level {
margin-bottom: $default-padding * 0.5;
}
.subtitle + p {
margin-top: $default-padding * 0.5;
}
}
.button {
&.is-hero-button {
background-color: rgba($white, 0.5);
font-weight: 300;
@include transition(background-color);
&:hover {
background-color: $white;
}
}
}
}
}

View File

@ -1,51 +0,0 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
.lds-ring {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 64px;
height: 64px;
margin: 8px;
border: 8px solid black;
border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: black transparent transparent transparent;
}
.lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -1,24 +0,0 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
section.section.is-main-section {
padding-top: $default-padding;
}

View File

@ -1,144 +0,0 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
nav.navbar {
box-shadow: $navbar-box-shadow;
.navbar-item {
&.has-user-avatar {
.is-user-avatar {
margin-right: $default-padding * 0.5;
display: inline-flex;
width: $navbar-avatar-size;
height: $navbar-avatar-size;
}
}
&.has-divider {
border-right: $navbar-divider-border;
}
&.no-left-space {
padding-left: 0;
}
&.has-dropdown {
padding-right: 0;
padding-left: 0;
.navbar-link {
padding-right: $navbar-item-h-padding;
padding-left: $navbar-item-h-padding;
}
}
&.has-control {
padding-top: 0;
padding-bottom: 0;
}
.control {
.input {
color: $navbar-input-color;
border: 0;
box-shadow: none;
background: transparent;
&::placeholder {
color: $navbar-input-placeholder-color;
}
}
}
}
}
@include touch {
nav.navbar {
display: flex;
padding-right: 0;
.navbar-brand {
flex: 1;
&.is-right {
flex: none;
}
}
.navbar-item {
&.no-left-space-touch {
padding-left: 0;
}
}
.navbar-menu {
position: absolute;
width: 100vw;
padding-top: 0;
top: $navbar-height;
left: 0;
.navbar-item {
.icon:first-child {
margin-right: $default-padding * 0.5;
}
&.has-dropdown {
> .navbar-link {
background-color: $white-ter;
.icon:last-child {
display: none;
}
}
}
&.has-user-avatar {
> .navbar-link {
display: flex;
align-items: center;
padding-top: $default-padding * 0.5;
padding-bottom: $default-padding * 0.5;
}
}
}
}
}
}
@include desktop {
nav.navbar {
.navbar-item {
padding-right: $navbar-item-h-padding;
padding-left: $navbar-item-h-padding;
&:not(.is-desktop-icon-only) {
.icon:first-child {
margin-right: $default-padding * 0.5;
}
}
&.is-desktop-icon-only {
span:not(.icon) {
display: none;
}
}
}
}
}

View File

@ -1,179 +0,0 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
table.table {
thead {
th {
border-bottom-width: 1px;
}
}
td,
th {
&.checkbox-cell {
.b-checkbox.checkbox:not(.button) {
margin-right: 0;
width: 20px;
.control-label {
display: none;
padding: 0;
}
}
}
}
td {
.image {
margin: 0 auto;
width: $table-avatar-size;
height: $table-avatar-size;
}
&.is-progress-col {
min-width: 5rem;
vertical-align: middle;
}
}
}
.b-table {
.table {
border: 0;
border-radius: 0;
}
/* This stylizes buefy's pagination */
.table-wrapper {
margin-bottom: 0;
}
.table-wrapper + .level {
padding: $notification-padding;
padding-left: $card-content-padding;
padding-right: $card-content-padding;
margin: 0;
border-top: $base-color-light;
background: $notification-background-color;
.pagination-link {
background: $button-background-color;
color: $button-color;
border-color: $button-border-color;
&.is-current {
border-color: $button-active-border-color;
}
}
.pagination-previous,
.pagination-next,
.pagination-link {
border-color: $button-border-color;
color: $base-color;
&[disabled] {
background-color: transparent;
}
}
}
}
@include mobile {
.card {
&.has-table {
.b-table {
.table-wrapper + .level {
.level-left + .level-right {
margin-top: 0;
}
}
}
}
&.has-mobile-sort-spaced {
.b-table {
.field.table-mobile-sort {
padding-top: $default-padding * 0.5;
}
}
}
}
.b-table {
.field.table-mobile-sort {
padding: 0 $default-padding * 0.5;
}
.table-wrapper.has-mobile-cards {
tr {
box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1);
margin-bottom: 3px !important;
}
td {
&.is-progress-col {
span,
progress {
display: flex;
width: 45%;
align-items: center;
align-self: center;
}
}
&.checkbox-cell,
&.is-image-cell {
border-bottom: 0 !important;
}
&.checkbox-cell,
&.is-actions-cell {
&:before {
display: none;
}
}
&.has-no-head-mobile {
&:before {
display: none;
}
span {
display: block;
width: 100%;
}
&.is-progress-col {
progress {
width: 100%;
}
}
&.is-image-cell {
.image {
width: $table-avatar-size-mobile;
height: auto;
margin: 0 auto $default-padding * 0.25;
}
}
}
}
}
}
}

View File

@ -1,136 +0,0 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
/* We'll need some initial vars to use here */
@import "node_modules/bulma/sass/utilities/initial-variables";
/* Base: Size */
$size-base: 1rem;
$default-padding: $size-base * 1.5;
/* Default font */
$family-sans-serif: "Nunito", sans-serif;
/* Base color */
$base-color: #2e323a;
$base-color-light: rgba(24, 28, 33, 0.06);
/* General overrides */
$primary: $turquoise;
$body-background-color: #f8f8f8;
$link: $blue;
$link-visited: $purple;
$light-border: 1px solid $base-color-light;
$hr-height: 1px;
/* NavBar: specifics */
$navbar-input-color: $grey-darker;
$navbar-input-placeholder-color: $grey-lighter;
$navbar-box-shadow: 0 1px 0 rgba(24, 28, 33, 0.04);
$navbar-divider-border: 1px solid rgba($grey-lighter, 0.25);
$navbar-item-h-padding: $default-padding * 0.75;
$navbar-avatar-size: 1.75rem;
/* Aside: Bulma override */
$menu-item-radius: 0;
$menu-list-link-padding: $size-base * 0.5 0;
$menu-label-color: lighten($base-color, 25%);
$menu-item-color: lighten($base-color, 30%);
$menu-item-hover-color: $white;
$menu-item-hover-background-color: darken($base-color, 3.5%);
$menu-item-active-color: $white;
$menu-item-active-background-color: darken($base-color, 2.5%);
/* Aside: specifics */
$aside-width: $size-base * 14;
$aside-mobile-width: $size-base * 15;
$aside-icon-width: $size-base * 3;
$aside-submenu-font-size: $size-base * 0.95;
$aside-box-shadow: none;
$aside-background-color: $base-color;
$aside-tools-background-color: darken($aside-background-color, 10%);
$aside-tools-color: $white;
/* Title Bar: specifics */
$title-bar-color: $grey;
$title-bar-active-color: $black-ter;
/* Hero Bar: specifics */
$hero-bar-background: $white;
/* Card: Bulma override */
$card-shadow: none;
$card-header-shadow: none;
/* Card: specifics */
$card-border: 1px solid $base-color-light;
$card-header-border-bottom-color: $base-color-light;
/* Table: Bulma override */
$table-cell-border: 1px solid $white-bis;
/* Table: specifics */
$table-avatar-size: $size-base * 1.5;
$table-avatar-size-mobile: 25vw;
/* Form */
$checkbox-border: 1px solid $base-color;
/* Modal card: Bulma override */
$modal-card-head-background-color: $white-ter;
$modal-card-title-size: $size-base;
$modal-card-body-padding: $default-padding 20px;
$modal-card-head-border-bottom: 1px solid $white-ter;
$modal-card-foot-border-top: 0;
/* Modal card: specifics */
$modal-card-width: 80vw;
$modal-card-width-mobile: 90vw;
$modal-card-foot-background-color: $white-ter;
/* Notification: Bulma override */
$notification-padding: $default-padding * 0.75 $default-padding;
/* Footer: Bulma override */
$footer-background-color: $white;
$footer-padding: $default-padding * 0.33 $default-padding;
/* Footer: specifics */
$footer-logo-height: $size-base * 2;
/* Progress: Bulma override */
$progress-bar-background-color: $grey-lighter;
/* Icon: specifics */
$icon-update-mark-size: $size-base * 0.5;
$icon-update-mark-color: $yellow;
$input-disabled-border-color: $grey-lighter;
$table-row-hover-background-color: hsl(0, 0%, 80%);
.menu-list {
div {
border-radius: $menu-item-radius;
color: $menu-item-color;
display: block;
padding: $menu-list-link-padding;
}
}

View File

@ -1,353 +0,0 @@
.navcontainer:not(.default-navcontainer) {
margin-bottom: 0 !important;
}
.abort-button {
margin-left: 2px;
border: 2px solid rgb(0, 120, 231);
color: rgb(0, 120, 231);
font-size: 87%;
margin-top: 1px;
background: white;
}
div.pages-list {
margin-top: 15px;
}
.footer {
margin-left: 2em;
margin-right: 2em;
}
.qr-div,
.login-div,
.register-div {
display: block;
text-align: center;
}
a.page-number {
color: blue;
}
a.current-page-number {
color: inherit;
background-color: inherit;
}
.cancelled {
text-decoration: line-through;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* This CSS code styles the tab */
.tab {
overflow: hidden;
}
.top-right {
float: right;
}
.some-space {
display: inline-block;
border: 20px;
margin-right: 15px;
margin-top: 15px;
}
.tab button {
background-color: lightgray;
color: black;
float: left;
border: none;
outline: none;
cursor: pointer;
padding: 18px 19px;
border: 2px solid #c1c1c1;
transition: 0.5s;
font-weight: bold;
}
.tab button:hover {
background-color: yellow;
border: 2px solid #c1c1c1;
color: black;
}
.tab button.active {
background-color: orange;
border: 2px solid #c1c1c1;
color: black;
font-weight: bold;
}
.tabcontent {
display: none;
padding: 8px 16px;
border: 2px solid #c1c1c1;
width: min-content;
}
.tabcontent.active {
display: block;
}
input[type="number"] {
-moz-appearance: textfield;
}
#transfer-fields {
display: flex;
flex-wrap: wrap;
}
#id_amount {
width: 6em;
display: inline-block;
border-radius: 4px 0px 0px 4px;
}
/**
* Amount without the currency,
* placed left to a .currency-indicator.
*/
#main .amount {
width: 6em;
display: inline-block;
border-radius: 4px 0px 0px 4px;
}
input {
background-color: inherit;
}
.large-amount {
font-weight: bold;
font-size: xxx-large;
}
.currency {
font-style: oblique;
}
/*
* Currency indicator to the right of input fields,
* with non-rounded corners to the left.
*/
#main .currency-indicator {
color: black;
border-radius: 4px 0px 0px 4px;
position: relative;
}
#main .fieldlabel {
display: block;
padding-bottom: 0.5em;
}
#main .fieldbox {
margin-right: 1em;
margin-bottom: 0.5em;
}
#logout-button {
display: block;
width: fit-content;
}
.register-form > .pure-form,
.login-form > .pure-form {
background: #4a4a4a;
color: #ffffff;
display: inline-block;
text-align: left;
margin-left: auto;
margin-right: auto;
padding: 16px 16px;
border-radius: 8px;
width: min-content;
.formFieldLabel {
margin: 2px 2px;
}
input[type="text"],
input[type="password"] {
border: none;
border-radius: 4px;
background: #6a6a6a;
color: #fefefe;
box-shadow: none;
}
input[placeholder="Password"][type="password"] {
margin-bottom: 8px;
}
.btn-register,
.btn-login {
float: left;
}
.btn-cancel {
float: right;
}
h2 {
margin-top: 0;
margin-bottom: 10px;
}
}
.challenge-div {
display: block;
text-align: center;
}
.challenge-form > .pure-form {
background: #4a4a4a;
color: #ffffff;
display: inline-block;
text-align: left;
margin-left: auto;
margin-right: auto;
padding: 16px 16px;
border-radius: 8px;
width: min-content;
.formFieldLabel {
margin: 2px 2px;
}
input[type="text"] {
border: none;
border-radius: 4px;
background: #6a6a6a;
color: #fefefe;
box-shadow: none;
}
.btn-confirm {
float: left;
}
.btn-cancel {
float: right;
}
h2 {
margin-top: 0;
margin-bottom: 10px;
}
}
.wire-transfer-form > .pure-form,
.payto-form > .pure-form,
.reserve-form > .pure-form {
background: #4a4a4a;
color: #ffffff;
display: inline-block;
text-align: left;
margin-left: auto;
margin-right: auto;
padding: 16px 16px;
border-radius: 8px;
width: min-content;
.formFieldLabel {
margin: 2px 2px;
}
input[type="text"] {
border: none;
border-radius: 4px;
background: #6a6a6a;
color: #fefefe;
box-shadow: none;
}
}
html {
background: #ffffff;
color: #2a2a2a;
}
.hint {
scale: 0.7;
}
h1.nav {
text-align: center;
}
.pure-form > fieldset > label {
display: block;
}
.pure-form > fieldset > input[disabled] {
color: black !important;
}
.pure-form > fieldset > div > input[disabled] {
color: black !important;
}
.pure-form > fieldset > div.channel > div {
display: inline-block;
margin: 1em;
border: 1px black solid;
width: fit-content;
padding: 0.4em;
cursor: pointer;
}
.button-success {
background: rgb(28, 184, 65);
/* this is a green */
}
.button-error {
background: rgb(202, 60, 60);
/* this is a maroon */
}
.button-warning {
background: rgb(223, 117, 20);
/* this is an orange */
}
.button-secondary {
background: rgb(66, 184, 221);
/* this is a light blue */
}
[name=wire-transfer-form] > input {
margin-bottom: 1em;
}
.lds-ring {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 64px;
height: 64px;
margin: 8px;
border: 8px solid black;
border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: black transparent transparent transparent;
}
.lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -1,31 +0,0 @@
nav,
nav a,
nav span,
.navcontainer,
nav button,
.demobar,
.navbtn {
color: white;
background: #a00000;
}
nav a.active,
nav button,
nav span.active,
.navbtn.active {
background-color: #7a0606;
}
nav a.active:hover,
nav span.active:hover,
.navbtn.active:hover,
nav button:hover,
nav a:hover,
nav span:hover,
.navbtn:hover {
background: #df3d3d;
}
nav a.navbtn.langbtn:focus {
background-color: #df3d3d;
}

View File

@ -1,167 +0,0 @@
@charset "UTF-8";
/*
Style common to all demo pages.
Colors:
- #1e2739 (dark blue)
- #0042b2 (default blue)
- #3daee9 (highlight blue)
*/
.demobar h1 {
text-align: center;
}
.demobar > p {
padding: 0.5em;
}
.demobar a,
.demobar a:visited {
color: inherit;
background-color: inherit;
}
.tt {
font-family: "Lucida Console", Monaco, monospace;
}
.informational-ok {
background: lightgreen;
border-radius: 1em;
padding: 0.5em;
}
.informational-fail {
background: lightpink;
border-radius: 1em;
padding: 0.5em;
}
.content {
margin-left: 1em;
margin-right: 1em;
overflow-x: auto;
}
.demobar {
overflow-x: auto;
background-color: #0042b2;
color: white;
}
body {
overflow-x: hidden;
overflow-y: auto;
}
.navcontainer {
background: #0042b2;
margin-bottom: 50px;
width: 100%;
color: white;
// position: -webkit-sticky;
// position: sticky;
top: 0px;
width: 100vw;
backdrop-filter: blur(10px);
opacity: 1;
z-index: 100;
}
nav {
// left: 1vw;
position: relative;
background: #0042b2;
z-index: 100;
}
nav a,
nav button,
nav span,
.navbtn {
border: none;
color: white;
text-align: center;
// text-decoration: none;
display: inline-block;
font-size: 16px;
background: #0042b2;
height: inherit;
}
nav a,
nav button,
nav span,
.navbtn {
padding: 8px;
}
nav a:hover,
nav span:hover,
.navbtn:hover {
background: #3daee9;
}
nav a.active,
nav span.active,
.navbtn.active {
background-color: #1e2739;
}
nav a.active:hover,
nav button.active:hover,
nav span.active:hover,
.navbtn.active:hover {
background: #3daee9;
}
nav a,
nav span,
.navbtn {
cursor: pointer;
}
nav .right {
float: right;
margin-right: 5vw;
}
nav .hide div.nav {
display: none;
}
// nav .right div.nav:hover {
// display: block;
// }
// nav .right:hover div.nav {
// display: block;
// }
.langbtn {
width: 100px;
text-align: left;
}
.skip {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
.skip:focus {
position: static;
width: auto;
height: auto;
}
.demolist > a {
margin: 8px;
}
.buttons-account input.pure-button {
margin: 8px;
}

View File

@ -1,22 +0,0 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 400;
src: url(./XRXV3I6Li01BKofINeaE.ttf) format("truetype");
}

File diff suppressed because it is too large Load Diff

View File

@ -1,29 +0,0 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
@import "node_modules/bulma-radio/bulma-radio";
// @import "node_modules/bulma-responsive-tables/bulma-responsive-tables";
@import "node_modules/bulma-checkbox/bulma-checkbox";
// @import "node_modules/bulma-switch-control/bulma-switch-control";
// @import "node_modules/bulma-upload-control/bulma-upload-control";
/* Bulma */
@import "node_modules/bulma/bulma";

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,5 +0,0 @@
@use "pure";
@use "bank";
@use "demo";
@use "toggle";
@use "colors-bank";

File diff suppressed because it is too large Load Diff

View File

@ -1,51 +0,0 @@
$green: #56c080;
.toggle {
cursor: pointer;
display: inline-block;
}
.toggle-switch {
display: inline-block;
background: #ccc;
border-radius: 16px;
width: 58px;
height: 32px;
position: relative;
vertical-align: middle;
transition: background 0.25s;
&:before,
&:after {
content: "";
}
&:before {
display: block;
background: linear-gradient(to bottom, #fff 0%, #eee 100%);
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
width: 24px;
height: 24px;
position: absolute;
top: 4px;
left: 4px;
transition: left 0.25s;
}
.toggle:hover &:before {
background: linear-gradient(to bottom, #fff 0%, #fff 100%);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
}
.toggle-checkbox:checked + & {
background: $green;
&:before {
left: 30px;
}
}
}
.toggle-checkbox {
position: absolute;
visibility: hidden;
}
.toggle-label {
margin-left: 5px;
position: relative;
top: 2px;
}

View File

@ -15,11 +15,14 @@
*/ */
export interface BankUiSettings { export interface BankUiSettings {
backendBaseURL: string; backendBaseURL?: string;
allowRegistrations: boolean; allowRegistrations?: boolean;
showDemoNav: boolean; iconLinkURL?: string;
bankName: string; showDemoNav?: boolean;
demoSites: [string, string][]; simplePasswordForRandomAccounts?: boolean;
allowRandomAccountCreation?: boolean;
bankName?: string;
demoSites?: [string, string][];
} }
/** /**
@ -27,9 +30,12 @@ export interface BankUiSettings {
*/ */
const defaultSettings: BankUiSettings = { const defaultSettings: BankUiSettings = {
backendBaseURL: "https://bank.demo.taler.net/demobanks/default/", backendBaseURL: "https://bank.demo.taler.net/demobanks/default/",
iconLinkURL: "https://demo.taler.net/",
allowRegistrations: true, allowRegistrations: true,
bankName: "Taler Bank", bankName: "Taler Bank",
showDemoNav: true, showDemoNav: true,
simplePasswordForRandomAccounts: true,
allowRandomAccountCreation: true,
demoSites: [ demoSites: [
["Landing", "https://demo.taler.net/"], ["Landing", "https://demo.taler.net/"],
["Bank", "https://bank.demo.taler.net/"], ["Bank", "https://bank.demo.taler.net/"],

Some files were not shown because too many files have changed in this diff Show More