Merge branch 'master' into age-withdraw

This commit is contained in:
Özgür Kesim 2023-10-06 16:33:05 +02:00
commit fe7b51ef27
Signed by: oec
GPG Key ID: 3D76A56D79EDD9D7
276 changed files with 12492 additions and 26778 deletions

View File

@ -9,6 +9,11 @@ git-archive-all = ./build-system/taler-build-scripts/archive-with-submodules/git
include .config.mk
# Let recursive Makefiles know that they're being invoked
# from the top-level makefile.
export TOPLEVEL := yes
export TOP_DESTDIR := $(abspath $(DESTDIR))
.PHONY: compile
compile:
pnpm install -r --frozen-lockfile
@ -121,18 +126,18 @@ lint:
install:
pnpm install --frozen-lockfile
pnpm run compile
make -C packages/taler-wallet-cli TOPLEVEL=yes install-nodeps
make -C packages/anastasis-cli TOPLEVEL=yes install-nodeps
make -C packages/taler-harness TOPLEVEL=yes install-nodeps
make -C packages/demobank-ui TOPLEVEL=yes install-nodeps
make -C packages/merchant-backoffice-ui TOPLEVEL=yes install-nodeps
make -C packages/aml-backoffice-ui TOPLEVEL=yes install-nodeps
$(MAKE) -C packages/taler-wallet-cli install-nodeps
$(MAKE) -C packages/anastasis-cli install-nodeps
$(MAKE) -C packages/taler-harness install-nodeps
$(MAKE) -C packages/demobank-ui install-nodeps
$(MAKE) -C packages/merchant-backoffice-ui install-nodeps
$(MAKE) -C packages/aml-backoffice-ui install-nodeps
.PHONY: install-tools
# Install taler-wallet-cli, anastasis-cli and taler-harness
install-tools:
pnpm install --frozen-lockfile --filter @gnu-taler/taler-wallet-cli... --filter @gnu-taler/anastasis-cli... --filter @gnu-taler/taler-harness...
pnpm run --filter @gnu-taler/taler-wallet-cli... --filter @gnu-taler/anastasis-cli... --filter @gnu-taler/taler-harness... compile
make -C packages/taler-wallet-cli TOPLEVEL=yes install-nodeps
make -C packages/anastasis-cli TOPLEVEL=yes install-nodeps
make -C packages/taler-harness TOPLEVEL=yes install-nodeps
$(MAKE) -C packages/taler-wallet-cli install-nodeps
$(MAKE) -C packages/anastasis-cli install-nodeps
$(MAKE) -C packages/taler-harness install-nodeps

View File

@ -1,6 +1,6 @@
#!/bin/bash
[ ! -d prebuilt ] && echo 'directory "prebuilt" not found. first checkout the prebuilt branch into a prebuilt directory' && exit 1
[ ! -d prebuilt ] && git worktree add -f prebuilt prebuilt && exit 1
for file in depleted_tip.en.html offer_refund.en.html offer_tip.en.html request_payment.en.html show_order_details.en.html; do
cp packages/merchant-backend-ui/dist/pages/$file prebuilt/backend/

View File

@ -1,6 +1,6 @@
#!/bin/bash
[ ! -d prebuilt ] && echo 'directory "prebuilt" not found. first checkout the prebuilt branch into a prebuilt directory' && exit 1
[ ! -d prebuilt ] && git worktree add -f prebuilt prebuilt && exit 1
find packages/merchant-backoffice-ui/dist/prod/ -type f -printf '%P\n' | sort > prebuilt/backoffice/bof

View File

@ -1,6 +1,6 @@
#!/bin/bash
[ ! -d prebuilt ] && echo 'directory "prebuilt" not found. first checkout the prebuilt branch into a prebuilt directory' && exit 1
[ ! -d prebuilt ] && git worktree add -f prebuilt prebuilt && exit 1
find packages/demobank-ui/dist/prod/ -type f -printf '%P\n' | sort > prebuilt/demobank/bof

View File

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

View File

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

View File

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

View File

