Compare commits
68 Commits
35611f0bf9
...
ffcb40b464
Author | SHA1 | Date | |
---|---|---|---|
ffcb40b464 | |||
fe7b51ef27 | |||
|
101f62123a | ||
|
98013322db | ||
97d7be7503 | |||
|
851b2da39c | ||
|
535b990215 | ||
|
26e77181d8 | ||
|
e84d2b6175 | ||
8e70b89593 | |||
671bbf2954 | |||
|
e54df1f167 | ||
|
372ddff917 | ||
|
1708d49a2d | ||
|
c10f3f3ade | ||
|
779ddae8b8 | ||
552155c826 | |||
467f968695 | |||
61424e2cb5 | |||
256e86fdc0 | |||
|
cdb7d78f22 | ||
|
649d704693 | ||
|
1e4f21cc76 | ||
|
dcdf8fb6a0 | ||
|
6024d0125e | ||
|
ea0738ccd5 | ||
|
820f953b96 | ||
|
4041a76a58 | ||
|
0b2c03dc5e | ||
|
fd9ed97fdc | ||
|
ae49194d42 | ||
|
15af6c619d | ||
|
5640f0a67d | ||
|
a59df74fb2 | ||
|
dfd23f63ba | ||
|
56a6f47c7d | ||
|
4faa037c20 | ||
|
af623f5096 | ||
|
0b7bbed99d | ||
|
062939d9cc | ||
|
b3c747151b | ||
|
7d4c5a71aa | ||
|
e39d5c488e | ||
|
fdbe623e10 | ||
|
a5406c5a5d | ||
e628ca1af8 | |||
361d92fe31 | |||
7b93938e71 | |||
bdd906c887 | |||
6b63ecc49e | |||
a99156ed22 | |||
58debefbe0 | |||
|
0388d31d36 | ||
40d2aa0c11 | |||
0ff189d229 | |||
a15eec55d3 | |||
de117e375a | |||
5de329e653 | |||
1d9d63b341 | |||
c919c30ef3 | |||
93e0f26b43 | |||
1ce53e1c21 | |||
f4587c44fd | |||
|
59ef010b0e | ||
|
b7afefb715 | ||
|
1653130de8 | ||
|
dd25740c91 | ||
|
ee48a39eb3 |
23
Makefile
23
Makefile
@ -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
|
||||||
|
@ -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/
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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:
|
||||||
|
@ -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:
|
||||||
|
@ -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/"],
|
||||||
|
@ -21,8 +21,8 @@ await build({
|
|||||||
type: "production",
|
type: "production",
|
||||||
source: {
|
source: {
|
||||||
js: ["src/index.tsx"],
|
js: ["src/index.tsx"],
|
||||||
assets: [{base:"src",files:["src/index.html"]}],
|
assets: [{ base: "src", files: ["src/index.html"] }],
|
||||||
},
|
},
|
||||||
destination: "./dist/prod",
|
destination: "./dist/prod",
|
||||||
css: "sass",
|
css: "postcss",
|
||||||
});
|
});
|
||||||
|
@ -18,17 +18,17 @@
|
|||||||
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",
|
||||||
source: {
|
source: {
|
||||||
js: devEntryPoints,
|
js: devEntryPoints,
|
||||||
assets: [{base:"src",files:["src/index.html"]}],
|
assets: [{ base: "src", files: ["src/index.html"] }],
|
||||||
},
|
},
|
||||||
destination: "./dist/dev",
|
destination: "./dist/dev",
|
||||||
public: "/app",
|
public: "/app",
|
||||||
css: "sass",
|
css: "postcss",
|
||||||
});
|
});
|
||||||
|
|
||||||
await build();
|
await build();
|
||||||
|
@ -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": {
|
||||||
|
6
packages/demobank-ui/postcss.config.js
Normal file
6
packages/demobank-ui/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
9
packages/demobank-ui/src/assets/logo-2021.svg
Normal file
9
packages/demobank-ui/src/assets/logo-2021.svg
Normal 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 |
59
packages/demobank-ui/src/components/Attention.tsx
Normal file
59
packages/demobank-ui/src/components/Attention.tsx
Normal 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>
|
||||||
|
}
|
@ -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
|
||||||
|
60
packages/demobank-ui/src/components/CopyButton.tsx
Normal file
60
packages/demobank-ui/src/components/CopyButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -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"
|
setHidden((h) => !h);
|
||||||
onClick={(ev) => {
|
}}>
|
||||||
ev.preventDefault();
|
<span class="flex items-center">
|
||||||
setHidden((h) => !h);
|
<img src="https://taler.net/images/languageicon.svg" alt="" class="h-5 w-5 flex-shrink-0 rounded-full" />
|
||||||
ev.stopPropagation();
|
<span class="ml-3 block truncate">{getLangName(lang)}</span>
|
||||||
}}
|
</span>
|
||||||
>
|
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
{getLangName(lang)}
|
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
</a>
|
<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" />
|
||||||
<div
|
</svg>
|
||||||
id="lang"
|
</span>
|
||||||
class={hidden ? "hide" : ""}
|
</button>
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
{!hidden &&
|
||||||
}}
|
<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">
|
||||||
>
|
|
||||||
<div style="position: relative; overflow: visible;">
|
|
||||||
<div
|
|
||||||
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 />
|
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
}}
|
}}
|
||||||
|
167
packages/demobank-ui/src/components/Routing.tsx
Normal file
167
packages/demobank-ui/src/components/Routing.tsx
Normal 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");
|
||||||
|
}
|
@ -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>;
|
||||||
}
|
}
|
@ -46,6 +46,8 @@ export namespace State {
|
|||||||
status: "ready";
|
status: "ready";
|
||||||
error: undefined;
|
error: undefined;
|
||||||
transactions: Transaction[];
|
transactions: Transaction[];
|
||||||
|
onPrev?: () => void;
|
||||||
|
onNext?: () => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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">
|
||||||
<thead>
|
<div class="sm:flex-auto">
|
||||||
<tr>
|
<h1 class="text-base font-semibold leading-6 text-gray-900"><i18n.Translate>Latest transactions</i18n.Translate></h1>
|
||||||
<th>{i18n.str`Date`}</th>
|
</div>
|
||||||
<th>{i18n.str`Amount`}</th>
|
</div>
|
||||||
<th>{i18n.str`Counterpart`}</th>
|
<div class="-mx-4 mt-5 ring-1 ring-gray-300 sm:mx-0 rounded-lg min-w-fit bg-white">
|
||||||
<th>{i18n.str`Subject`}</th>
|
<table class="min-w-full divide-y divide-gray-300">
|
||||||
</tr>
|
<thead>
|
||||||
</thead>
|
<tr>
|
||||||
<tbody>
|
<th scope="col" class="pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 ">{i18n.str`Date`}</th>
|
||||||
{transactions.map((item, idx) => {
|
<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>
|
||||||
return (
|
<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>
|
||||||
<tr key={idx}>
|
<th scope="col" class="pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 ">{i18n.str`Subject`}</th>
|
||||||
<td>
|
</tr>
|
||||||
{item.when.t_ms === "never"
|
</thead>
|
||||||
? ""
|
<tbody>
|
||||||
: format(item.when.t_ms, "dd/MM/yyyy HH:mm:ss")}
|
{Object.entries(txByDate).map(([date, txs], idx) => {
|
||||||
</td>
|
return <Fragment>
|
||||||
<td>
|
<tr class="border-t border-gray-200">
|
||||||
{item.negative ? "-" : ""}
|
<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.amount ? (
|
{date}
|
||||||
`${Amounts.stringifyValue(item.amount)} ${
|
</th>
|
||||||
item.amount.currency
|
</tr>
|
||||||
}`
|
{txs.map(item => {
|
||||||
) : (
|
const time = item.when.t_ms === "never" ? "" : format(item.when.t_ms, "HH:mm:ss")
|
||||||
<span style={{ color: "grey" }}><invalid value></span>
|
const amount = <Fragment>
|
||||||
)}
|
{ }
|
||||||
</td>
|
</Fragment>
|
||||||
<td>{item.counterpart}</td>
|
return (<tr key={idx}>
|
||||||
<td>{item.subject}</td>
|
<td class="relative py-2 pl-2 pr-2 text-sm ">
|
||||||
</tr>
|
<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>
|
||||||
</tbody>
|
<dd class="mt-1 truncate text-gray-700">
|
||||||
</table>
|
{item.negative ? i18n.str`sent` : i18n.str`received`} {item.amount ? (
|
||||||
|
<RenderAmount value={item.amount} />
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "grey" }}><{i18n.str`invalid value`}></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" }}><{i18n.str`invalid value`}></span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500">{item.counterpart}</td>
|
||||||
|
<td class="px-3 py-3.5 text-sm text-gray-500 break-all min-w-md">{item.subject}</td>
|
||||||
|
</tr>)
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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,22 +55,44 @@ const App: FunctionalComponent = () => {
|
|||||||
return (
|
return (
|
||||||
<TranslationProvider source={strings}>
|
<TranslationProvider source={strings}>
|
||||||
<BackendStateProvider>
|
<BackendStateProvider>
|
||||||
<SWRConfig
|
<VersionCheck>
|
||||||
value={{
|
<SWRConfig
|
||||||
provider: WITH_LOCAL_STORAGE_CACHE
|
value={{
|
||||||
? localStorageProvider
|
provider: WITH_LOCAL_STORAGE_CACHE
|
||||||
: undefined,
|
? localStorageProvider
|
||||||
}}
|
: undefined,
|
||||||
>
|
}}
|
||||||
<Routing />
|
>
|
||||||
</SWRConfig>
|
<Routing />
|
||||||
|
</SWRConfig>
|
||||||
|
</VersionCheck>
|
||||||
</BackendStateProvider>
|
</BackendStateProvider>
|
||||||
</TranslationProvider>
|
</TranslationProvider >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
(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") || "[]"));
|
||||||
|
|
||||||
|
@ -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: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
213
packages/demobank-ui/src/declaration.d.ts
vendored
213
packages/demobank-ui/src/declaration.d.ts
vendored
@ -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 {
|
||||||
|
21
packages/demobank-ui/src/demobank-ui-settings.js
Normal file
21
packages/demobank-ui/src/demobank-ui-settings.js
Normal 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/"],
|
||||||
|
],
|
||||||
|
};
|
66
packages/demobank-ui/src/forms/simplest.ts
Normal file
66
packages/demobank-ui/src/forms/simplest.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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,28 +187,8 @@ 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);
|
return data;
|
||||||
if (isAmount) {
|
|
||||||
//server response with correct format
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
const { currency } = Amounts.parseOrThrow(data.data.balance.amount);
|
|
||||||
const clone = structuredClone(data);
|
|
||||||
|
|
||||||
const theNumber = Number.parseInt(data.data.debitThreshold, 10);
|
|
||||||
const value = Number.isNaN(theNumber) ? 0 : theNumber;
|
|
||||||
clone.data.debitThreshold = Amounts.stringify({
|
|
||||||
currency,
|
|
||||||
value: value,
|
|
||||||
fraction: 0,
|
|
||||||
});
|
|
||||||
return clone;
|
|
||||||
}
|
}
|
||||||
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)
|
||||||
|
// }
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
result = bankUiSettings.backendBaseURL;
|
||||||
}
|
}
|
||||||
return canonicalizeBaseUrl(bankUiSettings.backendBaseURL);
|
} else {
|
||||||
|
// testing/development path
|
||||||
|
result = overrideUrl
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return canonicalizeBaseUrl(result)
|
||||||
|
} catch (e) {
|
||||||
|
//fall back
|
||||||
|
return canonicalizeBaseUrl(window.origin)
|
||||||
}
|
}
|
||||||
// testing/development path
|
|
||||||
return canonicalizeBaseUrl(overrideUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultState: BackendState = {
|
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 },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -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,
|
||||||
|
59
packages/demobank-ui/src/hooks/config.ts
Normal file
59
packages/demobank-ui/src/hooks/config.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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];
|
|
||||||
}
|
|
@ -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(
|
||||||
|
135
packages/demobank-ui/src/hooks/useCredentialsChecker.ts
Normal file
135
packages/demobank-ui/src/hooks/useCredentialsChecker.ts
Normal 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;
|
||||||
|
}
|
@ -16,27 +16,28 @@
|
|||||||
@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>
|
|
||||||
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta charset="utf-8" />
|
||||||
<meta name="taler-support" content="uri">
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="taler-support" content="uri">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<link
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
rel="icon"
|
<link 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. -->
|
<script src="demobank-ui-settings.js"></script>
|
||||||
<script src="demobank-ui-settings.js"></script>
|
<!-- Entry point for the demobank SPA. -->
|
||||||
<!-- Entry point for the demobank SPA. -->
|
<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>
|
@ -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");
|
||||||
|
|
||||||
|
@ -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 "{data.paytoUri}"</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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
92
packages/demobank-ui/src/pages/AccountPage/index.ts
Normal file
92
packages/demobank-ui/src/pages/AccountPage/index.ts
Normal 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,
|
||||||
|
);
|
92
packages/demobank-ui/src/pages/AccountPage/state.ts
Normal file
92
packages/demobank-ui/src/pages/AccountPage/state.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
@ -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, {});
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
93
packages/demobank-ui/src/pages/AccountPage/views.tsx
Normal file
93
packages/demobank-ui/src/pages/AccountPage/views.tsx
Normal 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 "{error.data.payto_uri}"</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
@ -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 = [];
|
||||||
for (const i in bankUiSettings.demoSites)
|
if (bankUiSettings.demoSites) {
|
||||||
demo_sites.push(
|
for (const i in bankUiSettings.demoSites)
|
||||||
<a href={bankUiSettings.demoSites[i][1]}>
|
demo_sites.push(
|
||||||
{bankUiSettings.demoSites[i][0]}
|
<a href={bankUiSettings.demoSites[i][1]}>
|
||||||
</a>,
|
{bankUiSettings.demoSites[i][0]}
|
||||||
);
|
</a>,
|
||||||
|
);
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<header
|
|
||||||
class="demobar"
|
|
||||||
style="display: flex; flex-direction: row; justify-content: space-between;"
|
|
||||||
>
|
|
||||||
<a href="#main" class="skip">{i18n.str`Skip to main content`}</a>
|
|
||||||
<div style="max-width: 50em; margin-left: 2em; margin-right: 2em;">
|
|
||||||
<h1>
|
|
||||||
<span class="it">
|
|
||||||
<a href="/">{bankUiSettings.bankName}</a>
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
{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}
|
|
||||||
|
|
||||||
<LangSelector />
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="pure-button logout-button"
|
|
||||||
onClick={() => {
|
|
||||||
backend.logOut();
|
|
||||||
updateSettings("currentWithdrawalOperationId", undefined);
|
|
||||||
}}
|
|
||||||
>{i18n.str`Logout`}</a>
|
|
||||||
</Fragment>
|
|
||||||
) : undefined}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<section id="main" class="content">
|
|
||||||
<StatusBanner />
|
|
||||||
{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 style="flex-grow:1" />
|
|
||||||
<p>
|
|
||||||
Copyright © 2014—2022 Taler Systems SA. {versionText}{" "}
|
|
||||||
<TestingTag />
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeDemoContent(content: VNode): VNode {
|
|
||||||
if (bankUiSettings.showDemoNav) {
|
|
||||||
return content;
|
|
||||||
}
|
}
|
||||||
return <Fragment />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorBannerFloat({
|
return (<div class="min-h-full flex flex-col m-0" style="min-height: 100vh;">
|
||||||
error,
|
<div class="bg-indigo-600 pb-32">
|
||||||
onClear,
|
<nav class="">
|
||||||
}: {
|
<div class="mx-auto max-w-7xl px-2 sm:px-4 lg:px-8">
|
||||||
error: ErrorMessage;
|
<div class="relative flex h-16 items-center justify-between ">
|
||||||
onClear?: () => void;
|
<div class="flex items-center px-2 lg:px-0">
|
||||||
}): VNode {
|
<div class="flex-shrink-0 bg-white rounded-lg">
|
||||||
return (
|
<a href={bankUiSettings.iconLinkURL ?? "#"}>
|
||||||
<div
|
<img
|
||||||
style={{
|
class="h-8 w-auto"
|
||||||
position: "fixed",
|
src={logo}
|
||||||
top: 10,
|
alt="Taler"
|
||||||
zIndex: 200,
|
style={{ height: "1.5rem", margin: ".5rem" }}
|
||||||
width: "90%",
|
/>
|
||||||
}}
|
</a>
|
||||||
>
|
</div>
|
||||||
<ErrorBanner error={error} onClear={onClear} />
|
{bankUiSettings.demoSites &&
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
function ErrorBanner({
|
<div class="flex">
|
||||||
error,
|
<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"
|
||||||
onClear,
|
onClick={(e) => {
|
||||||
}: {
|
setOpen(!open)
|
||||||
error: ErrorMessage;
|
}}>
|
||||||
onClear?: () => void;
|
<span class="absolute -inset-0.5"></span>
|
||||||
}): VNode {
|
<span class="sr-only">Open settings</span>
|
||||||
return (
|
<svg class="block h-10 w-10" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
|
||||||
<div
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
class="informational informational-fail"
|
</svg>
|
||||||
style={{
|
</button>
|
||||||
marginTop: 8,
|
|
||||||
paddingLeft: 16,
|
|
||||||
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>
|
|
||||||
<p>{error.description}</p>
|
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{!error ? undefined : (
|
{open &&
|
||||||
<ErrorBanner
|
<div class="relative z-10" aria-labelledby="slide-over-title" role="dialog" aria-modal="true"
|
||||||
error={error}
|
onClick={() => {
|
||||||
onClear={() => {
|
setOpen(false)
|
||||||
setError(undefined);
|
}}>
|
||||||
}}
|
<div class="fixed inset-0"></div>
|
||||||
/>
|
|
||||||
)}
|
<div class="fixed inset-0 overflow-hidden">
|
||||||
</div>
|
<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={() => {
|
||||||
|
backend.logOut();
|
||||||
|
setOpen(false)
|
||||||
|
updateSettings("currentWithdrawalOperationId", undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<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">></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>
|
||||||
|
</div>
|
||||||
|
</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 />
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div >
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MaybeShowDebugInfo({ info }: { info: any }): VNode {
|
||||||
|
const [settings] = useSettings()
|
||||||
|
if (settings.showDebugInfo) {
|
||||||
|
return <pre class="whitespace-break-spaces ">
|
||||||
|
{info}
|
||||||
|
</pre>
|
||||||
|
}
|
||||||
|
return <Fragment />
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function StatusBanner(): VNode {
|
||||||
|
const notifs = useNotifications()
|
||||||
|
if (notifs.length === 0) return <Fragment />
|
||||||
|
return <div class="fixed z-20 w-full p-4"> {
|
||||||
|
notifs.map(n => {
|
||||||
|
switch (n.message.type) {
|
||||||
|
case "error":
|
||||||
|
return <Attention type="danger" title={n.message.title} onClose={() => {
|
||||||
|
n.remove()
|
||||||
|
}}>
|
||||||
|
{n.message.description &&
|
||||||
|
<div class="mt-2 text-sm text-red-700">
|
||||||
|
{n.message.description}
|
||||||
|
</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 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 © 2014—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"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@ -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 />;
|
||||||
|
@ -14,199 +14,249 @@
|
|||||||
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;
|
||||||
|
|
||||||
|
function saveError({ title, description, debug }: { title: TranslatedString, description?: TranslatedString, debug?: any }) {
|
||||||
|
notifyError(title, description, debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
backend.logOut()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogin() {
|
||||||
|
if (!username || !password) return;
|
||||||
|
setBusy({})
|
||||||
|
const result = await requestNewLoginToken(username, password);
|
||||||
|
if (result.valid) {
|
||||||
|
backend.logIn({ username, token: result.token });
|
||||||
|
} else {
|
||||||
|
const { cause } = result;
|
||||||
|
switch (cause.type) {
|
||||||
|
case ErrorType.CLIENT: {
|
||||||
|
if (cause.status === HttpStatusCode.Unauthorized) {
|
||||||
|
saveError({
|
||||||
|
title: i18n.str`Wrong credentials for "${username}"`,
|
||||||
|
});
|
||||||
|
} else
|
||||||
|
if (cause.status === HttpStatusCode.NotFound) {
|
||||||
|
saveError({
|
||||||
|
title: i18n.str`Account not found`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
saveError({
|
||||||
|
title: i18n.str`Could not load due to a request error`,
|
||||||
|
description: i18n.str`Request to url "${cause.info.url}" returned ${cause.info.status}`,
|
||||||
|
debug: JSON.stringify(cause.payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ErrorType.SERVER: {
|
||||||
|
saveError({
|
||||||
|
title: i18n.str`Server had a problem, try again later or report.`,
|
||||||
|
// description: cause.payload.error.description,
|
||||||
|
debug: JSON.stringify(cause.payload),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ErrorType.TIMEOUT: {
|
||||||
|
saveError({
|
||||||
|
title: i18n.str`Request timeout, try again later.`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ErrorType.UNREADABLE: {
|
||||||
|
saveError({
|
||||||
|
title: i18n.str`Unexpected error.`,
|
||||||
|
description: `Response from ${cause.info?.url} is unreadable, http status: ${cause.status}` as TranslatedString,
|
||||||
|
debug: JSON.stringify(cause),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
saveError({
|
||||||
|
title: i18n.str`Unexpected error, please report.`,
|
||||||
|
description: `Diagnostic from ${cause.info?.url} is "${cause.message}"` as TranslatedString,
|
||||||
|
debug: JSON.stringify(cause),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// backend.logOut();
|
||||||
|
}
|
||||||
|
setPassword(undefined);
|
||||||
|
setBusy(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<div class="flex min-h-full flex-col justify-center">
|
||||||
<h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1>
|
|
||||||
{error && (
|
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
|
||||||
<ErrorBannerFloat error={error} onClear={() => saveError(undefined)} />
|
<form class="space-y-6" noValidate
|
||||||
)}
|
|
||||||
<div class="login-div">
|
|
||||||
<form
|
|
||||||
class="login-form"
|
|
||||||
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 login!`}</h2>
|
<label for="username" class="block text-sm font-medium leading-6 text-gray-900">
|
||||||
<p class="unameFieldLabel loginFieldLabel formFieldLabel">
|
<i18n.Translate>Username</i18n.Translate>
|
||||||
<label for="username">{i18n.str`Username:`}</label>
|
</label>
|
||||||
</p>
|
<div class="mt-2">
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={doAutoFocus}
|
||||||
autoFocus
|
type="text"
|
||||||
type="text"
|
name="username"
|
||||||
name="username"
|
id="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 ?? ""}
|
value={username ?? ""}
|
||||||
enterkeyhint="next"
|
disabled={isSessionExpired}
|
||||||
placeholder="Username"
|
enterkeyhint="next"
|
||||||
autocomplete="username"
|
placeholder="identification"
|
||||||
required
|
autocomplete="username"
|
||||||
onInput={(e): void => {
|
required
|
||||||
setUsername(e.currentTarget.value);
|
onInput={(e): void => {
|
||||||
}}
|
setUsername(e.currentTarget.value);
|
||||||
/>
|
}}
|
||||||
<ShowInputErrorLabel
|
/>
|
||||||
message={errors?.username}
|
<ShowInputErrorLabel
|
||||||
isDirty={username !== undefined}
|
message={errors?.username}
|
||||||
/>
|
isDirty={username !== undefined}
|
||||||
<p class="passFieldLabel loginFieldLabel formFieldLabel">
|
/>
|
||||||
<label for="password">{i18n.str`Password:`}</label>
|
</div>
|
||||||
</p>
|
</div>
|
||||||
<input
|
|
||||||
type="password"
|
<div>
|
||||||
name="password"
|
<div class="flex items-center justify-between">
|
||||||
id="password"
|
<label for="password" class="block text-sm font-medium leading-6 text-gray-900">Password</label>
|
||||||
autocomplete="current-password"
|
</div>
|
||||||
enterkeyhint="send"
|
<div class="mt-2">
|
||||||
value={password ?? ""}
|
<input
|
||||||
placeholder="Password"
|
type="password"
|
||||||
required
|
name="password"
|
||||||
onInput={(e): void => {
|
id="password"
|
||||||
setPassword(e.currentTarget.value);
|
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"
|
||||||
<ShowInputErrorLabel
|
value={password ?? ""}
|
||||||
message={errors?.password}
|
placeholder="Password"
|
||||||
isDirty={password !== undefined}
|
required
|
||||||
/>
|
onInput={(e): void => {
|
||||||
<br />
|
setPassword(e.currentTarget.value);
|
||||||
<button
|
}}
|
||||||
type="submit"
|
/>
|
||||||
class="pure-button pure-button-primary"
|
<ShowInputErrorLabel
|
||||||
disabled={!!errors}
|
message={errors?.password}
|
||||||
onClick={async (e) => {
|
isDirty={password !== undefined}
|
||||||
e.preventDefault();
|
/>
|
||||||
if (!username || !password) return;
|
</div>
|
||||||
const testResult = await testLogin(username, password);
|
</div>
|
||||||
if (testResult.valid) {
|
|
||||||
backend.logIn({ username, password });
|
{isSessionExpired ? <div class="flex justify-between">
|
||||||
} else {
|
<button type="submit"
|
||||||
if (testResult.requestError) {
|
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"
|
||||||
const { cause } = testResult;
|
onClick={(e) => {
|
||||||
switch (cause.type) {
|
e.preventDefault()
|
||||||
case ErrorType.CLIENT: {
|
doLogout()
|
||||||
if (cause.status === HttpStatusCode.Unauthorized) {
|
|
||||||
saveError({
|
|
||||||
title: i18n.str`Wrong credentials for "${username}"`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (cause.status === HttpStatusCode.NotFound) {
|
|
||||||
saveError({
|
|
||||||
title: i18n.str`Account not found`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
saveError({
|
|
||||||
title: i18n.str`Could not load due to a client error`,
|
|
||||||
description: cause.payload.error.description,
|
|
||||||
debug: JSON.stringify(cause.payload),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case ErrorType.SERVER: {
|
|
||||||
saveError({
|
|
||||||
title: i18n.str`Server had a problem, try again later or report.`,
|
|
||||||
description: cause.payload.error.description,
|
|
||||||
debug: JSON.stringify(cause.payload),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case ErrorType.TIMEOUT: {
|
|
||||||
saveError({
|
|
||||||
title: i18n.str`Request timeout, try again later.`,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case ErrorType.UNREADABLE: {
|
|
||||||
saveError({
|
|
||||||
title: i18n.str`Unexpected error.`,
|
|
||||||
description: `Response from ${cause.info?.url} is unreadable, http status: ${cause.status}`,
|
|
||||||
debug: JSON.stringify(cause),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
saveError({
|
|
||||||
title: i18n.str`Unexpected error, please report.`,
|
|
||||||
description: `Diagnostic from ${cause.info?.url} is "${cause.message}"`,
|
|
||||||
debug: JSON.stringify(cause),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
saveError({
|
|
||||||
title: i18n.str`Unexpected error, please report.`,
|
|
||||||
debug: JSON.stringify(testResult.error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
backend.logOut();
|
|
||||||
}
|
|
||||||
setUsername(undefined);
|
|
||||||
setPassword(undefined);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{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>
|
||||||
</Fragment>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
122
packages/demobank-ui/src/pages/OperationState/index.ts
Normal file
122
packages/demobank-ui/src/pages/OperationState/index.ts
Normal 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,
|
||||||
|
);
|
265
packages/demobank-ui/src/pages/OperationState/state.ts
Normal file
265
packages/demobank-ui/src/pages/OperationState/state.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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, {});
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
376
packages/demobank-ui/src/pages/OperationState/views.tsx
Normal file
376
packages/demobank-ui/src/pages/OperationState/views.tsx
Normal 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 "{payto}"</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function InvalidWithdrawalView({ uri, onClose }: State.InvalidWithdrawal) {
|
||||||
|
return (
|
||||||
|
<div>Withdrawal uri from server is not valid "{uri}"</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function InvalidReserveView({ reserve, onClose }: State.InvalidReserve) {
|
||||||
|
return (
|
||||||
|
<div>Reserve from server is not valid "{reserve}"</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`}
|
||||||
|
<em>
|
||||||
|
{captchaNumbers.a} + {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>
|
||||||
|
|
||||||
|
}
|
@ -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">💵</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">↔</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">
|
<WalletWithdrawForm
|
||||||
<h3>{i18n.str`Obtain digital cash`}</h3>
|
focus
|
||||||
<WalletWithdrawForm
|
limit={limit}
|
||||||
focus
|
goToConfirmOperation={goToConfirmOperation}
|
||||||
limit={limit}
|
onCancel={() => {
|
||||||
onSuccess={(id) => {
|
setTab(undefined)
|
||||||
updateSettings("currentWithdrawalOperationId", id);
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{tab === "wire-transfer" && (
|
{tab === "wire-transfer" && (
|
||||||
<div id="wire-transfer" class="tabcontent active">
|
<PaytoWireTransferForm
|
||||||
<h3>{i18n.str`Transfer to bank account`}</h3>
|
focus
|
||||||
<PaytoWireTransferForm
|
title={i18n.str`Transfer details`}
|
||||||
focus
|
limit={limit}
|
||||||
limit={limit}
|
onSuccess={() => {
|
||||||
onSuccess={() => {
|
notifyInfo(i18n.str`Wire transfer created!`);
|
||||||
notifyInfo(i18n.str`Wire transfer created!`);
|
setTab(undefined)
|
||||||
}}
|
}}
|
||||||
/>
|
onCancel={() => {
|
||||||
</div>
|
setTab(undefined)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</article>
|
</fieldset>
|
||||||
);
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -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,295 +79,372 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { createTransaction } = useAccessAPI();
|
const { createTransaction } = useAccessAPI();
|
||||||
|
|
||||||
if (!isRawPayto)
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<form
|
|
||||||
class="pure-form"
|
|
||||||
name="wire-transfer-form"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect="off"
|
|
||||||
>
|
|
||||||
<label for="iban">{i18n.str`Receiver IBAN:`}</label>
|
|
||||||
<input
|
|
||||||
ref={ref}
|
|
||||||
type="text"
|
|
||||||
id="iban"
|
|
||||||
name="iban"
|
|
||||||
value={iban ?? ""}
|
|
||||||
placeholder="CC0123456789"
|
|
||||||
required
|
|
||||||
pattern={ibanRegex}
|
|
||||||
onInput={(e): void => {
|
|
||||||
setIban(e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ShowInputErrorLabel
|
|
||||||
message={errorsWire?.iban}
|
|
||||||
isDirty={iban !== undefined}
|
|
||||||
/>
|
|
||||||
<label for="subject">{i18n.str`Transfer subject:`}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="subject"
|
|
||||||
id="subject"
|
|
||||||
placeholder="subject"
|
|
||||||
value={subject ?? ""}
|
|
||||||
required
|
|
||||||
onInput={(e): void => {
|
|
||||||
setSubject(e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ShowInputErrorLabel
|
|
||||||
message={errorsWire?.subject}
|
|
||||||
isDirty={subject !== undefined}
|
|
||||||
/>
|
|
||||||
<label for="amount">{i18n.str`Amount:`}</label>
|
|
||||||
<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>
|
|
||||||
<ShowInputErrorLabel
|
|
||||||
message={errorsWire?.amount}
|
|
||||||
isDirty={amount !== undefined}
|
|
||||||
/>
|
|
||||||
<p style={{ display: "flex", justifyContent: "space-between" }}>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput);
|
const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput);
|
||||||
|
|
||||||
const errorsPayto = undefinedIfEmpty({
|
const errorsPayto = undefinedIfEmpty({
|
||||||
rawPaytoInput: !rawPaytoInput
|
rawPaytoInput: !rawPaytoInput
|
||||||
? i18n.str`required`
|
? i18n.str`required`
|
||||||
: !parsed
|
: !parsed
|
||||||
? i18n.str`does not follow the pattern`
|
? i18n.str`does not follow the pattern`
|
||||||
: !parsed.params.amount
|
: !parsed.isKnown || parsed.targetType !== "iban"
|
||||||
? i18n.str`use the "amount" parameter to specify the amount to be transferred`
|
? i18n.str`only "IBAN" target are supported`
|
||||||
: Amounts.parse(parsed.params.amount) === undefined
|
: !parsed.params.amount
|
||||||
? i18n.str`the amount is not valid`
|
? i18n.str`use the "amount" parameter to specify the amount to be transferred`
|
||||||
: !parsed.params.message
|
: Amounts.parse(parsed.params.amount) === undefined
|
||||||
? i18n.str`use the "message" parameter to specify a reference text for the transfer`
|
? i18n.str`the amount is not valid`
|
||||||
: !parsed.isKnown || parsed.targetType !== "iban"
|
: !parsed.params.message
|
||||||
? i18n.str`only "IBAN" target are supported`
|
? i18n.str`use the "message" parameter to specify a reference text for the transfer`
|
||||||
: !IBAN_REGEX.test(parsed.iban)
|
: !IBAN_REGEX.test(parsed.iban)
|
||||||
? i18n.str`IBAN should have just uppercased letters and numbers`
|
? i18n.str`IBAN should have just uppercased letters and numbers`
|
||||||
: validateIBAN(parsed.iban, i18n),
|
: validateIBAN(parsed.iban, i18n),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
async function doSend() {
|
||||||
<div>
|
let payto_uri: string | undefined;
|
||||||
<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>
|
|
||||||
<input
|
|
||||||
name="address"
|
|
||||||
type="text"
|
|
||||||
size={50}
|
|
||||||
ref={ref}
|
|
||||||
id="address"
|
|
||||||
value={rawPaytoInput ?? ""}
|
|
||||||
required
|
|
||||||
placeholder={i18n.str`payto address`}
|
|
||||||
// pattern={`payto://iban/[A-Z][A-Z][0-9]+?message=[a-zA-Z0-9 ]+&amount=${currency}:[0-9]+(.[0-9]+)?`}
|
|
||||||
onInput={(e): void => {
|
|
||||||
rawPaytoInputSetter(e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ShowInputErrorLabel
|
|
||||||
message={errorsPayto?.rawPaytoInput}
|
|
||||||
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>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if (rawPaytoInput) {
|
||||||
await createTransaction({
|
payto_uri = rawPaytoInput
|
||||||
paytoUri: rawPaytoInput,
|
} else {
|
||||||
});
|
if (!iban || !subject) return;
|
||||||
onSuccess();
|
const ibanPayto = buildPayto("iban", iban, undefined);
|
||||||
rawPaytoInputSetter(undefined);
|
ibanPayto.params.message = encodeURIComponent(subject);
|
||||||
} catch (error) {
|
payto_uri = stringifyPaytoUri(ibanPayto);
|
||||||
if (error instanceof RequestError) {
|
}
|
||||||
notifyError(
|
|
||||||
buildRequestErrorMessage(i18n, error.cause, {
|
try {
|
||||||
onClientError: (status) =>
|
await createTransaction({
|
||||||
status === HttpStatusCode.BadRequest
|
payto_uri,
|
||||||
? i18n.str`The request was invalid or the payto://-URI used unacceptable features.`
|
amount: `${limit.currency}:${amount}`,
|
||||||
: undefined,
|
});
|
||||||
}),
|
onSuccess();
|
||||||
);
|
setAmount(undefined);
|
||||||
} else {
|
setIban(undefined);
|
||||||
notifyError({
|
setSubject(undefined);
|
||||||
title: i18n.str`Operation failed, please report`,
|
rawPaytoInputSetter(undefined)
|
||||||
description:
|
} catch (error) {
|
||||||
error instanceof Error
|
if (error instanceof RequestError) {
|
||||||
? error.message
|
notify(
|
||||||
: JSON.stringify(error),
|
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 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)
|
||||||
/>
|
}} />
|
||||||
</p>
|
<span class="flex flex-1">
|
||||||
<p>
|
<span class="flex flex-col">
|
||||||
<a
|
<span class="block text-sm font-medium text-gray-900">
|
||||||
href="/account"
|
<i18n.Translate>Using a form</i18n.Translate>
|
||||||
onClick={() => {
|
</span>
|
||||||
setIsRawPayto(false);
|
</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
|
||||||
|
class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2 w-fit mx-auto"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
onSubmit={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
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 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||||
|
name="iban"
|
||||||
|
id="iban"
|
||||||
|
value={iban ?? ""}
|
||||||
|
placeholder="CC0123456789"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
pattern={ibanRegex}
|
||||||
|
onInput={(e): void => {
|
||||||
|
setIban(e.currentTarget.value.toUpperCase());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ShowInputErrorLabel
|
||||||
|
message={errorsWire?.iban}
|
||||||
|
isDirty={iban !== undefined}
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
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"
|
||||||
|
id="subject"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="subject"
|
||||||
|
value={subject ?? ""}
|
||||||
|
required
|
||||||
|
onInput={(e): void => {
|
||||||
|
setSubject(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ShowInputErrorLabel
|
||||||
|
message={errorsWire?.subject}
|
||||||
|
isDirty={subject !== undefined}
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
message={errorsWire?.amount}
|
||||||
|
isDirty={subject !== undefined}
|
||||||
|
/>
|
||||||
|
<p class="mt-2 text-sm text-gray-500" >amount to transfer</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div> :
|
||||||
|
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6 w-full">
|
||||||
|
<div class="sm:col-span-6">
|
||||||
|
<label for="address" class="block text-sm font-medium leading-6 text-gray-900">{i18n.str`payto URI:`}</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<textarea
|
||||||
|
ref={focus ? doAutoFocus : undefined}
|
||||||
|
name="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 ?? ""}
|
||||||
|
required
|
||||||
|
placeholder={i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limit.currency}:X.Y]`}
|
||||||
|
onInput={(e): void => {
|
||||||
|
rawPaytoInputSetter(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ShowInputErrorLabel
|
||||||
|
message={errorsPayto?.rawPaytoInput}
|
||||||
|
isDirty={rawPaytoInput !== undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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.str`Use wire-transfer form?`}
|
<i18n.Translate>Cancel</i18n.Translate>
|
||||||
</a>
|
</button>
|
||||||
</p>
|
: <div />
|
||||||
</form>
|
}
|
||||||
|
<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()
|
||||||
|
doSend()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i18n.Translate>Send</i18n.Translate>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</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>
|
</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>
|
||||||
|
}
|
@ -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 (
|
||||||
|
@ -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,47 +51,87 @@ export function QrCodeSection({
|
|||||||
const talerWithdrawUri = stringifyWithdrawUri(withdrawUri);
|
const talerWithdrawUri = stringifyWithdrawUri(withdrawUri);
|
||||||
|
|
||||||
const { abortWithdrawal } = useAccessAnonAPI();
|
const { abortWithdrawal } = useAccessAnonAPI();
|
||||||
|
|
||||||
|
async function doAbort() {
|
||||||
|
try {
|
||||||
|
await abortWithdrawal(withdrawUri.withdrawalOperationId);
|
||||||
|
onAborted();
|
||||||
|
} 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="main" class="content">
|
<Fragment>
|
||||||
<h1 class="nav">{i18n.str`Charge your GNU Taler wallet`}</h1>
|
<div class="bg-white shadow-xl sm:rounded-lg">
|
||||||
<article>
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<div class="qr-div ">
|
<h3 class="text-base font-semibold leading-6 text-gray-900">
|
||||||
<a href={talerWithdrawUri} class="pure-button pure-button-primary">
|
<i18n.Translate>If you have a Taler wallet installed in this device</i18n.Translate>
|
||||||
<i18n.Translate>Continue with GNU Taler</i18n.Translate>
|
</h3>
|
||||||
</a>
|
<div class="mt-4 mb-4 text-sm text-gray-500">
|
||||||
<p>{i18n.str`Or scan this QR code with your mobile to receive the coin in another device:`}</p>
|
<p><i18n.Translate>
|
||||||
<QR text={talerWithdrawUri} />
|
You will see the details of the operation in your wallet including the fees (if applies).
|
||||||
<a
|
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>.
|
||||||
class="pure-button btn-cancel"
|
</i18n.Translate></p>
|
||||||
onClick={async (e) => {
|
</div>
|
||||||
e.preventDefault();
|
<div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 pt-2 mt-2 ">
|
||||||
try {
|
<button type="button"
|
||||||
await abortWithdrawal(withdrawUri.withdrawalOperationId);
|
// 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"
|
||||||
onAborted();
|
class="text-sm font-semibold leading-6 text-gray-900"
|
||||||
} catch (error) {
|
onClick={doAbort}
|
||||||
if (error instanceof RequestError) {
|
>
|
||||||
notifyError(
|
Cancel
|
||||||
buildRequestErrorMessage(i18n, error.cause, {
|
</button>
|
||||||
onClientError: (status) =>
|
<a href={talerWithdrawUri}
|
||||||
status === HttpStatusCode.Conflict
|
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.str`The reserve operation has been confirmed previously and can't be aborted`
|
>
|
||||||
: undefined,
|
<i18n.Translate>Withdraw</i18n.Translate>
|
||||||
}),
|
</a>
|
||||||
);
|
</div>
|
||||||
} else {
|
|
||||||
notifyError({
|
|
||||||
title: i18n.str`Operation failed, please report`,
|
|
||||||
description:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: JSON.stringify(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>{i18n.str`Cancel`}</a>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
</section>
|
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -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,168 +45,357 @@ 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`
|
||||||
: repeatPassword !== password
|
: repeatPassword !== password
|
||||||
? i18n.str`Passwords don't match`
|
? i18n.str`Passwords don't match`
|
||||||
: 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>
|
||||||
<input
|
<div class="mt-2">
|
||||||
id="register-un"
|
<input
|
||||||
name="register-un"
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Username"
|
name="username"
|
||||||
autocomplete="username"
|
id="username"
|
||||||
value={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"
|
||||||
onInput={(e): void => {
|
value={username ?? ""}
|
||||||
setUsername(e.currentTarget.value);
|
enterkeyhint="next"
|
||||||
}}
|
placeholder="identification"
|
||||||
/>
|
autocomplete="username"
|
||||||
<ShowInputErrorLabel
|
required
|
||||||
message={errors?.username}
|
onInput={(e): void => {
|
||||||
isDirty={username !== undefined}
|
setUsername(e.currentTarget.value);
|
||||||
/>
|
}}
|
||||||
<p class="unameFieldLabel registerFieldLabel formFieldLabel">
|
/>
|
||||||
<label for="register-pw">{i18n.str`Password:`}</label>
|
<ShowInputErrorLabel
|
||||||
</p>
|
message={errors?.username}
|
||||||
<input
|
isDirty={username !== undefined}
|
||||||
type="password"
|
/>
|
||||||
name="register-pw"
|
</div>
|
||||||
id="register-pw"
|
</div>
|
||||||
placeholder="Password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
value={password ?? ""}
|
|
||||||
required
|
|
||||||
onInput={(e): void => {
|
|
||||||
setPassword(e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ShowInputErrorLabel
|
|
||||||
message={errors?.password}
|
|
||||||
isDirty={password !== undefined}
|
|
||||||
/>
|
|
||||||
<p class="unameFieldLabel registerFieldLabel formFieldLabel">
|
|
||||||
<label for="register-repeat">{i18n.str`Repeat Password:`}</label>
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
style={{ marginBottom: 8 }}
|
|
||||||
name="register-repeat"
|
|
||||||
id="register-repeat"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Same password"
|
|
||||||
value={repeatPassword ?? ""}
|
|
||||||
required
|
|
||||||
onInput={(e): void => {
|
|
||||||
setRepeatPassword(e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ShowInputErrorLabel
|
|
||||||
message={errors?.repeatPassword}
|
|
||||||
isDirty={repeatPassword !== undefined}
|
|
||||||
/>
|
|
||||||
<br />
|
|
||||||
<button
|
|
||||||
class="pure-button pure-button-primary btn-register"
|
|
||||||
type="submit"
|
|
||||||
disabled={!!errors}
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!username || !password) return;
|
<div>
|
||||||
try {
|
<div class="flex items-center justify-between">
|
||||||
const credentials = { username, password };
|
<label for="password" class="block text-sm font-medium leading-6 text-gray-900">
|
||||||
await register(credentials);
|
<i18n.Translate>Password</i18n.Translate>
|
||||||
setUsername(undefined);
|
<b style={{ color: "red" }}> *</b>
|
||||||
setPassword(undefined);
|
</label>
|
||||||
setRepeatPassword(undefined);
|
</div>
|
||||||
backend.logIn(credentials);
|
<div class="mt-2">
|
||||||
onComplete();
|
<input
|
||||||
} catch (error) {
|
type="password"
|
||||||
if (error instanceof RequestError) {
|
name="password"
|
||||||
notifyError(
|
id="password"
|
||||||
buildRequestErrorMessage(i18n, error.cause, {
|
autocomplete="current-password"
|
||||||
onClientError: (status) =>
|
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"
|
||||||
status === HttpStatusCode.Conflict
|
enterkeyhint="send"
|
||||||
? i18n.str`That username is already taken`
|
value={password ?? ""}
|
||||||
: undefined,
|
placeholder="Password"
|
||||||
}),
|
required
|
||||||
);
|
onInput={(e): void => {
|
||||||
} else {
|
setPassword(e.currentTarget.value);
|
||||||
notifyError({
|
}}
|
||||||
title: i18n.str`Operation failed, please report`,
|
/>
|
||||||
description:
|
<ShowInputErrorLabel
|
||||||
error instanceof Error
|
message={errors?.password}
|
||||||
? error.message
|
isDirty={password !== undefined}
|
||||||
: JSON.stringify(error),
|
/>
|
||||||
});
|
</div>
|
||||||
}
|
</div>
|
||||||
}
|
|
||||||
}}
|
<div>
|
||||||
>
|
<div class="flex items-center justify-between">
|
||||||
{i18n.str`Register`}
|
<label for="register-repeat" class="block text-sm font-medium leading-6 text-gray-900">
|
||||||
</button>
|
<i18n.Translate>Repeat password</i18n.Translate>
|
||||||
{/* FIXME: should use a different color */}
|
<b style={{ color: "red" }}> *</b>
|
||||||
<button
|
</label>
|
||||||
class="pure-button pure-button-secondary btn-cancel"
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="register-repeat"
|
||||||
|
id="register-repeat"
|
||||||
|
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={repeatPassword ?? ""}
|
||||||
|
placeholder="Same password"
|
||||||
|
required
|
||||||
|
onInput={(e): void => {
|
||||||
|
setRepeatPassword(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ShowInputErrorLabel
|
||||||
|
message={errors?.repeatPassword}
|
||||||
|
isDirty={repeatPassword !== undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium leading-6 text-gray-900">
|
||||||
|
<i18n.Translate>Name</i18n.Translate>
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
id="name"
|
||||||
|
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={name ?? ""}
|
||||||
|
enterkeyhint="next"
|
||||||
|
placeholder="your name"
|
||||||
|
autocomplete="name"
|
||||||
|
required
|
||||||
|
onInput={(e): void => {
|
||||||
|
setName(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* <ShowInputErrorLabel
|
||||||
|
message={errors?.name}
|
||||||
|
isDirty={name !== undefined}
|
||||||
|
/> */}
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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");
|
|
||||||
}
|
|
167
packages/demobank-ui/src/pages/ShowAccountDetails.tsx
Normal file
167
packages/demobank-ui/src/pages/ShowAccountDetails.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
177
packages/demobank-ui/src/pages/UpdateAccountPassword.tsx
Normal file
177
packages/demobank-ui/src/pages/UpdateAccountPassword.tsx
Normal 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>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
@ -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();
|
||||||
|
|
||||||
@ -65,142 +74,186 @@ export function WalletWithdrawForm({
|
|||||||
trimmedAmountStr == null
|
trimmedAmountStr == null
|
||||||
? i18n.str`required`
|
? i18n.str`required`
|
||||||
: !parsedAmount
|
: !parsedAmount
|
||||||
? i18n.str`invalid`
|
? i18n.str`invalid`
|
||||||
: Amounts.cmp(limit, parsedAmount) === -1
|
: Amounts.cmp(limit, parsedAmount) === -1
|
||||||
? i18n.str`balance is not enough`
|
? i18n.str`balance is not enough`
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
async function doStart() {
|
||||||
<form
|
if (!parsedAmount) return;
|
||||||
id="reserve-form"
|
try {
|
||||||
class="pure-form"
|
const result = await createWithdrawal({
|
||||||
name="tform"
|
amount: Amounts.stringify(parsedAmount),
|
||||||
onSubmit={(e) => {
|
});
|
||||||
e.preventDefault();
|
const uri = parseWithdrawUri(result.data.taler_withdraw_uri);
|
||||||
}}
|
if (!uri) {
|
||||||
autoCapitalize="none"
|
return notifyError(
|
||||||
autoCorrect="off"
|
i18n.str`Server responded with an invalid withdraw URI`,
|
||||||
>
|
i18n.str`Withdraw URI: ${result.data.taler_withdraw_uri}`);
|
||||||
<p>
|
} else {
|
||||||
<label for="withdraw-amount">{i18n.str`Amount to withdraw:`}</label>
|
updateSettings("currentWithdrawalOperationId", uri.withdrawalOperationId)
|
||||||
|
goToConfirmOperation(uri.withdrawalOperationId);
|
||||||
<RefAmount
|
}
|
||||||
currency={limit.currency}
|
} catch (error) {
|
||||||
value={amountStr}
|
if (error instanceof RequestError) {
|
||||||
onChange={(v) => {
|
notify(
|
||||||
setAmountStr(v);
|
buildRequestErrorMessage(i18n, error.cause, {
|
||||||
}}
|
onClientError: (status) =>
|
||||||
error={errors?.amount}
|
status === HttpStatusCode.Forbidden
|
||||||
ref={ref}
|
? i18n.str`The operation was rejected due to insufficient funds`
|
||||||
/>
|
: undefined,
|
||||||
</p>
|
}),
|
||||||
<p>
|
);
|
||||||
<div>
|
} else {
|
||||||
<input
|
notifyError(
|
||||||
id="select-exchange"
|
i18n.str`Operation failed, please report`,
|
||||||
class="pure-button pure-button-primary"
|
(error instanceof Error
|
||||||
type="submit"
|
? error.message
|
||||||
disabled={!!errors}
|
: JSON.stringify(error)) as TranslatedString
|
||||||
value={i18n.str`Withdraw`}
|
)
|
||||||
onClick={async (e) => {
|
}
|
||||||
e.preventDefault();
|
}
|
||||||
if (!parsedAmount) return;
|
}
|
||||||
try {
|
|
||||||
const result = await createWithdrawal({
|
return <form
|
||||||
amount: Amounts.stringify(parsedAmount),
|
class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2 mt-4"
|
||||||
});
|
autoCapitalize="none"
|
||||||
const uri = parseWithdrawUri(result.data.taler_withdraw_uri);
|
autoCorrect="off"
|
||||||
if (!uri) {
|
onSubmit={e => {
|
||||||
return notifyError({
|
e.preventDefault()
|
||||||
title: i18n.str`Server responded with an invalid withdraw URI`,
|
}}
|
||||||
description: i18n.str`Withdraw URI: ${result.data.taler_withdraw_uri}`,
|
>
|
||||||
});
|
<div class="px-4 py-6 ">
|
||||||
} else {
|
<div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||||
onSuccess(uri.withdrawalOperationId);
|
<div class="sm:col-span-5">
|
||||||
}
|
<label for="withdraw-amount">{i18n.str`Amount`}</label>
|
||||||
} catch (error) {
|
<RefAmount
|
||||||
if (error instanceof RequestError) {
|
currency={limit.currency}
|
||||||
notifyError(
|
value={amountStr}
|
||||||
buildRequestErrorMessage(i18n, error.cause, {
|
name="withdraw-amount"
|
||||||
onClientError: (status) =>
|
onChange={(v) => {
|
||||||
status === HttpStatusCode.Forbidden
|
setAmountStr(v);
|
||||||
? i18n.str`The operation was rejected due to insufficient funds`
|
|
||||||
: undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
notifyError({
|
|
||||||
title: i18n.str`Operation failed, please report`,
|
|
||||||
description:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: JSON.stringify(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
error={errors?.amount}
|
||||||
|
ref={focus ? doAutoFocus : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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>
|
</p>
|
||||||
</form>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
@ -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,139 +73,263 @@ 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`
|
||||||
: Number.isNaN(answer)
|
: Number.isNaN(answer)
|
||||||
? i18n.str`The answer should be a number`
|
? i18n.str`The answer should be a number`
|
||||||
: 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;
|
||||||
|
|
||||||
|
async function doTransfer() {
|
||||||
|
try {
|
||||||
|
setBusy({})
|
||||||
|
await confirmWithdrawal(
|
||||||
|
withdrawUri.withdrawalOperationId,
|
||||||
|
);
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doCancel() {
|
||||||
|
try {
|
||||||
|
setBusy({})
|
||||||
|
await abortWithdrawal(withdrawUri.withdrawalOperationId);
|
||||||
|
onAborted();
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<h1 class="nav">{i18n.str`Confirm Withdrawal`}</h1>
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
<article>
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<div class="challenge-div">
|
<h3 class="text-base font-semibold text-gray-900">
|
||||||
<form
|
<i18n.Translate>Confirm the withdrawal operation</i18n.Translate>
|
||||||
class="challenge-form"
|
</h3>
|
||||||
noValidate
|
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||||
onSubmit={(e) => {
|
<div class="px-4 mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-3 sm:gap-x-3">
|
||||||
e.preventDefault();
|
|
||||||
}}
|
<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"}>
|
||||||
autoCapitalize="none"
|
<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" />
|
||||||
autoCorrect="off"
|
<span class="flex flex-1">
|
||||||
>
|
<span class="flex flex-col">
|
||||||
<div class="pure-form" id="captcha" name="capcha-form">
|
<span id="project-type-0-label" class="block text-sm font-medium text-gray-900 ">
|
||||||
<h2>{i18n.str`Authorize withdrawal by solving challenge`}</h2>
|
<i18n.Translate>challenge response test</i18n.Translate>
|
||||||
<p>
|
</span>
|
||||||
<label for="answer">
|
</span>
|
||||||
{i18n.str`What is`}
|
</span>
|
||||||
<em>
|
<svg class="h-5 w-5 text-indigo-600" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
{captchaNumbers.a} + {captchaNumbers.b}
|
<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" />
|
||||||
</em>
|
</svg>
|
||||||
?
|
</label>
|
||||||
</label>
|
|
||||||
|
|
||||||
<input
|
<label class="relative flex cursor-pointer rounded-lg border bg-gray-100 p-4 shadow-sm focus:outline-none border-gray-300">
|
||||||
name="answer"
|
<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" />
|
||||||
id="answer"
|
<span class="flex flex-1">
|
||||||
value={captchaAnswer ?? ""}
|
<span class="flex flex-col">
|
||||||
type="text"
|
<span id="project-type-1-label" class="block text-sm font-medium text-gray-900">
|
||||||
autoFocus
|
<i18n.Translate>using SMS</i18n.Translate>
|
||||||
required
|
</span>
|
||||||
onInput={(e): void => {
|
<span id="project-type-1-description-0" class="mt-1 flex items-center text-sm text-gray-500">
|
||||||
setCaptchaAnswer(e.currentTarget.value);
|
<i18n.Translate>not available</i18n.Translate>
|
||||||
}}
|
</span>
|
||||||
/>
|
</span>
|
||||||
<ShowInputErrorLabel
|
</span>
|
||||||
message={errors?.answer}
|
<svg class="h-5 w-5 text-indigo-600 hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
isDirty={captchaAnswer !== undefined}
|
<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>
|
||||||
</p>
|
</label>
|
||||||
<p>
|
|
||||||
<button
|
<label class="relative flex cursor-pointer rounded-lg border bg-gray-100 p-4 shadow-sm focus:outline-none border-gray-300">
|
||||||
type="submit"
|
<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" />
|
||||||
class="pure-button pure-button-primary btn-confirm"
|
<span class="flex flex-1">
|
||||||
disabled={!!errors}
|
<span class="flex flex-col">
|
||||||
onClick={async (e) => {
|
<span id="project-type-1-label" class="block text-sm font-medium text-gray-900">
|
||||||
e.preventDefault();
|
<i18n.Translate>one time password</i18n.Translate>
|
||||||
try {
|
</span>
|
||||||
await confirmWithdrawal(
|
<span id="project-type-1-description-0" class="mt-1 flex items-center text-sm text-gray-500">
|
||||||
withdrawUri.withdrawalOperationId,
|
<i18n.Translate>not available</i18n.Translate>
|
||||||
);
|
</span>
|
||||||
} catch (error) {
|
</span>
|
||||||
if (error instanceof RequestError) {
|
</span>
|
||||||
notifyError(
|
<svg class="h-5 w-5 text-indigo-600 hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
buildRequestErrorMessage(i18n, error.cause, {
|
<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" />
|
||||||
onClientError: (status) =>
|
</svg>
|
||||||
status === HttpStatusCode.Conflict
|
</label>
|
||||||
? 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({
|
|
||||||
title: i18n.str`Operation failed, please report`,
|
|
||||||
description:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: JSON.stringify(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n.str`Confirm`}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="pure-button pure-button-secondary btn-cancel"
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
await abortWithdrawal(withdrawUri.withdrawalOperationId);
|
|
||||||
onAborted();
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof RequestError) {
|
|
||||||
notifyError(
|
|
||||||
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({
|
|
||||||
title: i18n.str`Operation failed, please report`,
|
|
||||||
description:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: JSON.stringify(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n.str`Cancel`}
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
<div class="hint">
|
<div class="mt-3 text-sm leading-6">
|
||||||
<p>
|
|
||||||
<i18n.Translate>
|
<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">
|
||||||
A this point, a <b>real</b> bank would ask for an additional
|
<div class="px-4 sm:px-0">
|
||||||
authentication proof (PIN/TAN, one time password, ..), instead
|
<h2 class="text-base font-semibold text-gray-900"><i18n.Translate>Answer the next question to authorize the wire transfer.</i18n.Translate></h2>
|
||||||
of a simple calculation.
|
</div>
|
||||||
</i18n.Translate>
|
<form
|
||||||
</p>
|
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`}
|
||||||
|
<em>
|
||||||
|
{captchaNumbers.a} + {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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</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">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>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
|
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
@ -84,12 +72,11 @@ export function WithdrawalQRCode({
|
|||||||
</i18n.Translate>
|
</i18n.Translate>
|
||||||
</p>
|
</p>
|
||||||
<a class="pure-button pure-button-primary"
|
<a class="pure-button pure-button-primary"
|
||||||
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,57 +85,77 @@ 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" />
|
||||||
<i18n.Translate>
|
</svg>
|
||||||
The wire transfer to the GNU Taler Exchange bank's account is completed, now the
|
|
||||||
exchange will send the requested amount into your GNU Taler wallet.
|
|
||||||
</i18n.Translate>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<i18n.Translate>
|
|
||||||
You can close this page now or continue to the account page.
|
|
||||||
</i18n.Translate>
|
|
||||||
</p>
|
|
||||||
<div style={{textAlign:"center"}}>
|
|
||||||
|
|
||||||
<a class="pure-button pure-button-primary"
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
clearCurrentWithdrawal()
|
|
||||||
onContinue()
|
|
||||||
}}>
|
|
||||||
{i18n.str`Continue`}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="mt-3 text-center sm:mt-5">
|
||||||
</section>
|
<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-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>Done</i18n.Translate>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
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()
|
}}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
38
packages/demobank-ui/src/pages/admin/Account.tsx
Normal file
38
packages/demobank-ui/src/pages/admin/Account.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
315
packages/demobank-ui/src/pages/admin/AccountForm.tsx
Normal file
315
packages/demobank-ui/src/pages/admin/AccountForm.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
132
packages/demobank-ui/src/pages/admin/AccountList.tsx
Normal file
132
packages/demobank-ui/src/pages/admin/AccountList.tsx
Normal 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>
|
||||||
|
}
|
101
packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx
Normal file
101
packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
148
packages/demobank-ui/src/pages/admin/Home.tsx
Normal file
148
packages/demobank-ui/src/pages/admin/Home.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
171
packages/demobank-ui/src/pages/admin/RemoveAccount.tsx
Normal file
171
packages/demobank-ui/src/pages/admin/RemoveAccount.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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);
|
||||||
}}
|
}}
|
||||||
@ -201,13 +199,13 @@ function useRatiosAndFeeConfigWithChangeDetection(): HttpResponse<
|
|||||||
(result.data.name !== oldResult.name ||
|
(result.data.name !== oldResult.name ||
|
||||||
result.data.version !== oldResult.version ||
|
result.data.version !== oldResult.version ||
|
||||||
result.data.ratios_and_fees.buy_at_ratio !==
|
result.data.ratios_and_fees.buy_at_ratio !==
|
||||||
oldResult.ratios_and_fees.buy_at_ratio ||
|
oldResult.ratios_and_fees.buy_at_ratio ||
|
||||||
result.data.ratios_and_fees.buy_in_fee !==
|
result.data.ratios_and_fees.buy_in_fee !==
|
||||||
oldResult.ratios_and_fees.buy_in_fee ||
|
oldResult.ratios_and_fees.buy_in_fee ||
|
||||||
result.data.ratios_and_fees.sell_at_ratio !==
|
result.data.ratios_and_fees.sell_at_ratio !==
|
||||||
oldResult.ratios_and_fees.sell_at_ratio ||
|
oldResult.ratios_and_fees.sell_at_ratio ||
|
||||||
result.data.ratios_and_fees.sell_out_fee !==
|
result.data.ratios_and_fees.sell_out_fee !==
|
||||||
oldResult.ratios_and_fees.sell_out_fee ||
|
oldResult.ratios_and_fees.sell_out_fee ||
|
||||||
result.data.fiat_currency !== oldResult.fiat_currency);
|
result.data.fiat_currency !== oldResult.fiat_currency);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -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;
|
||||||
@ -251,15 +249,14 @@ function CreateCashout({
|
|||||||
const sellFee = !config.ratios_and_fees.sell_out_fee
|
const sellFee = !config.ratios_and_fees.sell_out_fee
|
||||||
? zero
|
? zero
|
||||||
: Amounts.parseOrThrow(
|
: Amounts.parseOrThrow(
|
||||||
`${balance.currency}:${config.ratios_and_fees.sell_out_fee}`,
|
`${balance.currency}:${config.ratios_and_fees.sell_out_fee}`,
|
||||||
);
|
);
|
||||||
const fiatCurrency = config.fiat_currency;
|
const fiatCurrency = config.fiat_currency;
|
||||||
|
|
||||||
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,32 +265,32 @@ 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)
|
||||||
: {
|
: {
|
||||||
title: i18n.str`Could not estimate the cashout`,
|
type: "error",
|
||||||
description: error.message,
|
title: i18n.str`Could not estimate the cashout`,
|
||||||
},
|
description: error.message as TranslatedString
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
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)
|
||||||
: {
|
: {
|
||||||
title: i18n.str`Could not estimate the cashout`,
|
type: "error",
|
||||||
description: error.message,
|
title: i18n.str`Could not estimate the cashout`,
|
||||||
},
|
description: error.message,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -308,22 +305,19 @@ function CreateCashout({
|
|||||||
amount: !form.amount
|
amount: !form.amount
|
||||||
? i18n.str`required`
|
? i18n.str`required`
|
||||||
: !amount
|
: !amount
|
||||||
? i18n.str`could not be parsed`
|
? i18n.str`could not be parsed`
|
||||||
: Amounts.cmp(limit, calc.debit) === -1
|
: Amounts.cmp(limit, calc.debit) === -1
|
||||||
? i18n.str`balance is not enough`
|
? i18n.str`balance is not enough`
|
||||||
: Amounts.cmp(calc.beforeFee, sellFee) === -1
|
: Amounts.cmp(calc.beforeFee, sellFee) === -1
|
||||||
? i18n.str`the total amount to transfer does not cover the fees`
|
? i18n.str`the total amount to transfer does not cover the fees`
|
||||||
: Amounts.isZero(calc.credit)
|
: Amounts.isZero(calc.credit)
|
||||||
? i18n.str`the total transfer at destination will be zero`
|
? i18n.str`the total transfer at destination will be zero`
|
||||||
: undefined,
|
: undefined,
|
||||||
channel: !form.channel ? i18n.str`required` : undefined,
|
channel: !form.channel ? i18n.str`required` : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
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,18 +512,18 @@ 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
|
||||||
? i18n.str`The exchange rate was incorrectly applied`
|
? i18n.str`The exchange rate was incorrectly applied`
|
||||||
: status === HttpStatusCode.Forbidden
|
: status === HttpStatusCode.Forbidden
|
||||||
? i18n.str`A institutional user tried the operation`
|
? i18n.str`A institutional user tried the operation`
|
||||||
: status === HttpStatusCode.Conflict
|
: status === HttpStatusCode.Conflict
|
||||||
? i18n.str`Need a contact data where to send the TAN`
|
? i18n.str`Need a contact data where to send the TAN`
|
||||||
: status === HttpStatusCode.PreconditionFailed
|
: status === HttpStatusCode.PreconditionFailed
|
||||||
? i18n.str`The account does not have sufficient funds`
|
? i18n.str`The account does not have sufficient funds`
|
||||||
: undefined,
|
: undefined,
|
||||||
onServerError: (status) =>
|
onServerError: (status) =>
|
||||||
status === HttpStatusCode.ServiceUnavailable
|
status === HttpStatusCode.ServiceUnavailable
|
||||||
? i18n.str`The bank does not support the TAN channel for this operation`
|
? i18n.str`The bank does not support the TAN channel for this operation`
|
||||||
@ -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)) as TranslatedString
|
||||||
: JSON.stringify(error),
|
)
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -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,24 +657,23 @@ 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
|
||||||
? i18n.str`Cashout not found. It may be also mean that it was already aborted.`
|
? i18n.str`Cashout not found. It may be also mean that it was already aborted.`
|
||||||
: status === HttpStatusCode.PreconditionFailed
|
: status === HttpStatusCode.PreconditionFailed
|
||||||
? i18n.str`Cashout was already confimed`
|
? i18n.str`Cashout was already confimed`
|
||||||
: undefined,
|
: undefined,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} 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)) as TranslatedString
|
||||||
: JSON.stringify(error),
|
)
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -699,28 +694,27 @@ 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
|
||||||
? i18n.str`Cashout not found. It may be also mean that it was already aborted.`
|
? i18n.str`Cashout not found. It may be also mean that it was already aborted.`
|
||||||
: status === HttpStatusCode.PreconditionFailed
|
: status === HttpStatusCode.PreconditionFailed
|
||||||
? i18n.str`Cashout was already confimed`
|
? i18n.str`Cashout was already confimed`
|
||||||
: status === HttpStatusCode.Conflict
|
: status === HttpStatusCode.Conflict
|
||||||
? i18n.str`Confirmation failed. Maybe the user changed their cash-out address between the creation and the confirmation`
|
? i18n.str`Confirmation failed. Maybe the user changed their cash-out address between the creation and the confirmation`
|
||||||
: status === HttpStatusCode.Forbidden
|
: status === HttpStatusCode.Forbidden
|
||||||
? i18n.str`Invalid code`
|
? i18n.str`Invalid code`
|
||||||
: undefined,
|
: undefined,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} 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)) as TranslatedString
|
||||||
: JSON.stringify(error),
|
)
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
2895
packages/demobank-ui/src/pages/rnd.ts
Normal file
2895
packages/demobank-ui/src/pages/rnd.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
Binary file not shown.
@ -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");
|
|
||||||
}
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -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";
|
|
3
packages/demobank-ui/src/scss/main.css
Normal file
3
packages/demobank-ui/src/scss/main.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
@ -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
@ -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;
|
|
||||||
}
|
|
@ -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
Loading…
Reference in New Issue
Block a user