@ -36,11 +36,18 @@ to the default settings:
```
globalThis.talerDemobankSettings = {
backendBaseURL: "https://bank.demo.taler.net/demobanks/default/",
// location of libeufin server
backendBaseURL: "https://bank.demo.taler.net/",
allowRegistrations: true,
bankName: "Taler Bank",
// Show explainer text and navbar to other demo sites
showDemoNav: true,
// href value of the icon in the top left
iconLinkURL: "https://demo.taler.net/",
// show the button "create random user" in registration form
allowRandomAccountCreation: true,
// do not create random password for random users
simplePasswordForRandomAccounts: true,
// Names and links for other demo sites to show in the navbar
demoSites: [
["Landing", "https://demo.taler.net/"],

View File

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

View File

@ -18,7 +18,7 @@
import { serve } from "@gnu-taler/web-util/node";
import { initializeDev } from "@gnu-taler/web-util/build";
const devEntryPoints = ["src/stories.tsx", "src/index.tsx"];
const devEntryPoints = ["src/stories.tsx", "src/index.tsx", "src/demobank-ui-settings.js"];
const build = initializeDev({
type: "development",
@ -28,7 +28,7 @@ const build = initializeDev({
},
destination: "./dist/dev",
public: "/app",
css: "sass",
css: "postcss",
});
await build();

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
/*
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@ -14,37 +15,15 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
import { HttpError, useTranslationContext } from "@gnu-taler/web-util/browser";
import { h, VNode } from "preact";
import { Attention } from "./Attention.js";
import { TranslatedString } from "@gnu-taler/taler-util";
.is-user-avatar {
&.has-max-width {
max-width: $size-base * 7;
}
&.is-aligned-center {
margin: 0 auto;
}
img {
margin: 0 auto;
border-radius: $radius-rounded;
}
}
.icon.has-update-mark {
position: relative;
&:after {
content: "";
width: $icon-update-mark-size;
height: $icon-update-mark-size;
position: absolute;
top: 1px;
right: 1px;
background-color: $icon-update-mark-color;
border-radius: $radius-rounded;
}
export function ErrorLoading({ error }: { error: HttpError<SandboxBackend.SandboxError> }): VNode {
const { i18n } = useTranslationContext()
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>
);
}

View File

@ -42,11 +42,11 @@ function getLangName(s: keyof LangsNames | string): string {
return String(s);
}
// FIXME: explain "like py".
export function LangSelectorLikePy(): VNode {
export function LangSelector(): VNode {
const [updatingLang, setUpdatingLang] = useState(false);
const { lang, changeLanguage } = useTranslationContext();
const [hidden, setHidden] = useState(true);
useEffect(() => {
function bodyKeyPress(event: KeyboardEvent) {
if (event.code === "Escape") setHidden(true);
@ -62,51 +62,49 @@ export function LangSelectorLikePy(): VNode {
};
}, []);
return (
<Fragment>
<a
href="#"
class="pure-button"
name="language"
onClick={(ev) => {
ev.preventDefault();
<div>
<div class="relative mt-2">
<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"
onClick={() => {
setHidden((h) => !h);
ev.stopPropagation();
}}
>
{getLangName(lang)}
</a>
<div
id="lang"
class={hidden ? "hide" : ""}
style={{
display: "inline-block",
}}
>
<div style="position: relative; overflow: visible;">
<div
class="nav"
style="position: absolute; max-height: 60vh; overflow-y: auto; margin-left: -120px; margin-top: 20px"
>
}}>
<span class="flex items-center">
<img src="https://taler.net/images/languageicon.svg" alt="" class="h-5 w-5 flex-shrink-0 rounded-full" />
<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">
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<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" />
</svg>
</span>
</button>
{!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">
{Object.keys(messages)
.filter((l) => l !== lang)
.map((l) => (
<a
key={l}
href="#"
class="navbtn langbtn"
value={l}
.map((lang) => (
<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"
onClick={() => {
changeLanguage(l);
changeLanguage(lang);
setUpdatingLang(false);
setHidden(true)
}}
>
{getLangName(l)}
</a>
<span class="font-normal block truncate">{getLangName(lang)}</span>
<span class="text-indigo-600 absolute inset-y-0 right-0 flex items-center pr-4">
{/* <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
</svg> */}
</span>
</li>
))}
<br />
</ul>
}
</div>
</div>
</div>
</Fragment>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { AbsoluteTime, Amounts } from "@gnu-taler/taler-util";
import { AbsoluteTime, Amounts, parsePaytoUri } from "@gnu-taler/taler-util";
import { useTransactions } from "../../hooks/access.js";
import { Props, State, Transaction } from "./index.js";
@ -34,45 +34,19 @@ export function useComponentState({ account }: Props): State {
}
const transactions = result.data.transactions
.map((item: unknown) => {
if (
!item ||
typeof item !== "object" ||
!("direction" in item) ||
!("creditorIban" in item) ||
!("debtorIban" in item) ||
!("date" in item) ||
!("subject" in item) ||
!("currency" in item) ||
!("amount" in item)
) {
//not valid
return;
}
const anyItem = item as any;
if (
!(typeof anyItem.creditorIban === "string") ||
!(typeof anyItem.debtorIban === "string") ||
!(typeof anyItem.date === "string") ||
!(typeof anyItem.subject === "string") ||
!(typeof anyItem.currency === "string") ||
!(typeof anyItem.amount === "string")
) {
return;
}
.map((tx) => {
const negative = anyItem.direction === "DBIT";
const counterpart = negative ? anyItem.creditorIban : anyItem.debtorIban;
const negative = tx.direction === "debit";
const cp = parsePaytoUri(negative ? tx.creditor_payto_uri : tx.debtor_payto_uri);
const counterpart = (cp === undefined || !cp.isKnown ? undefined :
cp.targetType === "iban" ? cp.iban :
cp.targetType === "x-taler-bank" ? cp.account :
cp.targetType === "bitcoin" ? `${cp.targetPath.substring(0, 6)}...` : undefined) ??
"unkown";
let date = anyItem.date ? parseInt(anyItem.date, 10) : 0;
if (isNaN(date) || !isFinite(date)) {
date = 0;
}
const when: AbsoluteTime = !date
? AbsoluteTime.never()
: AbsoluteTime.fromMilliseconds(date);
const amount = Amounts.parse(`${anyItem.currency}:${anyItem.amount}`);
const subject = anyItem.subject;
const when = AbsoluteTime.fromProtocolTimestamp(tx.date);
const amount = Amounts.parse(tx.amount);
const subject = tx.subject;
return {
negative,
counterpart,
@ -87,5 +61,7 @@ export function useComponentState({ account }: Props): State {
status: "ready",
error: undefined,
transactions,
onNext: result.isReachingEnd ? undefined : result.loadMore,
onPrev: result.isReachingStart ? undefined : result.loadMorePrev,
};
}

View File

@ -14,11 +14,13 @@
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 { State } from "./index.js";
import { format } from "date-fns";
import { format, isToday } from "date-fns";
import { Amounts } from "@gnu-taler/taler-util";
import { useEffect, useRef } from "preact/hooks";
import { RenderAmount } from "../../pages/PaytoWireTransferForm.js";
export function LoadingUriView({ error }: State.LoadingUriError): VNode {
const { i18n } = useTranslationContext();
@ -30,45 +32,104 @@ export function LoadingUriView({ error }: State.LoadingUriError): VNode {
);
}
export function ReadyView({ transactions }: State.Ready): VNode {
export function ReadyView({ transactions, onNext, onPrev }: State.Ready): VNode {
const { i18n } = useTranslationContext();
if (!transactions.length) return <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 (
<div class="results">
<table class="pure-table pure-table-striped">
<div class="px-4 mt-4">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold leading-6 text-gray-900"><i18n.Translate>Latest transactions</i18n.Translate></h1>
</div>
</div>
<div class="-mx-4 mt-5 ring-1 ring-gray-300 sm:mx-0 rounded-lg min-w-fit bg-white">
<table class="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th>{i18n.str`Date`}</th>
<th>{i18n.str`Amount`}</th>
<th>{i18n.str`Counterpart`}</th>
<th>{i18n.str`Subject`}</th>
<th scope="col" class="pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 ">{i18n.str`Date`}</th>
<th scope="col" class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 ">{i18n.str`Amount`}</th>
<th scope="col" class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 ">{i18n.str`Counterpart`}</th>
<th scope="col" class="pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 ">{i18n.str`Subject`}</th>
</tr>
</thead>
<tbody>
{transactions.map((item, idx) => {
return (
<tr key={idx}>
<td>
{item.when.t_ms === "never"
? ""
: format(item.when.t_ms, "dd/MM/yyyy HH:mm:ss")}
</td>
<td>
{item.negative ? "-" : ""}
{item.amount ? (
`${Amounts.stringifyValue(item.amount)} ${
item.amount.currency
}`
{Object.entries(txByDate).map(([date, txs], idx) => {
return <Fragment>
<tr class="border-t border-gray-200">
<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">
{date}
</th>
</tr>
{txs.map(item => {
const time = item.when.t_ms === "never" ? "" : format(item.when.t_ms, "HH:mm:ss")
const amount = <Fragment>
{ }
</Fragment>
return (<tr key={idx}>
<td class="relative py-2 pl-2 pr-2 text-sm ">
<div class="font-medium text-gray-900">{time}</div>
<dl class="font-normal sm:hidden">
<dt class="sr-only sm:hidden"><i18n.Translate>Amount</i18n.Translate></dt>
<dd class="mt-1 truncate text-gray-700">
{item.negative ? i18n.str`sent` : i18n.str`received`} {item.amount ? (
<RenderAmount value={item.amount} />
) : (
<span style={{ color: "grey" }}>&lt;invalid value&gt;</span>
<span style={{ color: "grey" }}>&lt;{i18n.str`invalid value`}&gt;</span>
)}</dd>
<dt class="sr-only sm:hidden"><i18n.Translate>Counterpart</i18n.Translate></dt>
<dd class="mt-1 truncate text-gray-500 sm:hidden">
{item.negative ? i18n.str`to` : i18n.str`from`} {item.counterpart}
</dd>
</dl>
</td>
<td data-negative={item.negative ? "true" : "false"}
class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 data-[negative=false]:text-green-600 data-[negative=true]:text-red-600">
{item.amount ? (<RenderAmount value={item.amount} negative={item.negative} />
) : (
<span style={{ color: "grey" }}>&lt;{i18n.str`invalid value`}&gt;</span>
)}
</td>
<td>{item.counterpart}</td>
<td>{item.subject}</td>
</tr>
);
<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>
);
}

View File

@ -15,16 +15,23 @@
*/
import {
LibtoolVersion,
getGlobalLogLevel,
setGlobalLogLevelFromString,
} from "@gnu-taler/taler-util";
import { TranslationProvider } from "@gnu-taler/web-util/browser";
import { FunctionalComponent, h } from "preact";
import { TranslationProvider, useApiContext } from "@gnu-taler/web-util/browser";
import { ComponentChildren, Fragment, FunctionalComponent, VNode, h } from "preact";
import { SWRConfig } from "swr";
import { BackendStateProvider } from "../context/backend.js";
import { BackendStateProvider, useBackendContext } from "../context/backend.js";
import { strings } from "../i18n/strings.js";
import { Routing } from "../pages/Routing.js";
import { Routing } from "./Routing.js";
import { useEffect, useState } from "preact/hooks";
import { Loading } from "./Loading.js";
import { getInitialBackendBaseURL } from "../hooks/backend.js";
import { BANK_INTEGRATION_PROTOCOL_VERSION, useConfigState } from "../hooks/config.js";
import { ErrorLoading } from "./ErrorLoading.js";
import { BankFrame } from "../pages/BankFrame.js";
import { ConfigStateProvider } from "../context/config.js";
const WITH_LOCAL_STORAGE_CACHE = false;
/**
@ -48,6 +55,7 @@ const App: FunctionalComponent = () => {
return (
<TranslationProvider source={strings}>
<BackendStateProvider>
<VersionCheck>
<SWRConfig
value={{
provider: WITH_LOCAL_STORAGE_CACHE
@ -57,6 +65,7 @@ const App: FunctionalComponent = () => {
>
<Routing />
</SWRConfig>
</VersionCheck>
</BackendStateProvider>
</TranslationProvider >
);
@ -64,6 +73,26 @@ const App: FunctionalComponent = () => {
(window as any).setGlobalLogLevelFromString = setGlobalLogLevelFromString;
(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> {
const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]"));

View File

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

View File

@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@ -14,37 +14,39 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { ComponentChildren, createContext, h, VNode } from "preact";
import { useContext } from "preact/hooks";
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
section.section.is-title-bar {
padding: $default-padding;
border-bottom: $light-border;
export type Type = Required<SandboxBackend.Config>;
ul {
li {
display: inline-block;
padding: 0 $default-padding * 0.5 0 0;
font-size: $default-padding;
color: $title-bar-color;
const initial: Type = {
name: "",
version: "0:0:0",
currency_fraction_digits: 2,
currency_fraction_limit: 2,
fiat_currency: "",
have_cashout: false,
};
const Context = createContext<Type>(initial);
&:after {
display: inline-block;
content: "/";
padding-left: $default-padding * 0.5;
}
export const useConfigContext = (): Type => useContext(Context);
&:last-child {
padding-right: 0;
font-weight: 900;
color: $title-bar-active-color;
export const ConfigStateProvider = ({
value,
children,
}: {
value: Type,
children: ComponentChildren;
}): VNode => {
return h(Context.Provider, {
value,
children,
});
};
&:after {
display: none;
}
}
}
}
}

View File

@ -74,7 +74,9 @@ type HashCode = string;
type EddsaPublicKey = string;
type EddsaSignature = string;
type WireTransferIdentifierRawP = string;
type RelativeTime = Duration;
type RelativeTime = {
d_us: number | "forever"
};
type ImageDataUrl = string;
interface WithId {
@ -99,20 +101,33 @@ type Amount = string;
type UUID = string;
type Integer = number;
interface Balance {
amount: Amount;
credit_debit_indicator: "credit" | "debit";
}
namespace SandboxBackend {
export interface Config {
// Name of this API, always "circuit".
name: string;
// API version in the form $n:$n:$n
version: string;
// Contains ratios and fees related to buying
// and selling the circuit currency.
ratios_and_fees: RatiosAndFees;
// If 'true', the server provides local currency
// conversion support.
// If missing or false, some parts of the API
// are not supported and return 404.
have_cashout?: boolean;
// Fiat currency. That is the currency in which
// cash-out operations ultimately wire money.
// Only applicable if have_cashout=true.
fiat_currency?: string;
// How many digits should the amounts be rendered
// with by default. Small capitals should
// be used to render fractions beyond the number
// given here (like on gas stations).
currency_fraction_digits?: number;
// How many decimal digits an operation can
// have. Wire transfers with more decimal
// digits will not be accepted.
currency_fraction_limit?: number;
}
interface RatiosAndFees {
// Exchange rate to buy the circuit currency from fiat.
@ -126,7 +141,7 @@ namespace SandboxBackend {
}
export interface SandboxError {
error: SandboxErrorDetail;
error?: SandboxErrorDetail;
}
interface SandboxErrorDetail {
// String enum classifying the error.
@ -152,26 +167,12 @@ namespace SandboxBackend {
UtilError = "util-error",
}
namespace Access {
interface PublicAccountsResponse {
publicAccounts: PublicAccount[];
}
interface PublicAccount {
iban: string;
balance: string;
// The account name _and_ the username of the
// Sandbox customer that owns such a bank account.
accountLabel: string;
}
interface BankAccountBalanceResponse {
// Available balance on the account.
balance: Balance;
// payto://-URI of the account. (New)
paytoUri: string;
// Number indicating the max debit allowed for the requesting user.
debitThreshold: Amount;
}
type EmailAddress = string;
type PhoneNumber = string;
namespace CoreBank {
interface BankAccountCreateWithdrawalRequest {
// Amount to withdraw.
amount: Amount;
@ -213,28 +214,24 @@ namespace SandboxBackend {
}
interface BankAccountTransactionInfo {
creditorIban: string;
creditorBic: string; // Optional
creditorName: string;
creditor_payto_uri: string;
debtor_payto_uri: string;
debtorIban: string;
debtorBic: string;
debtorName: string;
amount: Amount;
direction: "debit" | "credit";
amount: number;
currency: string;
subject: string;
// Transaction unique ID. Matches
// $transaction_id from the URI.
uid: string;
direction: "DBIT" | "CRDT";
date: string; // milliseconds since the Unix epoch
row_id: number;
date: Timestamp;
}
interface CreateBankAccountTransactionCreate {
// Address in the Payto format of the wire transfer receiver.
// It needs at least the 'message' query string parameter.
paytoUri: string;
payto_uri: string;
// Transaction amount (in the $currency:x.y format), optional.
// However, when not given, its value must occupy the 'amount'
@ -243,11 +240,143 @@ namespace SandboxBackend {
amount?: string;
}
interface BankRegistrationRequest {
interface RegisterAccountRequest {
// Username
username: string;
// Password.
password: string;
// Legal name of the account owner
name: string;
// Defaults to false.
is_public?: boolean;
// Is this a taler exchange account?
// If true:
// - incoming transactions to the account that do not
// have a valid reserve public key are automatically
// - the account provides the taler-wire-gateway-api endpoints
// Defaults to false.
is_taler_exchange?: boolean;
// Addresses where to send the TAN for transactions.
// Currently only used for cashouts.
// If missing, cashouts will fail.
// In the future, might be used for other transactions
// as well.
challenge_contact_data?: ChallengeContactData;
// 'payto' address pointing a bank account
// external to the libeufin-bank.
// Payments will be sent to this bank account
// when the user wants to convert the local currency
// back to fiat currency outside libeufin-bank.
cashout_payto_uri?: string;
// Internal payto URI of this bank account.
// Used mostly for testing.
internal_payto_uri?: string;
}
interface ChallengeContactData {
// E-Mail address
email?: EmailAddress;
// Phone number.
phone?: PhoneNumber;
}
interface AccountReconfiguration {
// Addresses where to send the TAN for transactions.
// Currently only used for cashouts.
// If missing, cashouts will fail.
// In the future, might be used for other transactions
// as well.
challenge_contact_data?: ChallengeContactData;
// 'payto' address pointing a bank account
// external to the libeufin-bank.
// Payments will be sent to this bank account
// when the user wants to convert the local currency
// back to fiat currency outside libeufin-bank.
cashout_address?: string;
// Legal name associated with $username.
// When missing, the old name is kept.
name?: string;
// If present, change the is_exchange configuration.
// See RegisterAccountRequest
is_exchange?: boolean;
}
interface AccountPasswordChange {
// New password.
new_password: string;
}
interface PublicAccountsResponse {
public_accounts: PublicAccount[];
}
interface PublicAccount {
payto_uri: string;
balance: Balance;
// The account name (=username) of the
// libeufin-bank account.
account_name: string;
}
interface ListBankAccountsResponse {
accounts: AccountMinimalData[];
}
interface Balance {
amount: Amount;
credit_debit_indicator: "credit" | "debit";
}
interface AccountMinimalData {
// Username
username: string;
// Legal name of the account owner.
name: string;
// current balance of the account
balance: Balance;
// Number indicating the max debit allowed for the requesting user.
debit_threshold: Amount;
}
interface AccountData {
// Legal name of the account owner.
name: string;
// Available balance on the account.
balance: Balance;
// payto://-URI of the account.
payto_uri: string;
// Number indicating the max debit allowed for the requesting user.
debit_threshold: Amount;
contact_data?: ChallengeContactData;
// 'payto' address pointing the bank account
// where to send cashouts. This field is optional
// because not all the accounts are required to participate
// in the merchants' circuit. One example is the exchange:
// that never cashouts. Registering these accounts can
// be done via the access API.
cashout_payto_uri?: string;
}
}
namespace Circuit {

View File

@ -0,0 +1,21 @@
// Values for development environment
/**
* Global settings for the demobank UI.
*/
localStorage.setItem("bank-base-url", "http://bank.taler.test/");
globalThis.talerDemobankSettings = {
backendBaseURL: "http://bank.taler.test/",
allowRegistrations: true,
showDemoNav: true,
simplePasswordForRandomAccounts: true,
allowRandomAccountCreation: true,
bankName: "Taler DEVELOPMENT Bank",
// Names and links for other demo sites to show in the navbar
demoSites: [
["Exchange", "https://Exchnage.taler.test/"],
["Bank", "https://bank-ui.taler.test/"],
["Merchant", "https://merchant.taler.test/"],
],
};

View File

@ -0,0 +1,66 @@
import {
AbsoluteTime,
AmountJson,
TranslatedString
} from "@gnu-taler/taler-util";
import { DoubleColumnForm, FormState } from "@gnu-taler/web-util/browser";
export namespace Data {
export interface WithResolution {
when: AbsoluteTime;
threshold: AmountJson;
state: string;
}
export interface Form extends WithResolution {
comment: string;
}
}
const design: DoubleColumnForm = [
{
title: "Simple form" as TranslatedString,
fields: [
{
type: "textArea",
props: {
name: "comment",
label: "Comments" as TranslatedString,
},
},
],
},
{
title: "Resolution" as TranslatedString,
description: `Current state is and threshold at ` as TranslatedString,
fields: [
{
type: "date",
props: {
name: "when",
label: "Decision Time" as TranslatedString,
},
},
{
type: "amount",
props: {
name: "threshold",
label: "New threshold" as TranslatedString,
},
},
],
}
,
];
function formBehavior(v: Partial<Data.Form>): FormState<Data.Form> {
return {
when: {
disabled: true,
},
threshold: {
// disabled: v.state === AmlExchangeBackend.AmlState.frozen,
},
};
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,59 @@
import { LibtoolVersion } from "@gnu-taler/taler-util";
import { ErrorType, HttpError, HttpResponseServerError, RequestError, useApiContext } from "@gnu-taler/web-util/browser";
import { useEffect, useState } from "preact/hooks";
import { getInitialBackendBaseURL } from "./backend.js";
/**
* Protocol version spoken with the bank.
*
* Uses libtool's current:revision:age versioning.
*/
export const BANK_INTEGRATION_PROTOCOL_VERSION = "0:0:0";
async function getConfigState(
request: ReturnType<typeof useApiContext>["request"],
): Promise<SandboxBackend.Config> {
const url = getInitialBackendBaseURL();
const result = await request<SandboxBackend.Config>(url, `config`);
return result.data;
}
export type ConfigResult = undefined
| { type: "ok", result: Required<SandboxBackend.Config> }
| { type: "wrong", result: SandboxBackend.Config }
| { type: "error", result: HttpError<SandboxBackend.SandboxError> }
export function useConfigState(): ConfigResult {
const [checked, setChecked] = useState<ConfigResult>()
const { request } = useApiContext();
useEffect(() => {
getConfigState(request)
.then((result) => {
const r = LibtoolVersion.compare(BANK_INTEGRATION_PROTOCOL_VERSION, result.version)
if (r?.compatible) {
const complete: Required<SandboxBackend.Config> = {
currency_fraction_digits: result.currency_fraction_digits ?? 2,
currency_fraction_limit: result.currency_fraction_limit ?? 2,
fiat_currency: "",
have_cashout: result.have_cashout ?? false,
name: result.name,
version: result.version,
}
setChecked({ type: "ok", result: complete });
} else {
setChecked({ type: "wrong", result })
}
})
.catch((error: unknown) => {
if (error instanceof RequestError) {
const result = error.cause
setChecked({ type: "error", result });
}
});
}, []);
return checked;
}

View File

@ -1,54 +0,0 @@
import { TranslatedString } from "@gnu-taler/taler-util";
import { memoryMap } from "@gnu-taler/web-util/browser";
import { StateUpdater, useEffect, useState } from "preact/hooks";
export type NotificationMessage = ErrorNotification | InfoNotification;
//FIXME: this should not be exported since every notification
// goes throw notify function
export interface ErrorMessage {
description?: string;
title: TranslatedString;
debug?: string;
}
interface ErrorNotification {
type: "error";
error: ErrorMessage;
}
interface InfoNotification {
type: "info";
info: TranslatedString;
}
const storage = memoryMap<NotificationMessage>();
const NOTIFICATION_KEY = "notification";
export function onNotificationUpdate(
handler: (newValue: NotificationMessage | undefined) => void,
) {
return storage.onUpdate(NOTIFICATION_KEY, () => {
const newValue = storage.get(NOTIFICATION_KEY);
handler(newValue);
});
}
export function notifyError(error: ErrorMessage) {
storage.set(NOTIFICATION_KEY, { type: "error", error });
}
export function notifyInfo(info: TranslatedString) {
storage.set(NOTIFICATION_KEY, { type: "info", info });
}
export function useNotifications(): [
NotificationMessage | undefined,
StateUpdater<NotificationMessage | undefined>,
] {
const [value, setter] = useState<NotificationMessage | undefined>();
useEffect(() => {
return storage.onUpdate(NOTIFICATION_KEY, () => {
setter(storage.get(NOTIFICATION_KEY));
});
});
return [value, setter];
}

View File

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

View File

@ -0,0 +1,135 @@
import { AbsoluteTime, HttpStatusCode } from "@gnu-taler/taler-util";
import { ErrorType, HttpError, RequestError, useApiContext } from "@gnu-taler/web-util/browser";
import { getInitialBackendBaseURL } from "./backend.js";
export function useCredentialsChecker() {
const { request } = useApiContext();
const baseUrl = getInitialBackendBaseURL();
//check against instance details endpoint
//while merchant backend doesn't have a login endpoint
async function requestNewLoginToken(
username: string,
password: string,
): Promise<LoginResult> {
const data: LoginTokenRequest = {
scope: "readwrite" as "write", //FIX: different than merchant
duration: {
// d_us: "forever" //FIX: should return shortest
d_us: 60 * 60 * 24 * 7 * 1000 * 1000
},
refreshable: true,
}
try {
const response = await request<LoginTokenSuccessResponse>(baseUrl, `accounts/${username}/token`, {
method: "POST",
basicAuth: {
username,
password,
},
data,
contentType: "json"
});
return { valid: true, token: `secret-token:${response.data.access_token}` as AccessToken, expiration: response.data.expiration };
} catch (error) {
if (error instanceof RequestError) {
return { valid: false, cause: error.cause };
}
return {
valid: false, cause: {
type: ErrorType.UNEXPECTED,
loading: false,
info: {
hasToken: true,
status: 0,
options: {},
url: `/private/token`,
payload: {}
},
exception: error,
message: (error instanceof Error ? error.message : "unpexepected error")
}
};
}
};
async function refreshLoginToken(
baseUrl: string,
token: LoginToken
): Promise<LoginResult> {
if (AbsoluteTime.isExpired(AbsoluteTime.fromProtocolTimestamp(token.expiration))) {
return {
valid: false, cause: {
type: ErrorType.CLIENT,
status: HttpStatusCode.Unauthorized,
message: "login token expired, login again.",
info: {
hasToken: true,
status: 401,
options: {},
url: `/private/token`,
payload: {}
},
payload: {}
},
}
}
return requestNewLoginToken(baseUrl, token.token)
}
return { requestNewLoginToken, refreshLoginToken }
}
export interface LoginToken {
token: AccessToken,
expiration: Timestamp,
}
// token used to get loginToken
// must forget after used
declare const __ac_token: unique symbol;
export type AccessToken = string & {
[__ac_token]: true;
};
type YesOrNo = "yes" | "no";
export type LoginResult = {
valid: true;
token: AccessToken;
expiration: Timestamp;
} | {
valid: false;
cause: HttpError<{}>;
}
// DELETE /private/instances/$INSTANCE
export interface LoginTokenRequest {
// Scope of the token (which kinds of operations it will allow)
scope: "readonly" | "write";
// Server may impose its own upper bound
// on the token validity duration
duration?: RelativeTime;
// Can this token be refreshed?
// Defaults to false.
refreshable?: boolean;
}
export interface LoginTokenSuccessResponse {
// The login token that can be used to access resources
// that are in scope for some time. Must be prefixed
// with "Bearer " when used in the "Authorization" HTTP header.
// Will already begin with the RFC 8959 prefix.
access_token: AccessToken;
// Scope of the token (which kinds of operations it will allow)
scope: "readonly" | "write";
// Server may impose its own upper bound
// on the token validity duration
expiration: Timestamp;
// Can this token be refreshed?
refreshable: boolean;
}

View File

@ -16,7 +16,8 @@
@author Sebastian Javier Marchano
-->
<!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" />
<meta charset="utf-8" />
@ -24,10 +25,8 @@
<meta name="taler-support" content="uri">
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<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=="
/>
<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==" />
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
<title>Demobank</title>
<!-- Optional customization script. -->
@ -36,7 +35,9 @@
<script type="module" src="index.js"></script>
<link rel="stylesheet" href="index.css" />
</head>
<body>
<body class="h-full">
<div id="app"></div>
</body>
</html>

View File

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

View File

@ -1,170 +0,0 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { Amounts, HttpStatusCode, parsePaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util";
import {
ErrorType,
HttpResponsePaginated,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { Transactions } from "../components/Transactions/index.js";
import { useBackendContext } from "../context/backend.js";
import { useAccountDetails } from "../hooks/access.js";
import { LoginForm } from "./LoginForm.js";
import { PaymentOptions } from "./PaymentOptions.js";
import { notifyError } from "../hooks/notification.js";
import { useEffect, useState } from "preact/hooks";
interface Props {
account: string;
onLoadNotOk: <T>(
error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
) => VNode;
}
export const CopyIcon = (): VNode => (
<svg height="16" viewBox="0 0 16 16" width="16">
<path
fill-rule="evenodd"
d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"
/>
<path
fill-rule="evenodd"
d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"
/>
</svg>
);
export const CopiedIcon = (): VNode => (
<svg height="16" viewBox="0 0 16 16" width="16">
<path
fill-rule="evenodd"
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
/>
</svg>
);
function CopyButton({ getContent }: { getContent: () => string }): VNode {
const [copied, setCopied] = useState(false);
function copyText(): void {
navigator.clipboard.writeText(getContent() || "");
setCopied(true);
}
useEffect(() => {
if (copied) {
setTimeout(() => {
setCopied(false);
}, 1000);
}
}, [copied]);
if (!copied) {
return (
<button onClick={copyText} style={{width:32, height:32, fontSize: "initial"}}>
<CopyIcon />
</button>
);
}
return (
<div content="Copied" style={{display:"inline-block"}}>
<button disabled style={{width:32, height:32 , fontSize: "initial"}}>
<CopiedIcon />
</button>
</div>
);
}
/**
* Query account information and show QR code if there is pending withdrawal
*/
export function AccountPage({ account, onLoadNotOk }: Props): VNode {
const result = useAccountDetails(account);
const backend = useBackendContext();
const { i18n } = useTranslationContext();
if (!result.ok) {
if (result.loading || result.type === ErrorType.TIMEOUT) {
return onLoadNotOk(result);
}
//logout if there is any error, not if loading
backend.logOut();
if (result.status === HttpStatusCode.NotFound) {
notifyError({
title: i18n.str`Username or account label "${account}" not found`,
});
return <LoginForm />;
}
return onLoadNotOk(result);
}
const { data } = result;
const balance = Amounts.parseOrThrow(data.balance.amount);
const debitThreshold = Amounts.parseOrThrow(data.debitThreshold);
const payto = parsePaytoUri(data.paytoUri);
if (!payto || !payto.isKnown || payto.targetType !== "iban") {
return (
<div>Payto from server is not valid &quot;{data.paytoUri}&quot;</div>
);
}
const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
const limit = balanceIsDebit
? Amounts.sub(debitThreshold, balance).amount
: Amounts.add(balance, debitThreshold).amount;
return (
<Fragment>
<div>
<h1 class="nav welcome-text">
<i18n.Translate>
Welcome, {account} (<a href={stringifyPaytoUri(payto)}>{payto.iban}</a>)! <CopyButton getContent={() => stringifyPaytoUri(payto)} />
</i18n.Translate>
</h1>
</div>
<section id="assets">
<div class="asset-summary">
<h2>{i18n.str`Bank account balance`}</h2>
{!balance ? (
<div class="large-amount" style={{ color: "gray" }}>
Waiting server response...
</div>
) : (
<div class="large-amount amount">
{balanceIsDebit ? <b>-</b> : null}
<span class="value">{`${Amounts.stringifyValue(balance)}`}</span>
&nbsp;
<span class="currency">{`${balance.currency}`}</span>
</div>
)}
</div>
</section>
<section id="payments">
<div class="payments">
<h2>{i18n.str`Payments`}</h2>
<PaymentOptions limit={limit} />
</div>
</section>
<section style={{ marginTop: "2em" }}>
<div class="active">
<h3>{i18n.str`Latest transactions`}</h3>
<Transactions account={account} />
</div>
</section>
</Fragment>
);
}

View File

@ -0,0 +1,92 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { HttpError, HttpResponseOk, HttpResponsePaginated, utils } from "@gnu-taler/web-util/browser";
import { AbsoluteTime, AmountJson, PaytoUriIBAN, PaytoUriTalerBank } from "@gnu-taler/taler-util";
import { Loading } from "../../components/Loading.js";
import { useComponentState } from "./state.js";
import { ReadyView, InvalidIbanView } from "./views.js";
import { VNode } from "preact";
import { LoginForm } from "../LoginForm.js";
import { ErrorLoading } from "../../components/ErrorLoading.js";
export interface Props {
account: string;
onLoadNotOk: <T>(
error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
) => VNode;
goToBusinessAccount: () => void;
goToConfirmOperation: (id: string) => void;
}
export type State = State.Loading | State.LoadingError | State.Ready | State.InvalidIban | State.UserNotFound;
export namespace State {
export interface Loading {
status: "loading";
error: undefined;
}
export interface LoadingError {
status: "loading-error";
error: HttpError<SandboxBackend.SandboxError>;
}
export interface BaseInfo {
error: undefined;
}
export interface Ready extends BaseInfo {
status: "ready";
error: undefined;
account: string,
limit: AmountJson,
goToBusinessAccount: () => void;
goToConfirmOperation: (id: string) => void;
}
export interface InvalidIban {
status: "invalid-iban",
error: HttpResponseOk<SandboxBackend.CoreBank.AccountData>;
}
export interface UserNotFound {
status: "error-user-not-found",
error: HttpError<any>;
onRegister?: () => void;
}
}
export interface Transaction {
negative: boolean;
counterpart: string;
when: AbsoluteTime;
amount: AmountJson | undefined;
subject: string;
}
const viewMapping: utils.StateViewMap<State> = {
loading: Loading,
"error-user-not-found": LoginForm,
"invalid-iban": InvalidIbanView,
"loading-error": ErrorLoading,
ready: ReadyView,
};
export const AccountPage = utils.compose(
(p: Props) => useComponentState(p),
viewMapping,
);

View File

@ -0,0 +1,92 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { Amounts, HttpStatusCode, parsePaytoUri } from "@gnu-taler/taler-util";
import { ErrorType, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
import { useBackendContext } from "../../context/backend.js";
import { useAccountDetails } from "../../hooks/access.js";
import { Props, State } from "./index.js";
export function useComponentState({ account, goToBusinessAccount, goToConfirmOperation }: Props): State {
const result = useAccountDetails(account);
const backend = useBackendContext();
const { i18n } = useTranslationContext();
if (result.loading) {
return {
status: "loading",
error: undefined,
};
}
if (!result.ok) {
if (result.loading || result.type === ErrorType.TIMEOUT) {
return {
status: "loading-error",
error: result,
};
}
//logout if there is any error, not if loading
// backend.logOut();
if (result.status === HttpStatusCode.NotFound) {
notifyError(i18n.str`Username or account label "${account}" not found`, undefined);
return {
status: "error-user-not-found",
error: result,
};
}
if (result.status === HttpStatusCode.Unauthorized) {
notifyError(i18n.str`Authorization denied`, i18n.str`Maybe the session has expired, login again.`);
return {
status: "error-user-not-found",
error: result,
};
}
return {
status: "loading-error",
error: result,
};
}
const { data } = result;
const balance = Amounts.parseOrThrow(data.balance.amount);
const debitThreshold = Amounts.parseOrThrow(data.debit_threshold);
const payto = parsePaytoUri(data.payto_uri);
if (!payto || !payto.isKnown || (payto.targetType !== "iban" && payto.targetType !== "x-taler-bank")) {
return {
status: "invalid-iban",
error: result
};
}
const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
const limit = balanceIsDebit
? Amounts.sub(debitThreshold, balance).amount
: Amounts.add(balance, debitThreshold).amount;
return {
status: "ready",
goToBusinessAccount,
goToConfirmOperation,
error: undefined,
account,
limit,
};
}

View File

@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@ -19,17 +19,11 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
footer.footer {
.logo {
img {
width: auto;
height: $footer-logo-height;
}
}
}
import * as tests from "@gnu-taler/web-util/testing";
import { ReadyView } from "./views.js";
@include mobile {
.footer-copyright {
text-align: center;
}
}
export default {
title: "account page",
};
export const Ready = tests.createExample(ReadyView, {});

View File

@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@ -19,16 +19,14 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
@mixin transition($t) {
transition: $t 250ms ease-in-out 50ms;
}
import * as tests from "@gnu-taler/web-util/testing";
import { SwrMockEnvironment } from "@gnu-taler/web-util/testing";
import { expect } from "chai";
import { CASHOUT_API_EXAMPLE } from "../../endpoints.js";
import { Props } from "./index.js";
import { useComponentState } from "./state.js";
@mixin icon-with-update-mark($icon-base-width) {
.icon {
width: $icon-base-width;
&.has-update-mark:after {
right: ($icon-base-width / 2) - 0.85;
}
}
}
describe("Account states", () => {
it("should do some tests", async () => {
});
});

View File

@ -0,0 +1,93 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { Attention } from "../../components/Attention.js";
import { Transactions } from "../../components/Transactions/index.js";
import { useBusinessAccountDetails } from "../../hooks/circuit.js";
import { useSettings } from "../../hooks/settings.js";
import { PaymentOptions } from "../PaymentOptions.js";
import { State } from "./index.js";
export function InvalidIbanView({ error }: State.InvalidIban) {
return (
<div>Payto from server is not valid &quot;{error.data.payto_uri}&quot;</div>
);
}
const IS_PUBLIC_ACCOUNT_ENABLED = false
function ShowDemoInfo(): VNode {
const { i18n } = useTranslationContext();
const [settings, updateSettings] = useSettings();
if (!settings.showDemoDescription) return <Fragment />
return <Attention title={i18n.str`This is a demo bank`} onClose={() => {
updateSettings("showDemoDescription", false);
}}>
{IS_PUBLIC_ACCOUNT_ENABLED ? (
<i18n.Translate>
This part of the demo shows how a bank that supports Taler
directly would work. In addition to using your own bank
account, you can also see the transaction history of some{" "}
<a href="/public-accounts">Public Accounts</a>.
</i18n.Translate>
) : (
<i18n.Translate>
This part of the demo shows how a bank that supports Taler
directly would work.
</i18n.Translate>
)}
</Attention>
}
export function ReadyView({ account, limit, goToBusinessAccount, goToConfirmOperation }: State.Ready): VNode<{}> {
const { i18n } = useTranslationContext();
return <Fragment>
<MaybeBusinessButton account={account} onClick={goToBusinessAccount} />
<ShowDemoInfo />
<PaymentOptions limit={limit} goToConfirmOperation={goToConfirmOperation} />
<Transactions account={account} />
</Fragment>;
}
function MaybeBusinessButton({
account,
onClick,
}: {
account: string;
onClick: () => void;
}): VNode {
const { i18n } = useTranslationContext();
const result = useBusinessAccountDetails(account);
if (!result.ok) return <Fragment />;
return (
<div class="w-full flex justify-end">
<button
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
onClick={(e) => {
e.preventDefault()
onClick()
}}
>
<i18n.Translate>Business Profile</i18n.Translate>
</button>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -0,0 +1,122 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { AbsoluteTime, AmountJson, WithdrawUriResult } from "@gnu-taler/taler-util";
import { HttpError, utils } from "@gnu-taler/web-util/browser";
import { ErrorLoading } from "../../components/ErrorLoading.js";
import { Loading } from "../../components/Loading.js";
import { useComponentState } from "./state.js";
import { AbortedView, ConfirmedView, InvalidPaytoView, InvalidReserveView, InvalidWithdrawalView, NeedConfirmationView, ReadyView } from "./views.js";
export interface Props {
currency: string;
onClose: () => void;
}
export type State = State.Loading |
State.LoadingError |
State.Ready |
State.Aborted |
State.Confirmed |
State.InvalidPayto |
State.InvalidWithdrawal |
State.InvalidReserve |
State.NeedConfirmation;
export namespace State {
export interface Loading {
status: "loading";
error: undefined;
}
export interface LoadingError {
status: "loading-error";
error: HttpError<SandboxBackend.SandboxError>;
}
/**
* Need to open the wallet
*/
export interface Ready {
status: "ready";
error: undefined;
uri: WithdrawUriResult,
onClose: () => void;
onAbort: () => void;
}
export interface InvalidPayto {
status: "invalid-payto",
error: undefined;
payto: string | null;
onClose: () => void;
}
export interface InvalidWithdrawal {
status: "invalid-withdrawal",
error: undefined;
onClose: () => void;
uri: string,
}
export interface InvalidReserve {
status: "invalid-reserve",
error: undefined;
onClose: () => void;
reserve: string | null;
}
export interface NeedConfirmation {
status: "need-confirmation",
onAbort: () => void;
onConfirm: () => void;
error: undefined;
busy: boolean,
}
export interface Aborted {
status: "aborted",
error: undefined;
onClose: () => void;
}
export interface Confirmed {
status: "confirmed",
error: undefined;
onClose: () => void;
}
}
export interface Transaction {
negative: boolean;
counterpart: string;
when: AbsoluteTime;
amount: AmountJson | undefined;
subject: string;
}
const viewMapping: utils.StateViewMap<State> = {
loading: Loading,
"invalid-payto": InvalidPaytoView,
"invalid-withdrawal": InvalidWithdrawalView,
"invalid-reserve": InvalidReserveView,
"need-confirmation": NeedConfirmationView,
"aborted": AbortedView,
"confirmed": ConfirmedView,
"loading-error": ErrorLoading,
ready: ReadyView,
};
export const OperationState = utils.compose(
(p: Props) => useComponentState(p),
viewMapping,
);

View File

@ -0,0 +1,265 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { Amounts, HttpStatusCode, TranslatedString, parsePaytoUri, parseWithdrawUri, stringifyWithdrawUri } from "@gnu-taler/taler-util";
import { RequestError, notify, notifyError, notifyInfo, useTranslationContext, utils } from "@gnu-taler/web-util/browser";
import { useEffect, useState } from "preact/hooks";
import { useAccessAPI, useAccessAnonAPI, useWithdrawalDetails } from "../../hooks/access.js";
import { getInitialBackendBaseURL } from "../../hooks/backend.js";
import { useSettings } from "../../hooks/settings.js";
import { buildRequestErrorMessage } from "../../utils.js";
import { Props, State } from "./index.js";
export function useComponentState({ currency, onClose }: Props): utils.RecursiveState<State> {
const { i18n } = useTranslationContext();
const [settings, updateSettings] = useSettings()
const { createWithdrawal } = useAccessAPI();
const { abortWithdrawal, confirmWithdrawal } = useAccessAnonAPI();
const [busy, setBusy] = useState<Record<string, undefined>>()
const amount = settings.maxWithdrawalAmount
async function doSilentStart() {
//FIXME: if amount is not enough use balance
const parsedAmount = Amounts.parseOrThrow(`${currency}:${amount}`)
try {
const result = await createWithdrawal({
amount: Amounts.stringify(parsedAmount),
});
const uri = parseWithdrawUri(result.data.taler_withdraw_uri);
if (!uri) {
return notifyError(
i18n.str`Server responded with an invalid withdraw URI`,
i18n.str`Withdraw URI: ${result.data.taler_withdraw_uri}`);
} else {
updateSettings("currentWithdrawalOperationId", uri.withdrawalOperationId)
}
} catch (error) {
if (error instanceof RequestError) {
notify(
buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) =>
status === HttpStatusCode.Forbidden
? i18n.str`The operation was rejected due to insufficient funds`
: undefined,
}),
);
} else {
notifyError(
i18n.str`Operation failed, please report`,
(error instanceof Error
? error.message
: JSON.stringify(error)) as TranslatedString
)
}
}
}
const withdrawalOperationId = settings.currentWithdrawalOperationId
useEffect(() => {
if (withdrawalOperationId === undefined) {
doSilentStart()
}
}, [settings.fastWithdrawal, amount])
const baseUrl = getInitialBackendBaseURL()
if (!withdrawalOperationId) {
return {
status: "loading",
error: undefined
}
}
const wid = withdrawalOperationId
async function doAbort() {
try {
setBusy({})
await abortWithdrawal(wid);
onClose();
} catch (error) {
if (error instanceof RequestError) {
notify(
buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) =>
status === HttpStatusCode.Conflict
? i18n.str`The reserve operation has been confirmed previously and can't be aborted`
: undefined,
}),
);
} else {
notifyError(
i18n.str`Operation failed, please report`,
(error instanceof Error
? error.message
: JSON.stringify(error)) as TranslatedString
)
}
}
setBusy(undefined)
}
async function doConfirm() {
try {
setBusy({})
await confirmWithdrawal(wid);
if (!settings.showWithdrawalSuccess) {
notifyInfo(i18n.str`Wire transfer completed!`)
}
} catch (error) {
if (error instanceof RequestError) {
notify(
buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) =>
status === HttpStatusCode.Conflict
? i18n.str`The withdrawal has been aborted previously and can't be confirmed`
: status === HttpStatusCode.UnprocessableEntity
? i18n.str`The withdraw operation cannot be confirmed because no exchange and reserve public key selection happened before`
: undefined,
}),
);
} else {
notifyError(
i18n.str`Operation failed, please report`,
(error instanceof Error
? error.message
: JSON.stringify(error)) as TranslatedString
)
}
}
setBusy(undefined)
}
const bankIntegrationApiBaseUrl = `${baseUrl}/taler-integration`
const uri = stringifyWithdrawUri({
bankIntegrationApiBaseUrl,
withdrawalOperationId,
});
const parsedUri = parseWithdrawUri(uri);
if (!parsedUri) {
return {
status: "invalid-withdrawal",
error: undefined,
uri,
onClose,
}
}
return (): utils.RecursiveState<State> => {
const result = useWithdrawalDetails(withdrawalOperationId);
const shouldCreateNewOperation = !result.ok && !result.loading && result.info.status === HttpStatusCode.NotFound
useEffect(() => {
if (shouldCreateNewOperation) {
doSilentStart()
}
}, [])
if (!result.ok) {
if (result.loading) {
return {
status: "loading",
error: undefined
}
}
if (result.info.status === HttpStatusCode.NotFound) {
return {
status: "loading",
error: undefined,
}
}
return {
status: "loading-error",
error: result
}
}
const { data } = result;
if (data.aborted) {
return {
status: "aborted",
error: undefined,
onClose: async () => {
updateSettings("currentWithdrawalOperationId", undefined)
onClose()
},
}
}
if (data.confirmation_done) {
if (!settings.showWithdrawalSuccess) {
updateSettings("currentWithdrawalOperationId", undefined)
onClose()
}
return {
status: "confirmed",
error: undefined,
onClose: async () => {
updateSettings("currentWithdrawalOperationId", undefined)
onClose()
},
}
}
if (!data.selection_done) {
return {
status: "ready",
error: undefined,
uri: parsedUri,
onClose: async () => {
await doAbort()
updateSettings("currentWithdrawalOperationId", undefined)
onClose()
},
onAbort: doAbort,
}
}
if (!data.selected_reserve_pub) {
return {
status: "invalid-reserve",
error: undefined,
reserve: data.selected_reserve_pub,
onClose,
}
}
const account = !data.selected_exchange_account ? undefined : parsePaytoUri(data.selected_exchange_account)
if (!account) {
return {
status: "invalid-payto",
error: undefined,
payto: data.selected_exchange_account,
onClose,
}
}
// goToConfirmOperation(withdrawalOperationId)
return {
status: "need-confirmation",
error: undefined,
onAbort: async () => {
await doAbort()
updateSettings("currentWithdrawalOperationId", undefined)
onClose()
},
busy: !!busy,
onConfirm: doConfirm
}
}
}

View File

@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@ -19,6 +19,11 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
.is-tiles-wrapper {
margin-bottom: $default-padding;
}
import * as tests from "@gnu-taler/web-util/testing";
import { ReadyView } from "./views.js";
export default {
title: "operation status page",
};
export const Ready = tests.createExample(ReadyView, {});

View File

@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@ -19,17 +19,14 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
.modal-card {
width: $modal-card-width;
}
import * as tests from "@gnu-taler/web-util/testing";
import { SwrMockEnvironment } from "@gnu-taler/web-util/testing";
import { expect } from "chai";
import { CASHOUT_API_EXAMPLE } from "../../endpoints.js";
import { Props } from "./index.js";
import { useComponentState } from "./state.js";
.modal-card-foot {
background-color: $modal-card-foot-background-color;
}
@include mobile {
.modal .animation-content .modal-card {
width: $modal-card-width-mobile;
margin: 0 auto;
}
}
describe("Withdrawal operation states", () => {
it("should do some tests", async () => {
});
});

View File

@ -0,0 +1,376 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { stringifyWithdrawUri } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useEffect, useMemo, useState } from "preact/hooks";
import { QR } from "../../components/QR.js";
import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js";
import { useSettings } from "../../hooks/settings.js";
import { undefinedIfEmpty } from "../../utils.js";
import { State } from "./index.js";
export function InvalidPaytoView({ payto, onClose }: State.InvalidPayto) {
return (
<div>Payto from server is not valid &quot;{payto}&quot;</div>
);
}
export function InvalidWithdrawalView({ uri, onClose }: State.InvalidWithdrawal) {
return (
<div>Withdrawal uri from server is not valid &quot;{uri}&quot;</div>
);
}
export function InvalidReserveView({ reserve, onClose }: State.InvalidReserve) {
return (
<div>Reserve from server is not valid &quot;{reserve}&quot;</div>
);
}
export function NeedConfirmationView({ error, onAbort, onConfirm, busy }: State.NeedConfirmation) {
const { i18n } = useTranslationContext()
const captchaNumbers = useMemo(() => {
return {
a: Math.floor(Math.random() * 10),
b: Math.floor(Math.random() * 10),
};
}, []);
const [captchaAnswer, setCaptchaAnswer] = useState<string | undefined>();
const answer = parseInt(captchaAnswer ?? "", 10);
const errors = undefinedIfEmpty({
answer: !captchaAnswer
? i18n.str`Answer the question before continue`
: Number.isNaN(answer)
? i18n.str`The answer should be a number`
: answer !== captchaNumbers.a + captchaNumbers.b
? i18n.str`The answer "${answer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.`
: undefined,
}) ?? (busy ? {} as Record<string, undefined> : undefined);
return (
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold text-gray-900">
<i18n.Translate>Confirm the withdrawal operation</i18n.Translate>
</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<div class="px-4 mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-4 sm:gap-x-3">
<label class={"relative sm:col-span-2 flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-noneborder-indigo-600 ring-2 ring-indigo-600"}>
<input type="radio" name="project-type" value="Newsletter" class="sr-only" aria-labelledby="project-type-0-label" aria-describedby="project-type-0-description-0 project-type-0-description-1" />
<span class="flex flex-1">
<span class="flex flex-col">
<span id="project-type-0-label" class="block text-sm font-medium text-gray-900 ">
<i18n.Translate>challenge response test</i18n.Translate>
</span>
</span>
</span>
<svg class="h-5 w-5 text-indigo-600" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</label>
<label class="relative flex cursor-pointer rounded-lg border bg-gray-100 p-4 shadow-sm focus:outline-none border-gray-300">
<input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" />
<span class="flex flex-1">
<span class="flex flex-col">
<span id="project-type-1-label" class="block text-sm font-medium text-gray-900">
<i18n.Translate>using SMS</i18n.Translate>
</span>
<span id="project-type-1-description-0" class="mt-1 flex items-center text-sm text-gray-500">
<i18n.Translate>not available</i18n.Translate>
</span>
</span>
</span>
<svg class="h-5 w-5 text-indigo-600 hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</label>
<label class="relative flex cursor-pointer rounded-lg border bg-gray-100 p-4 shadow-sm focus:outline-none border-gray-300">
<input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" />
<span class="flex flex-1">
<span class="flex flex-col">
<span id="project-type-1-label" class="block text-sm font-medium text-gray-900">
<i18n.Translate>one time password</i18n.Translate>
</span>
<span id="project-type-1-description-0" class="mt-1 flex items-center text-sm text-gray-500">
<i18n.Translate>not available</i18n.Translate>
</span>
</span>
</span>
<svg class="h-5 w-5 text-indigo-600 hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</label>
</div>
</div>
<div class="mt-3 text-sm leading-6">
<form
class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
autoCapitalize="none"
autoCorrect="off"
onSubmit={e => {
e.preventDefault()
}}
>
<div class="px-4 py-6 sm:p-8">
<label for="withdraw-amount">{i18n.str`What is`}&nbsp;
<em>
{captchaNumbers.a}&nbsp;+&nbsp;{captchaNumbers.b}
</em>
?
</label>
<div class="mt-2">
<div class="relative rounded-md shadow-sm">
<input
type="text"
// class="block w-full rounded-md border-0 py-1.5 pl-16 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
aria-describedby="answer"
autoFocus
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
value={captchaAnswer ?? ""}
required
name="answer"
id="answer"
autocomplete="off"
onChange={(e): void => {
setCaptchaAnswer(e.currentTarget.value)
}}
/>
</div>
<ShowInputErrorLabel message={errors?.answer} isDirty={captchaAnswer !== undefined} />
</div>
</div>
<div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
<button type="button" class="text-sm font-semibold leading-6 text-gray-900"
onClick={onAbort}
>
<i18n.Translate>Cancel</i18n.Translate></button>
<button type="submit"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
disabled={!!errors}
onClick={(e) => {
e.preventDefault()
onConfirm()
}}
>
<i18n.Translate>Transfer</i18n.Translate>
</button>
</div>
</form>
</div>
<div class="px-4 mt-4 ">
{/* <div class="w-full">
<div class="px-4 sm:px-0 text-sm">
<p><i18n.Translate>Wire transfer details</i18n.Translate></p>
</div>
<div class="mt-6 border-t border-gray-100">
<dl class="divide-y divide-gray-100">
{((): VNode => {
switch (details.account.targetType) {
case "iban": {
const p = details.account as PaytoUriIBAN
const name = p.params["receiver-name"]
return <Fragment>
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.iban}</dd>
</div>
{name &&
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Exchange name</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd>
</div>
}
</Fragment>
}
case "x-taler-bank": {
const p = details.account as PaytoUriTalerBank
const name = p.params["receiver-name"]
return <Fragment>
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.account}</dd>
</div>
{name &&
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Exchange name</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd>
</div>
}
</Fragment>
}
default:
return <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{details.account.targetPath}</dd>
</div>
}
})()}
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Withdrawal identification</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0 break-words">{details.reserve}</dd>
</div>
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">To be added</dd>
// {/* Amounts.stringifyValue(details.amount)
</div>
</dl>
</div>
</div> */}
</div>
</div>
</div>
);
}
export function AbortedView({ error, onClose }: State.Aborted) {
return (
<div>aborted</div>
);
}
export function ConfirmedView({ error, onClose }: State.Confirmed) {
const { i18n } = useTranslationContext();
const [settings, updateSettings] = useSettings()
return (
<Fragment>
<div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white p-4 text-left shadow-xl transition-all ">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<div class="mt-3 text-center sm:mt-5">
<h3 class="text-base font-semibold leading-6 text-gray-900" id="modal-title">
<i18n.Translate>Withdrawal confirmed</i18n.Translate>
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
<i18n.Translate>
The wire transfer to the Taler operator has been initiated. You will soon receive the requested amount in your Taler wallet.
</i18n.Translate>
</p>
</div>
</div>
</div>
<div class="mt-4">
<div class="flex items-center justify-between">
<span class="flex flex-grow flex-col">
<span class="text-sm text-black font-medium leading-6 " id="availability-label">
<i18n.Translate>Do not show this again</i18n.Translate>
</span>
</span>
<button type="button" data-enabled={!settings.showWithdrawalSuccess} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description"
onClick={() => {
updateSettings("showWithdrawalSuccess", !settings.showWithdrawalSuccess);
}}>
<span aria-hidden="true" data-enabled={!settings.showWithdrawalSuccess} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
</button>
</div>
</div>
<div class="mt-5 sm:mt-6">
<button type="button"
class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
onClick={async (e) => {
e.preventDefault();
onClose()
}}>
<i18n.Translate>Close</i18n.Translate>
</button>
</div>
</Fragment>
);
}
export function ReadyView({ uri, onClose }: State.Ready): VNode<{}> {
const { i18n } = useTranslationContext();
useEffect(() => {
//Taler Wallet WebExtension is listening to headers response and tab updates.
//In the SPA there is no header response with the Taler URI so
//this hack manually triggers the tab update after the QR is in the DOM.
// WebExtension will be using
// https://developer.chrome.com/docs/extensions/reference/tabs/#event-onUpdated
document.title = `${document.title} ${uri.withdrawalOperationId}`;
}, []);
const talerWithdrawUri = stringifyWithdrawUri(uri);
return <Fragment>
<div class="flex justify-end mt-4">
<button type="button"
class="inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
onClick={() => {
onClose()
}}
>
Cancel
</button>
</div>
<div class="bg-white shadow sm:rounded-lg mt-4">
<div class="p-4">
<h3 class="text-base font-semibold leading-6 text-gray-900">
<i18n.Translate>On this device</i18n.Translate>
</h3>
<div class="mt-2 sm:flex sm:items-start sm:justify-between">
<div class="max-w-xl text-sm text-gray-500">
<p>
<i18n.Translate>If you are using a desktop browser you can open the popup now or click the link if you have the "Inject Taler support" option enabled.</i18n.Translate>
</p>
</div>
<div class="mt-5 sm:ml-6 sm:mt-0 sm:flex sm:flex-shrink-0 sm:items-center">
<a href={talerWithdrawUri}
class="inline-flex items-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Start</i18n.Translate>
</a>
</div>
</div>
</div>
</div>
<div class="bg-white shadow sm:rounded-lg mt-2">
<div class="p-4">
<h3 class="text-base font-semibold leading-6 text-gray-900">
<i18n.Translate>On a mobile phone</i18n.Translate>
</h3>
<div class="mt-2 sm:flex sm:items-start sm:justify-between">
<div class="max-w-xl text-sm text-gray-500">
<p>
<i18n.Translate>Scan the QR code with your mobile device.</i18n.Translate>
</p>
</div>
</div>
<div class="mt-2 max-w-md ml-auto mr-auto">
<QR text={talerWithdrawUri} />
</div>
</div>
</div>
</Fragment>
}

View File

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

View File

@ -17,42 +17,51 @@
import {
AmountJson,
Amounts,
buildPayto,
HttpStatusCode,
Logger,
TranslatedString,
buildPayto,
parsePaytoUri,
stringifyPaytoUri,
stringifyPaytoUri
} from "@gnu-taler/taler-util";
import {
RequestError,
notify,
notifyError,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { h, VNode } from "preact";
import { h, VNode, Fragment, Ref } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { notifyError } from "../hooks/notification.js";
import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js";
import { useAccessAPI } from "../hooks/access.js";
import {
buildRequestErrorMessage,
undefinedIfEmpty,
validateIBAN,
} from "../utils.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
import { useConfigState } from "../hooks/config.js";
import { useConfigContext } from "../context/config.js";
const logger = new Logger("PaytoWireTransferForm");
export function PaytoWireTransferForm({
focus,
title,
onSuccess,
onCancel,
limit,
}: {
title: TranslatedString,
focus?: boolean;
onSuccess: () => void;
onCancel: (() => void) | undefined;
limit: AmountJson;
}): VNode {
const [isRawPayto, setIsRawPayto] = useState(false);
const [iban, setIban] = useState<string | undefined>(undefined);
const [subject, setSubject] = useState<string | undefined>(undefined);
const [amount, setAmount] = useState<string | undefined>(undefined);
// FIXME: remove this
const [iban, setIban] = useState<string | undefined>();
const [subject, setSubject] = useState<string | undefined>();
const [amount, setAmount] = useState<string | undefined>();
const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>(
undefined,
@ -70,17 +79,17 @@ export function PaytoWireTransferForm({
const errorsWire = undefinedIfEmpty({
iban: !iban
? i18n.str`Missing IBAN`
? i18n.str`required`
: !IBAN_REGEX.test(iban)
? i18n.str`IBAN should have just uppercased letters and numbers`
: validateIBAN(iban, i18n),
subject: !subject ? i18n.str`Missing subject` : undefined,
subject: !subject ? i18n.str`required` : undefined,
amount: !trimmedAmountStr
? i18n.str`Missing amount`
? i18n.str`required`
: !parsedAmount
? i18n.str`Amount is not valid`
? i18n.str`not valid`
: Amounts.isZero(parsedAmount)
? i18n.str`Should be greater than 0`
? i18n.str`should be greater than 0`
: Amounts.cmp(limit, parsedAmount) === -1
? i18n.str`balance is not enough`
: undefined,
@ -88,41 +97,179 @@ export function PaytoWireTransferForm({
const { createTransaction } = useAccessAPI();
if (!isRawPayto)
return (
const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput);
const errorsPayto = undefinedIfEmpty({
rawPaytoInput: !rawPaytoInput
? i18n.str`required`
: !parsed
? i18n.str`does not follow the pattern`
: !parsed.isKnown || parsed.targetType !== "iban"
? i18n.str`only "IBAN" target are supported`
: !parsed.params.amount
? i18n.str`use the "amount" parameter to specify the amount to be transferred`
: Amounts.parse(parsed.params.amount) === undefined
? i18n.str`the amount is not valid`
: !parsed.params.message
? i18n.str`use the "message" parameter to specify a reference text for the transfer`
: !IBAN_REGEX.test(parsed.iban)
? i18n.str`IBAN should have just uppercased letters and numbers`
: validateIBAN(parsed.iban, i18n),
});
async function doSend() {
let payto_uri: string | undefined;
if (rawPaytoInput) {
payto_uri = rawPaytoInput
} else {
if (!iban || !subject) return;
const ibanPayto = buildPayto("iban", iban, undefined);
ibanPayto.params.message = encodeURIComponent(subject);
payto_uri = stringifyPaytoUri(ibanPayto);
}
try {
await createTransaction({
payto_uri,
amount: `${limit.currency}:${amount}`,
});
onSuccess();
setAmount(undefined);
setIban(undefined);
setSubject(undefined);
rawPaytoInputSetter(undefined)
} catch (error) {
if (error instanceof RequestError) {
notify(
buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) =>
status === HttpStatusCode.BadRequest
? i18n.str`The request was invalid or the payto://-URI used unacceptable features.`
: undefined,
}),
);
} else {
notifyError(
i18n.str`Operation failed, please report`,
(error instanceof Error
? error.message
: JSON.stringify(error)) as TranslatedString
)
}
}
}
return (<div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
{/**
* FIXME: Scan a qr code
*/}
<div class="px-4 sm:px-0">
<h2 class="text-base font-semibold leading-7 text-gray-900">
{title}
</h2>
<div>
<div class="px-4 mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-1 sm:gap-x-4">
<label class={"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + (!isRawPayto ? "border-indigo-600 ring-2 ring-indigo-600" : "border-gray-300")}>
<input type="radio" name="project-type" value="Newsletter" class="sr-only" aria-labelledby="project-type-0-label" aria-describedby="project-type-0-description-0 project-type-0-description-1" onChange={() => {
if (parsed && parsed.isKnown && parsed.targetType === "iban") {
setIban(parsed.iban)
const amount = Amounts.parse(parsed.params["amount"])
if (amount) {
setAmount(Amounts.stringifyValue(amount))
}
const subject = parsed.params["subject"]
if (subject) {
setSubject(subject)
}
}
setIsRawPayto(false)
}} />
<span class="flex flex-1">
<span class="flex flex-col">
<span class="block text-sm font-medium text-gray-900">
<i18n.Translate>Using a form</i18n.Translate>
</span>
</span>
</span>
</label>
<label class={"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + (isRawPayto ? "border-indigo-600 ring-2 ring-indigo-600" : "border-gray-300")}>
<input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" onChange={() => {
if (iban) {
const payto = buildPayto("iban", iban, undefined)
if (parsedAmount) {
payto.params["amount"] = Amounts.stringify(parsedAmount)
}
if (subject) {
payto.params["message"] = subject
}
rawPaytoInputSetter(stringifyPaytoUri(payto))
}
setIsRawPayto(true)
}} />
<span class="flex flex-1">
<span class="flex flex-col">
<span class="block text-sm font-medium text-gray-900">
<i18n.Translate>Import payto:// URI</i18n.Translate>
</span>
</span>
</span>
</label>
</div>
</div>
</div>
<form
class="pure-form"
name="wire-transfer-form"
onSubmit={(e) => {
e.preventDefault();
}}
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()
}}
>
<label for="iban">{i18n.str`Receiver IBAN:`}</label>&nbsp;
<div class="px-4 py-6 sm:p-8">
{!isRawPayto ?
<div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<div class="sm:col-span-5">
<label for="iban" class="block text-sm font-medium leading-6 text-gray-900">{i18n.str`Recipient`}</label>
<div class="mt-2">
<input
ref={ref}
ref={focus ? doAutoFocus : undefined}
type="text"
id="iban"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
name="iban"
id="iban"
value={iban ?? ""}
placeholder="CC0123456789"
autocomplete="off"
required
pattern={ibanRegex}
onInput={(e): void => {
setIban(e.currentTarget.value);
setIban(e.currentTarget.value.toUpperCase());
}}
/>
<ShowInputErrorLabel
message={errorsWire?.iban}
isDirty={iban !== undefined}
/>
<label for="subject">{i18n.str`Transfer subject:`}</label>&nbsp;
</div>
<p class="mt-2 text-sm text-gray-500" >
<i18n.Translate>IBAN of the recipient's account</i18n.Translate>
</p>
</div>
<div class="sm:col-span-5">
<label for="subject" class="block text-sm font-medium leading-6 text-gray-900">{i18n.str`Transfer subject`}</label>
<div class="mt-2">
<input
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
@ -134,161 +281,43 @@ export function PaytoWireTransferForm({
message={errorsWire?.subject}
isDirty={subject !== undefined}
/>
<label for="amount">{i18n.str`Amount:`}</label>&nbsp;
<div style={{ width: "max-content", display: "flex" }}>
<input
type="text"
readonly
class="currency-indicator"
size={limit.currency.length}
maxLength={limit.currency.length}
tabIndex={-1}
style={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
borderRight: 0,
}}
value={limit.currency}
/>
<input
type="number"
name="amount"
id="amount"
placeholder="amount"
required
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderLeft: 0,
width: 150,
}}
value={amount ?? ""}
onInput={(e): void => {
setAmount(e.currentTarget.value);
}}
/>
</div>
<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={amount !== undefined}
isDirty={subject !== 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>
<p class="mt-2 text-sm text-gray-500" >amount to transfer</p>
</div>
);
const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput);
const errorsPayto = undefinedIfEmpty({
rawPaytoInput: !rawPaytoInput
? i18n.str`required`
: !parsed
? i18n.str`does not follow the pattern`
: !parsed.params.amount
? i18n.str`use the "amount" parameter to specify the amount to be transferred`
: Amounts.parse(parsed.params.amount) === undefined
? i18n.str`the amount is not valid`
: !parsed.params.message
? i18n.str`use the "message" parameter to specify a reference text for the transfer`
: !parsed.isKnown || parsed.targetType !== "iban"
? i18n.str`only "IBAN" target are supported`
: !IBAN_REGEX.test(parsed.iban)
? i18n.str`IBAN should have just uppercased letters and numbers`
: validateIBAN(parsed.iban, i18n),
});
return (
<div>
<p>{i18n.str`Transfer money to account identified by payto:// URI:`}</p>
<form
class="pure-form"
name="payto-form"
onSubmit={(e) => {
e.preventDefault();
}}
autoCapitalize="none"
autoCorrect="off"
>
<p>
<label for="address">{i18n.str`payto URI:`}</label>&nbsp;
<input
</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"
type="text"
size={50}
ref={ref}
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 address`}
// pattern={`payto://iban/[A-Z][A-Z][0-9]+?message=[a-zA-Z0-9 ]+&amount=${currency}:[0-9]+(.[0-9]+)?`}
placeholder={i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limit.currency}:X.Y]`}
onInput={(e): void => {
rawPaytoInputSetter(e.currentTarget.value);
}}
@ -297,68 +326,125 @@ export function PaytoWireTransferForm({
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;
</div>
</div>
}
try {
await createTransaction({
paytoUri: rawPaytoInput,
});
onSuccess();
rawPaytoInputSetter(undefined);
} catch (error) {
if (error instanceof RequestError) {
notifyError(
buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) =>
status === HttpStatusCode.BadRequest
? i18n.str`The request was invalid or the payto://-URI used unacceptable features.`
: undefined,
}),
);
} else {
notifyError({
title: i18n.str`Operation failed, please report`,
description:
error instanceof Error
? error.message
: JSON.stringify(error),
});
</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 />
}
}
}}
/>
</p>
<p>
<a
href="/account"
onClick={() => {
setIsRawPayto(false);
<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.str`Use wire-transfer form?`}
</a>
</p>
<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>
);
}
export function RenderAmount({ value, negative }: { value: AmountJson, negative?: boolean }): VNode {
const cfg = useConfigContext()
const str = Amounts.stringifyValue(value)
const sep_pos = str.indexOf(FRAC_SEPARATOR)
if (sep_pos !== -1 && str.length - sep_pos - 1 > cfg.currency_fraction_digits) {
const limit = sep_pos + cfg.currency_fraction_digits + 1
const normal = str.substring(0, limit)
const small = str.substring(limit)
return <span class="whitespace-nowrap">
{negative ? "-" : undefined}
{value.currency} {normal} <sup class="-ml-2">{small}</sup>
</span>
}
return <span class="whitespace-nowrap">
{negative ? "-" : undefined}
{value.currency} {str}
</span>
}

View File

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

View File

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

View File

@ -13,26 +13,31 @@
You should have received a copy of the GNU General Public License along with
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 {
RequestError,
notify,
notifyError,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { useBackendContext } from "../context/backend.js";
import { useTestingAPI } from "../hooks/access.js";
import { notifyError } from "../hooks/notification.js";
import { bankUiSettings } from "../settings.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");
export function RegistrationPage({
onComplete,
onCancel
}: {
onComplete: () => void;
onCancel: () => void;
}): VNode {
const { i18n } = useTranslationContext();
if (!bankUiSettings.allowRegistrations) {
@ -40,29 +45,48 @@ export function RegistrationPage({
<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.
*/
function RegistrationForm({ onComplete }: { onComplete: () => void }): VNode {
function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, onCancel: () => void }): VNode {
const backend = useBackendContext();
const [username, setUsername] = useState<string | undefined>();
const [name, setName] = 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 { requestNewLoginToken } = useCredentialsChecker()
const { register } = useTestingAPI();
const { i18n } = useTranslationContext();
const errors = undefinedIfEmpty({
// name: !name
// ? i18n.str`Missing name`
// : undefined,
username: !username
? i18n.str`Missing username`
: !USERNAME_REGEX.test(username)
? i18n.str`Use letters and numbers only, and start with a lowercase letter`
: 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,
repeatPassword: !repeatPassword
? i18n.str`Missing password`
@ -71,32 +95,120 @@ function RegistrationForm({ onComplete }: { onComplete: () => void }): VNode {
: 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 (
<Fragment>
<h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1>
<article>
<div class="register-div">
<form
class="register-form"
noValidate
<h1 class="nav"></h1>
<div class="flex min-h-full flex-col justify-center">
<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`Account registration`}</h2>
</div>
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form class="space-y-6" noValidate
onSubmit={(e) => {
e.preventDefault();
}}
autoCapitalize="none"
autoCorrect="off"
>
<div class="pure-form">
<h2>{i18n.str`Please register!`}</h2>
<p class="unameFieldLabel registerFieldLabel formFieldLabel">
<label for="register-un">{i18n.str`Username:`}</label>
</p>
<div>
<label for="username" class="block text-sm font-medium leading-6 text-gray-900">
<i18n.Translate>Username</i18n.Translate>
<b style={{ color: "red" }}> *</b>
</label>
<div class="mt-2">
<input
id="register-un"
name="register-un"
autoFocus
type="text"
placeholder="Username"
autocomplete="username"
name="username"
id="username"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
value={username ?? ""}
enterkeyhint="next"
placeholder="identification"
autocomplete="username"
required
onInput={(e): void => {
setUsername(e.currentTarget.value);
}}
@ -105,16 +217,26 @@ function RegistrationForm({ onComplete }: { onComplete: () => void }): VNode {
message={errors?.username}
isDirty={username !== undefined}
/>
<p class="unameFieldLabel registerFieldLabel formFieldLabel">
<label for="register-pw">{i18n.str`Password:`}</label>
</p>
</div>
</div>
<div>
<div class="flex items-center justify-between">
<label for="password" class="block text-sm font-medium leading-6 text-gray-900">
<i18n.Translate>Password</i18n.Translate>
<b style={{ color: "red" }}> *</b>
</label>
</div>
<div class="mt-2">
<input
type="password"
name="register-pw"
id="register-pw"
placeholder="Password"
autocomplete="new-password"
name="password"
id="password"
autocomplete="current-password"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
enterkeyhint="send"
value={password ?? ""}
placeholder="Password"
required
onInput={(e): void => {
setPassword(e.currentTarget.value);
@ -124,17 +246,26 @@ function RegistrationForm({ onComplete }: { onComplete: () => void }): VNode {
message={errors?.password}
isDirty={password !== undefined}
/>
<p class="unameFieldLabel registerFieldLabel formFieldLabel">
<label for="register-repeat">{i18n.str`Repeat Password:`}</label>
</p>
</div>
</div>
<div>
<div class="flex items-center justify-between">
<label for="register-repeat" class="block text-sm font-medium leading-6 text-gray-900">
<i18n.Translate>Repeat password</i18n.Translate>
<b style={{ color: "red" }}> *</b>
</label>
</div>
<div class="mt-2">
<input
type="password"
style={{ marginBottom: 8 }}
name="register-repeat"
id="register-repeat"
autocomplete="new-password"
placeholder="Same password"
autocomplete="current-password"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
enterkeyhint="send"
value={repeatPassword ?? ""}
placeholder="Same password"
required
onInput={(e): void => {
setRepeatPassword(e.currentTarget.value);
@ -144,64 +275,127 @@ function RegistrationForm({ onComplete }: { onComplete: () => void }): VNode {
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();
</div>
</div>
if (!username || !password) return;
try {
const credentials = { username, password };
await register(credentials);
setUsername(undefined);
setPassword(undefined);
setRepeatPassword(undefined);
backend.logIn(credentials);
onComplete();
} catch (error) {
if (error instanceof RequestError) {
notifyError(
buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) =>
status === HttpStatusCode.Conflict
? i18n.str`That username is already taken`
: undefined,
}),
);
} else {
notifyError({
title: i18n.str`Operation failed, please report`,
description:
error instanceof Error
? error.message
: JSON.stringify(error),
});
}
}
<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);
}}
>
{i18n.str`Register`}
</button>
{/* FIXME: should use a different color */}
<button
class="pure-button pure-button-secondary btn-cancel"
/>
{/* <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) => {
e.preventDefault();
setUsername(undefined);
setPassword(undefined);
setRepeatPassword(undefined);
onComplete();
e.preventDefault()
onCancel()
}}
>
{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>
</div>
</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>
</article>
</div>
</Fragment>
);
}

View File

@ -1,110 +0,0 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { createHashHistory } from "history";
import { VNode, h } from "preact";
import { Route, Router, route } from "preact-router";
import { useEffect, useMemo, useState } from "preact/hooks";
import { BankFrame } from "./BankFrame.js";
import { BusinessAccount } from "./BusinessAccount.js";
import { HomePage, WithdrawalOperationPage } from "./HomePage.js";
import { PublicHistoriesPage } from "./PublicHistoriesPage.js";
import { RegistrationPage } from "./RegistrationPage.js";
export function Routing(): VNode {
const history = createHashHistory();
return (
<BankFrame
goToBusinessAccount={() => {
route("/business");
}}
>
<Router history={history}>
<Route
path="/operation/:wopid"
component={({ wopid }: { wopid: string }) => (
<WithdrawalOperationPage
operationId={wopid}
onContinue={() => {
route("/account");
}}
onLoadNotOk={() => {
route("/account");
}}
/>
)}
/>
<Route
path="/public-accounts"
component={() => <PublicHistoriesPage />}
/>
<Route
path="/register"
component={() => (
<RegistrationPage
onComplete={() => {
route("/account");
}}
/>
)}
/>
<Route
path="/account"
component={() => (
<HomePage
onPendingOperationFound={(wopid) => {
route(`/operation/${wopid}`);
}}
onRegister={() => {
route("/register");
}}
/>
)}
/>
<Route
path="/business"
component={() => (
<BusinessAccount
onClose={() => {
route("/account");
}}
onRegister={() => {
route("/register");
}}
onLoadNotOk={() => {
route("/account");
}}
/>
)}
/>
<Route default component={Redirect} to="/account" />
</Router>
</BankFrame>
);
}
function Redirect({ to }: { to: string }): VNode {
useEffect(() => {
route(to, true);
}, []);
return <div>being redirected to {to}</div>;
}
export function assertUnreachable(x: never): never {
throw new Error("Didn't expect to get here");
}

View File

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

View File

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

View File

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

View File

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

View File

@ -15,15 +15,16 @@
*/
import {
Amounts,
HttpStatusCode,
Logger,
WithdrawUriResult,
parsePaytoUri
} 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 { Loading } from "../components/Loading.js";
import { useWithdrawalDetails } from "../hooks/access.js";
import { notifyInfo } from "../hooks/notification.js";
import { useSettings } from "../hooks/settings.js";
import { handleNotOkResult } from "./HomePage.js";
import { QrCodeSection } from "./QrCodeSection.js";
@ -33,8 +34,7 @@ const logger = new Logger("WithdrawalQRCode");
interface Props {
withdrawUri: WithdrawUriResult;
onContinue: () => void;
onLoadNotOk: () => void;
onClose: () => void;
}
/**
* Offer the QR code (and a clickable taler://-link) to
@ -43,27 +43,15 @@ interface Props {
*/
export function WithdrawalQRCode({
withdrawUri,
onContinue,
onLoadNotOk,
onClose,
}: Props): VNode {
const [settings, updateSettings] = useSettings();
function clearCurrentWithdrawal(): void {
updateSettings("currentWithdrawalOperationId", undefined);
onContinue();
}
const { i18n } = useTranslationContext();
const result = useWithdrawalDetails(withdrawUri.withdrawalOperationId);
if (!result.ok) {
if (result.loading) {
return <Loading />;
}
if (
result.type === ErrorType.CLIENT &&
result.status === HttpStatusCode.NotFound
) {
return <div>operation not found</div>;
}
onLoadNotOk();
return handleNotOkResult(i18n)(result);
}
const { data } = result;
@ -87,8 +75,7 @@ export function WithdrawalQRCode({
style={{ float: "right" }}
onClick={async (e) => {
e.preventDefault();
clearCurrentWithdrawal()
onContinue()
onClose()
}}>
{i18n.str`Continue`}
</a>
@ -98,56 +85,76 @@ export function WithdrawalQRCode({
}
if (data.confirmation_done) {
return <section id="main" class="content">
<h1 class="nav">{i18n.str`Operation completed`}</h1>
<section id="assets" style={{maxWidth: 400, marginLeft: "auto", marginRight:"auto"}}>
<p>
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">
<div>
<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 GNU Taler Exchange bank's account is completed, now the
exchange will send the requested amount into your GNU Taler wallet.
The wire transfer to the Taler operator has been initiated. You will soon receive the requested amount in your 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"
</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();
clearCurrentWithdrawal()
onContinue()
onClose()
}}>
{i18n.str`Continue`}
</a>
<i18n.Translate>Done</i18n.Translate>
</button>
</div>
</div>
</section>
</section>
}
}
if (!data.selection_done) {
return (
<QrCodeSection
withdrawUri={withdrawUri}
onAborted={() => {
notifyInfo(i18n.str`Operation canceled`);
clearCurrentWithdrawal()
onContinue()
onClose()
}}
/>
);
}
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 (
<WithdrawalConfirmationQuestion
withdrawUri={withdrawUri}
details={{
account,
reserve: data.selected_reserve_pub,
amount: Amounts.parseOrThrow(data.amount)
}}
onAborted={() => {
notifyInfo(i18n.str`Operation canceled`);
clearCurrentWithdrawal()
onContinue()
onClose()
}}
/>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -1,71 +0,0 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
.field {
&.has-check {
.field-body {
margin-top: $default-padding * 0.125;
}
}
.control {
.mdi-24px.mdi-set,
.mdi-24px.mdi:before {
font-size: inherit;
}
}
}
.upload {
.upload-draggable {
display: block;
}
}
.input,
.textarea,
select {
box-shadow: none;
&:focus,
&:active {
box-shadow: none !important;
}
}
.switch input[type="checkbox"] + .check:before {
box-shadow: none;
}
.switch,
.b-checkbox.checkbox {
input[type="checkbox"] {
&:focus + .check,
&:focus:checked + .check {
box-shadow: none !important;
}
}
}
.b-checkbox.checkbox input[type="checkbox"],
.b-radio.radio input[type="radio"] {
& + .check {
border: $checkbox-border;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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